web-dev-qa-db-fra.com

Pourquoi la plupart des langages de programmation ne prennent-ils en charge que le retour d'une seule valeur d'une fonction?

Y a-t-il une raison pour laquelle les fonctions de la plupart des langages de programmation (?) Sont conçues pour prendre en charge un certain nombre de paramètres d'entrée mais une seule valeur de retour?

Dans la plupart des langues, il est possible de "contourner" cette limitation, par exemple en utilisant des paramètres de sortie, en renvoyant des pointeurs ou en définissant/renvoyant des structures/classes. Mais il semble étrange que les langages de programmation n'aient pas été conçus pour prendre en charge plusieurs valeurs de retour d'une manière plus "naturelle".

Y a-t-il une explication à cela?

123
M4N

Certaines langues, comme Python , prennent en charge plusieurs valeurs de retour nativement, tandis que certaines langues comme C # les prennent en charge via leurs bibliothèques de base.

Mais en général, même dans les langues qui les prennent en charge, plusieurs valeurs de retour ne sont pas souvent utilisées car elles sont bâclées:

  • Les fonctions qui renvoient plusieurs valeurs sont difficile à nommer clairement .
  • Il est facile de confondre l'ordre des valeurs de retour

    (password, username) = GetUsernameAndPassword()  
    

    (Pour cette même raison, beaucoup de gens évitent d'avoir trop de paramètres pour une fonction; certains vont même jusqu'à dire qu'une fonction ne devrait jamais avoir deux paramètres du même type!)

  • Les langages POO ont déjà une meilleure alternative à plusieurs valeurs de retour: classes.
    Ils sont plus fortement typés, ils gardent les valeurs de retour regroupées en une seule unité logique et ils gardent les noms de (propriétés de) les valeurs de retour cohérentes dans toutes les utilisations.

Le seul endroit où ils sont est assez pratique dans les langages (comme Python) où plusieurs valeurs de retour d'une fonction peuvent être utilisées comme plusieurs paramètres d'entrée pour une autre. Mais, les cas d'utilisation où c'est une meilleure conception que d'utiliser une classe sont assez minces.

Parce que les fonctions sont des constructions mathématiques qui effectuent un calcul et renvoient un résultat. En effet, une grande partie de ce qui est "sous le capot" de quelques langages de programmation se concentre uniquement sur une entrée et une sortie, plusieurs entrées n'étant qu'une mince enveloppe autour de l'entrée - et lorsqu'une sortie à valeur unique ne fonctionne pas, en utilisant une seule une structure cohérente (ou Tuple, ou Maybe) sera la sortie (bien que cette valeur de retour "unique" soit composée de plusieurs valeurs).

Cela n'a pas changé car les programmeurs ont trouvé que les paramètres out étaient des constructions maladroites qui ne sont utiles que dans un ensemble limité de scénarios. Comme pour beaucoup d'autres choses, le support n'est pas là car le besoin/la demande n'est pas là.

54
Telastyn

En mathématiques, une fonction "bien définie" est celle où il n'y a qu'une seule sortie pour une entrée donnée (en remarque, vous ne pouvez avoir qu'une seule fonction d'entrée et toujours obtenir sémantiquement plusieurs entrées en utilisant curry ).

Pour les fonctions à valeurs multiples (par exemple, la racine carrée d'un entier positif, par exemple), il suffit de renvoyer une collection ou une séquence de valeurs.

Pour les types de fonctions dont vous parlez (c'est-à-dire les fonctions qui renvoient plusieurs valeurs, de types différents ) Je le vois légèrement différemment que vous ne le semblez : Je vois la nécessité/l'utilisation de params comme solution de contournement pour une meilleure conception ou une structure de données plus utile. Par exemple, je préférerais que les méthodes *.TryParse(...) renvoient un Maybe<T> monad au lieu d'utiliser un paramètre out. Pensez à ce code en F #:

let s = "1"
match tryParse s with
| Some(i) -> // do whatever with i
| None -> // failed to parse

Le support du compilateur/IDE/analyse est très bon pour ces constructions. Cela résoudrait une grande partie du "besoin" de paramétrages. Pour être tout à fait honnête, je ne peux penser à aucune autre méthode à part où cela ne serait pas la solution.

Pour d'autres scénarios - ceux dont je ne me souviens pas - un simple Tuple suffit.

35
Steven Evers

En plus de ce qui a déjà été dit lorsque vous regardez les paradigmes utilisés dans Assembly lorsqu'une fonction retourne, elle laisse un pointeur sur l'objet renvoyé dans un registre spécifique. S'ils utilisaient des registres variables/multiples, la fonction appelante ne saurait pas où obtenir la ou les valeurs renvoyées si cette fonction se trouvait dans une bibliothèque. Cela rendrait donc la liaison aux bibliothèques difficile et au lieu de définir un nombre arbitraire de pointeurs retournables, ils allaient avec un. Les langues de niveau supérieur n'ont pas tout à fait la même excuse.

23
David

De nombreux cas d'utilisation où vous auriez utilisé plusieurs valeurs de retour dans le passé ne sont tout simplement plus nécessaires avec les fonctionnalités du langage moderne. Vous souhaitez renvoyer un code d'erreur? Lancez une exception ou renvoyez un Either<T, Throwable>. Vous souhaitez renvoyer un résultat facultatif? Retourne un Option<T>. Vous souhaitez retourner l'un des types suivants? Retourne un Either<T1, T2> ou une union balisée.

Et même dans les cas où vous avez vraiment besoin pour renvoyer plusieurs valeurs, les langages modernes prennent généralement en charge les tuples ou une sorte de structure de données (liste, tableau, dictionnaire) ou des objets ainsi qu'une certaine forme de liaison destructurante ou la correspondance de modèles, ce qui rend le regroupement de vos multiples valeurs en une seule valeur, puis la déstructuration à nouveau en plusieurs valeurs triviales.

Voici quelques exemples de langues qui ne le font pas prennent en charge le renvoi de plusieurs valeurs. Je ne vois pas vraiment comment l'ajout de la prise en charge de plusieurs valeurs de retour les rendrait beaucoup plus expressifs pour compenser le coût d'une nouvelle fonctionnalité de langue.

Ruby

def foo; return 1, 2, 3 end

one, two, three = foo

one
# => 1

three
# => 3

Python

def foo(): return 1, 2, 3

one, two, three = foo()

one
# >>> 1

three
# >>> 3

Scala

def foo = (1, 2, 3)

val (one, two, three) = foo
// => one:   Int = 1
// => two:   Int = 2
// => three: Int = 3

Haskell

let foo = (1, 2, 3)

let (one, two, three) = foo

one
-- > 1

three
-- > 3

Perl6

sub foo { 1, 2, 3 }

my ($one, $two, $three) = foo

$one
# > 1

$three
# > 3
18
Jörg W Mittag

La vraie raison pour laquelle une seule valeur de retour est si populaire est les expressions qui sont utilisées dans de nombreuses langues. Dans n'importe quelle langue où vous pouvez avoir une expression comme x + 1, Vous pensez déjà en termes de valeurs de retour uniques parce que vous évaluez une expression dans votre tête en la décomposant en morceaux et en décidant de la valeur de chaque pièce. Vous regardez x et décidez que sa valeur est 3 (par exemple), et vous regardez 1 puis vous regardez x + 1 Et vous mettez tout cela ensemble pour décider que la valeur de l'ensemble est 4. Chaque partie syntaxique de l'expression a une valeur, pas un autre nombre de valeurs; c'est la sémantique naturelle des expressions que tout le monde attend. Même lorsqu'une fonction retourne une paire de valeurs, elle renvoie toujours vraiment une valeur qui fait le travail de deux valeurs, car l'idée d'une fonction qui renvoie deux valeurs qui ne sont pas en quelque sorte enveloppées dans une seule collection est trop bizarre.

Les gens ne veulent pas gérer la sémantique alternative qui serait requise pour que les fonctions retournent plus d'une valeur. Par exemple, dans un langage basé sur la pile comme Forth , vous pouvez avoir n'importe quel nombre de valeurs de retour car chaque fonction modifie simplement le haut de la pile, faisant apparaître des entrées et poussant des sorties à volonté. C'est pourquoi Forth n'a pas le genre d'expressions que les langues normales ont.

Perl est un autre langage qui peut parfois agir comme si les fonctions renvoyaient plusieurs valeurs, même si elles sont généralement considérées comme renvoyant une liste. La façon dont les listes "interpolent" en Perl nous donne des listes comme (1, foo(), 3) Qui pourraient avoir 3 éléments comme la plupart des gens qui ne connaissent pas Perl pourraient s'y attendre, mais qui pourraient tout aussi facilement n'en avoir que 2 éléments, 4 éléments, ou tout plus grand nombre d'éléments en fonction de foo(). Les listes en Perl sont aplaties de sorte qu'une liste syntaxique n'a pas toujours la sémantique d'une liste; il peut s'agir simplement d'une partie d'une liste plus longue.

Une autre façon pour que les fonctions retournent plusieurs valeurs serait d'avoir une sémantique d'expression alternative où toute expression peut avoir plusieurs valeurs et chaque valeur représente une possibilité. Reprenez x + 1, Mais cette fois, imaginez que x a deux valeurs {3, 4}, alors les valeurs de x + 1 Seraient {4, 5}, et les valeurs de x + x serait {6, 8}, ou peut-être {6, 7, 8}, selon qu'une évaluation est autorisée à utiliser plusieurs valeurs pour x. Un langage comme celui-ci pourrait être implémenté en utilisant le backtracking un peu comme Prolog utilise pour donner plusieurs réponses à une requête.

En bref, un appel de fonction est une seule unité syntaxique et une seule unité syntaxique a une seule valeur dans l'expression sémantique que nous connaissons et aimons tous. Toute autre sémantique vous obligerait à des façons étranges de faire les choses, comme Perl, Prolog ou Forth.

12
Geo

Comme suggéré dans cette réponse , c'est une question de support matériel, bien que la tradition dans la conception de langage joue également un rôle.

lorsqu'une fonction retourne, elle laisse un pointeur sur l'objet renvoyé dans un registre spécifique

Des trois premières langues, Fortran, LISP et COBOL, la première a utilisé une seule valeur de retour car elle était calquée sur les mathématiques. Le second a renvoyé un nombre arbitraire de paramètres de la même manière qu'il les a reçus: sous forme de liste (on pourrait également affirmer qu'il n'a passé et renvoyé qu'un seul paramètre: l'adresse de la liste). Le troisième retourne zéro ou une valeur.

Ces premières langues ont beaucoup influencé la conception des langues qui les ont suivies, bien que la seule qui ait renvoyé plusieurs valeurs, LISP, n'ait jamais gagné en popularité.

Lorsque C est arrivé, bien qu'influencé par les langages précédents, il a mis l'accent sur une utilisation efficace des ressources matérielles, en gardant une association étroite entre ce que le langage C a fait et le code machine qui l'a implémenté. Certaines de ses fonctionnalités les plus anciennes, telles que les variables "auto" vs "register", sont le résultat de cette philosophie de conception.

Il convient également de souligner que le langage de l'Assemblée était largement populaire jusqu'aux années 80, date à laquelle il a finalement commencé à être progressivement supprimé du développement général. Les personnes qui ont écrit des compilateurs et créé des langages étaient familières avec Assembly, et, pour la plupart, respectaient ce qui fonctionnait le mieux là-bas.

La plupart des langues qui s'écartaient de cette norme n'ont jamais trouvé beaucoup de popularité et, par conséquent, n'ont jamais joué un rôle important influençant les décisions des concepteurs de langues (qui, bien sûr, ont été inspirés par ce qu'ils savaient).

Voyons donc le langage de l'assemblage. Regardons d'abord le 6502 , un microprocesseur de 1975 qui était célèbre pour les micro-ordinateurs Apple II et VIC-20. Il était très faible par rapport à ce qui était utilisé dans l'ordinateur central et les mini-ordinateurs de l'époque, bien que puissants par rapport aux premiers ordinateurs de 20, 30 ans auparavant, à l'aube des langages de programmation.

Si vous regardez la description technique, elle a 5 registres plus quelques drapeaux d'un bit. Le seul registre "complet" était le compteur de programmes (PC) - ce registre pointe vers la prochaine instruction à exécuter. Les autres registres contiennent l'accumulateur (A), deux registres "index" (X et Y) et un pointeur de pile (SP).

L'appel d'un sous-programme place le PC dans la mémoire pointée par le SP, puis décrémente le SP. Le retour d'un sous-programme fonctionne à l'envers. On peut pousser et tirer d'autres valeurs sur la pile, mais il est difficile de se référer à la mémoire par rapport au SP, donc écrire sous-programmes rentrants était difficile. Cette chose que nous tenons pour acquise, appelant un sous-programme à tout moment que nous ressentons, n'était pas si courante sur cette architecture. Souvent, une "pile" distincte était créée afin que les paramètres et l'adresse de retour de sous-programme soient maintenus séparés.

Si vous regardez le processeur qui a inspiré le 6502, le 68 , il avait un registre supplémentaire, le registre d'index (IX), aussi large que le SP, qui pouvait recevoir la valeur du SP.

Sur la machine, appeler un sous-programme rentrant consistait à pousser les paramètres sur la pile, à pousser le PC, à changer le PC vers la nouvelle adresse, puis le sous-programme pousserait Pousserait ses variables locales sur la pile . Comme le nombre de variables et de paramètres locaux est connu, il est possible de les traiter par rapport à la pile. Par exemple, une fonction recevant deux paramètres et ayant deux variables locales ressemblerait à ceci:

SP + 8: param 2
SP + 6: param 1
SP + 4: return address
SP + 2: local 2
SP + 0: local 1

Il peut être appelé un certain nombre de fois car tout l'espace temporaire est sur la pile.

Le 808 , utilisé sur TRS-80 et un hôte de micro-ordinateurs basés sur CP/M pourrait faire quelque chose de similaire au 6800, en poussant SP sur la pile, puis popping sur son registre indirect, HL.

C'est une façon très courante d'implémenter les choses, et elle a obtenu encore plus de support sur les processeurs plus modernes, avec le pointeur de base qui facilite le vidage de toutes les variables locales avant de revenir.

Le problème, le, est comment retourner quoi que ce soit ? Les registres de processeur n'étaient pas très nombreux au début, et il fallait souvent en utiliser certains, même pour savoir quel morceau de mémoire adresser. Retourner des choses sur la pile serait compliqué: il faudrait tout faire éclater, sauvegarder le PC, pousser les paramètres de retour (qui seraient stockés où entre-temps?), Puis pousser à nouveau le PC et revenir.

Donc, ce qui était généralement fait était de réserver un registre pour la valeur de retour. Le code appelant savait que la valeur de retour serait dans un registre particulier, qui devrait être préservée jusqu'à ce qu'elle puisse être enregistrée ou utilisée.

Examinons un langage qui autorise plusieurs valeurs de retour: Forth. Ce que Forth fait, c'est de garder une pile de retour (RP) et une pile de données (SP) distinctes, de sorte qu'une fonction devait simplement faire apparaître tous ses paramètres et laisser les valeurs de retour sur la pile. Étant donné que la pile de retour était séparée, elle ne gênait pas.

En tant que personne qui a appris le langage d'assemblage et Forth au cours des six premiers mois d'expérience avec les ordinateurs, les valeurs de retour multiples me semblent tout à fait normales. Des opérateurs tels que Forth's /mod, qui renvoie la division entière et le reste, semble évident. D'un autre côté, je peux facilement voir comment quelqu'un dont la première expérience était l'esprit C trouve ce concept étrange: cela va à l'encontre de leurs attentes enracinées à propos de ce qu'est une "fonction".

En ce qui concerne les mathématiques ... eh bien, je programmais des ordinateurs bien avant de commencer à travailler dans des cours de mathématiques. Il y a il y a une section entière de CS et de langages de programmation qui est influencée par les mathématiques, mais, là encore, il y a une section entière qui ne l'est pas.

Nous avons donc une confluence de facteurs où les mathématiques ont influencé la conception précoce du langage, où les contraintes matérielles ont dicté ce qui était facilement implémenté et où les langages populaires ont influencé la façon dont le matériel a évolué (la machine LISP et les processeurs de la machine Forth étaient des compétences routières dans ce processus).

9
Daniel C. Sobral

Les langages fonctionnels que je connais peuvent renvoyer plusieurs valeurs facilement grâce à l'utilisation de tuples (dans les langages typés dynamiquement, vous pouvez même utiliser des listes). Les tuples sont également pris en charge dans d'autres langues:

f :: Int -> (Int, Int)
f x = (x - 1, x + 1)

// Even C++ have tuples - see Boost.Graph for use
std::pair<int, int> f(int x) {
  return std::make_pair(x - 1, x + 1);
}

Dans l'exemple ci-dessus, f est une fonction renvoyant 2 pouces.

De même, ML, Haskell, F #, etc., peuvent également renvoyer des structures de données (les pointeurs sont trop bas pour la plupart des langues). Je n'ai pas entendu parler d'un langage GP moderne avec une telle restriction:

data MyValue = MyValue Int Int

g :: Int -> MyValue
g x = MyValue (x - 1, x + 1)

Enfin, les paramètres out peuvent être émulés même dans les langages fonctionnels par IORef. Il y a plusieurs raisons pour lesquelles il n'y a pas de support natif pour les variables out dans la plupart des langues:

  • Sémantique peu claire: La fonction suivante imprime-t-elle 0 ou 1? Je connais les langues qui imprimeraient 0 et celles qui imprimeraient 1. Il y a des avantages pour les deux (à la fois en termes de performances, ainsi que de correspondance avec le modèle mental du programmeur):

    int x;
    
    int f(out int y) {
      x = 0;
      y = 1;
      printf("%d\n", x);
    }
    f(out x);
    
  • Effets non localisés: Comme dans l'exemple ci-dessus, vous pouvez constater que vous pouvez avoir une longue chaîne et que la fonction la plus interne affecte l'état global. En général, il est plus difficile de raisonner sur les exigences de la fonction et si le changement est légal. Étant donné que la plupart des paradigmes modernes tentent de localiser les effets (encapsulation dans la POO) ou d'éliminer les effets secondaires (programmation fonctionnelle), cela entre en conflit avec ces paradigmes.

  • Étant redondant: Si vous avez des tuples, vous avez 99% de la fonctionnalité des paramètres out et 100% d'utilisation idiomatique. Si vous ajoutez des pointeurs au mélange, vous couvrez les 1% restants.

J'ai du mal à nommer une langue qui ne pourrait pas retourner plusieurs valeurs à l'aide d'un paramètre Tuple, class ou out (et dans la plupart des cas, 2 ou plusieurs de ces méthodes sont autorisées).

7
Maciej Piechotka

Je pense que c'est à cause de expressions, comme (a + b[i]) * c.

Les expressions sont composées de valeurs "singulières". Une fonction renvoyant une valeur singulière peut ainsi être directement utilisée dans une expression, à la place de l'une des quatre variables indiquées ci-dessus. Une fonction multi-sortie est au moins n pe maladroite dans une expression.

Personnellement, je pense que c'est la chose spéciale à propos d'une valeur de retour singulière. Vous pouvez contourner cela en ajoutant une syntaxe pour spécifier laquelle des multiples valeurs de retour que vous souhaitez utiliser dans une expression, mais elle est forcément plus maladroite que la bonne vieille notation mathématique, qui est concise et familière à tout le monde.

6
Roman Starkov

Cela complique un peu la syntaxe, mais il n'y a aucune bonne raison au niveau de l'implémentation de ne pas l'autoriser. Contrairement à certaines des autres réponses, le retour de plusieurs valeurs, lorsqu'elles sont disponibles, conduit à un code plus clair et plus efficace. Je ne peux pas compter la fréquence à laquelle j'ai souhaité pouvoir retourner un X et un Y, ou un booléen "succès" et une valeur utile.

4
ddyer

Dans la plupart des langues où les fonctions sont prises en charge, vous pouvez utiliser un appel de fonction partout où une variable de ce type peut être utilisée: -

x = n + sqrt(y);

Si la fonction renvoie plus d'une valeur, cela ne fonctionnera pas. Les langages typés dynamiquement tels que python vous permettront de le faire, mais, dans la plupart des cas, il affichera une erreur d'exécution à moins qu'il ne puisse résoudre quelque chose de sensé à faire avec un tuple au milieu d'une équation.

2
James Anderson

Je veux juste m'appuyer sur la réponse de Harvey. À l'origine, j'ai trouvé cette question sur un site de nouvelles techniques (arstechnica) et j'ai trouvé une explication étonnante qui, selon moi, répond vraiment au cœur de cette question et fait défaut dans toutes les autres réponses (sauf Harvey's):

L'origine du retour unique des fonctions réside dans le code machine. Au niveau du code machine, une fonction peut renvoyer une valeur dans le registre A (accumulateur). Toutes les autres valeurs de retour seront sur la pile.

Un langage qui prend en charge deux valeurs de retour le compilera en tant que code machine qui en renvoie une et place la seconde sur la pile. En d'autres termes, la deuxième valeur de retour finirait de toute façon comme paramètre de sortie.

C'est comme demander pourquoi l'affectation est une variable à la fois. Vous pourriez avoir un langage qui autorise a, b = 1, 2 par exemple. Mais cela finirait au niveau du code machine par a = 1 suivi de b = 2.

Il y a une certaine raison à ce que les constructions de langage de programmation ressemblent à ce qui se passera réellement lorsque le code sera compilé et exécuté.

1
user2202911