Ce qui suit montre une implémentation simple de code impliquant diverses manipulations de fonctions, et ce dans quatre langages distincts soit Java, JavaScript, C# et C++. Nous débuterons par un comparatif côte à côte des quatre implémentations, suite à quoi nous reviendrons sur les détails de chacune (détail de la version C#, détail de la version C++, détail de la version Java, détail de la version JavaScript.
Ce comparatif est particulier en ce sens où les quatre langages sont plutôt différents les uns des autres dans leur traitement des fonctions. Pour cette raison, nous donnerons un aperçu des mécanismes avec lesquels chacun manipule les fonctions, sans prétendre à l'exhaustivité, et sans prétendre non plus proposer des programmes dont les résultats seront les mêmes peu importe le langage.
Placées côte à côte, les implémentations que nous examinerons dans ces quatre langages vont comme suit.
Implémentation C# | Implémentation C++ | Implémentation Java | Implémentation JavaScript |
---|---|---|---|
|
|
|
|
Exécution, implémentation C# | Exécution, implémentation C++ | Exécution, implémentation Java | Exécution, implémentation JavaScript |
|
|
|
Pour testA() :
Pour testB() :
Pour testC() :
|
Cette diversité est intéressante étant donné les similitudes syntaxiques générales entre ces quatre langages : noms des types primitifs, structures de contrôle de base (if, while, for, try, etc.) et recours à des accolades pour délimiter les blocs, entre autres choses.
Quelques notes d'ordre général :
Clairement, on parle ici d'un créneau pour lequel ces quelques langages ont pris des chemins bien différents les uns des autres.
Le détail de notre implémentation à l'aide du langage C# suit.
En C#, depuis les tout débuts, la métephore privilégiée pour manipuler des méthodes en tant que méthodes est le délégué. Représenté par le type delegate dans le langage, un délégué est une entité polymorphique sur la base de la signature. En C#, ceci couvre à la fois les méthodes d'instance et les méthodes de classe. Dans cet exemple, un delg est un délégué qui peut pointer sur n'importe quelle méthode prenant en paramètre un double et retournant un int. |
|
La classe X contient une propriété privée, un constructeur paramétrique, et surtout (pour notre exemple) une méthode d'instance F et une méthode de classe G, toutes deux ayant la même signature (qui correspond à celle supportée par le type delg, plus haut). |
|
Dans le code de test, j'utilise une méthode de classe Tester() prenant en paramètre un Func<double,int> et un double. Un Func<T0,T1,...,R> est une sorte de délégué générique prenant en paramètre un T0, un T1, ... et retournant un R. Dans notre cas, le Func représente une fonction unaire (à un paramètre), et Tester(f,arg) appelle f(arg) pour en afficher le résultat. |
|
La méthode de classe TesterGénérique() se comporte comme le fait Tester(), mais ne se limite pas à un Func<double,int>, acceptant plutôt un Func<T,U> pour deux types T et U quelconques. |
|
La méthode de classe Créer() retourne un objet (une λ, concrètement) pouvant s'utiliser comme un délégué unaire prenant un double et retournant un int. Puisque le type d'une λ est inaccessible au programme, nous entreposons cet objet dans un Func de signature adéquate. |
|
La méthode de classe Générateur(n) retourne un objet (une λ, concrètement) se comportant comme une opération nullaire (sans paramètres). L'objet en question capture une copie de n dans une fermeture; à chaque utilisation de cet objet comme une fonction, il retournera n++, modifiant chaque fois sa copie interne de n. |
|
Le programme de test procède aux opérations suivantes :
Remarquez que pour les fonctions Créer() et Générateur(), j'ai utilisé var pour indiquer le type de l'objet retourné. En utilisant var, le type de la variable est déduit du type de l'expression utilisée pour l'initialiser. |
|
Le détail de notre implémentation à l'aide du langage C++ suit.
Pour les besoins de nos exemples, nous définirons plusieurs fonctions. L'une d'elles sera tite_somme(), qui calculera la somme de trois entiers et retournera cette somme. On aurait bien sûr pu y aller avec une version plus générale, par exemple :
... ou encore, avec une Fold Expression :
... mais pour nos besoins, cela n'aurait rien ajouté au propos. |
|
La fonction tester() prend en paramètre une opération f de signature int(double), donc prenant en paramètre un double et retournant un int, de même qu'un double nommé arg, et appelle f(arg). Un std::function est un proche cousin d'un délégué en C#, en ce sens qu'un objet de ce type encapsule une entité appelable sur la base d'une signature précise. Les deux sont distincts l'un de l'autre sur le plan de l'utilisation avec une méthode d'instance, cela dit : en C#, le lien entre this et sa méthode est encapsulé dans le délégué, alors qu'en C++, this apparaît comme premier paramètre (habituellement silencieux) dans la signature de la méthode. Nous montrerons un exemple plus bas. |
|
La fonction tester_generique() prend en paramètre une opération f et un paramètre arg, et appelle f(arg). Elle ne connaît pas de prime abord les types de l'un et de l'autre, mais il importe pour elle qu'utiliser f comme une fonction et lui passer arg en paramètre soit légal. Ceci permet entre autres un cas où f est de signature std::string(int) et où arg est un short, car il est légal de passer un short en paramètre à une fonction s'attendant à recevoir un int. |
|
Les fonctions tester_variadique(f, args...) et afficher_params(args...) permettent respectivement d'appeler f(args...) et d'afficher les valeurs de chaque paramètre dans args... On peut dire de tester_variadique() qu'il s'agit en quelque sorte d'une généralisation de tester_generique(). |
|
La fonction creer() retourne une λ encapsulée dans un function<int(double)> et pouvant s'utiliser comme telle. Puisque le type d'une λ est inaccessible au programme, nous entreposons cet objet dans un function de signature adéquate. |
|
La fonction generateur(n) retourne une λ encapsulée dans un function<int()> et pouvant s'utiliser comme telle (sans paramètres). L'objet en question capture une copie de n dans une fermeture; à chaque utilisation de cet objet comme une fonction, il retournera n++, modifiant chaque fois sa copie interne de n. Notez qu'en C++, par défaut, l'opérateur () d'une λ est une méthode const. Pour éviter cette situation, dans un cas comme celui-ci où nous souhaitons que les attributs capturés par copie à la construction de la λ soient modifiables, il convient de qualifier la λ de mutable. |
|
Nous utiliserons plusieurs formats de fonctions pour les fins de notre démonstration. La fonction fglob() est une fonction globale de signature int(double). |
|
Dans la classe X, on trouve deux méthodes de signature int(double), soit la méthode d'instance f et la méthode de classe g. Notez que dans le cas de X::f(), la signature est trompeuse car en réalité, le premier paramètre passé à l'appel de cette fonction est this, ici de type const X*. Ceci se reflétera dans notre utilisation de std::function pour l'appeler, un peu plus bas. |
|
Il est souvent utile de définir un type (avec typedef) pour un pointeur de fonction; une raison pour cette habitude répandue est que la syntaxe des pointeurs de fonctions, héritée de C, est quelque peu lourde (le nom du type apparaît en plein centre de l'expression!). Consolez-vous cependant : la syntaxe d'un pointeur de méthode d'instance est pire encore!. Ici, le type pfct est un pointeur sur une fonction prenant un double en paramètre et retournant un int. |
|
Depuis C++ 11, une nouvelle écriture, plus générale, est possible avec using. Ici, le type pfct11 est un pointeur sur une fonction prenant un double en paramètre et retournant un int. Bien que les écritures pour pfct et pfct11 soient équivalentes sur le plan opératoire, il est probable que celle utilisée pour pfct11 vous semble plus lisible. |
|
Le programme de test commence en utilisant un pfct et un pfct11 pour mener à fglob et montre qu'appeler fglob de manière directe ou indirecte, que ce soit par l'un ou l'autre de ces types, donne exactement le même résultat en pratique. Si nous avions voulu déclarer un pointeur de fonction globale sans définir au préalable un type spécial pour ce faire, une écriture correcte aurait été :
Vous conviendrez que ce n'est pas une syntaxe immédiatement accessible à tous et chacun. |
|
Une méthode de classe n'a pas de paramètre this silencieux; en ce sens, il s'agit d'une fonction globale, bien que son nom soit qualifié par celui de la classe à laquelle elle appartient. Pour cette raison, nos types pfct et pfct11 permettent tous deux de prendre l'adresse d'une méthode de classe ayant la signature qu'ils décrivent. |
|
Une méthode d'instance a par contre une syntaxe plus complexe, du fait qu'il faut tenir compte du passage de this (en tant que premier paramètre, habituellement implicite, à la fonction). Ainsi, à droite, pmeth est une variable du type « pointeur sur une méthode d'un X qualifié const, prenant en paramètre un double et retournant un int », alors que l'adresse de la méthode f dans un X s'écrit &X::f. Notez qu'en exprimant &X::f, nous savons où la méthode f d'un X se trouve peu importe ce X, mais pas à quelle instance de X elle se rattache (pas quel est le this à lui passer). À l'utilisation, on peut appeler la méthode f d'un X spécifique avec son opérateur .* s'il s'agit d'un objet ou d'une référence, ou à travers son opérateur ->* s'il s'agit d'un pointeur. Oui, je sais, ces syntaxes sont épouvantables, mais on parle ici de code pour spécialistes, pas d'opérateurs « de tous les jours ». Notez l'utilisation de parenthèses, nécessaire dans ce cas-ci pour réguler la priorité des opérateurs. |
|
Depuis C++ 11, il est plus « normal » d'avoir recours à un std::function applicable à la signature souhaitée pour manipuler fonctions, méthodes et λ. Quelques exemples sont présentés ici. Portez en particulier attention à meth, qui déclare un std::function applicable à une méthode d'instance d'un const X : la signature du function prend deux paramètres plutôt qu'un, le premier étant le this pour cette méthode (de type const X), et à l'utilisation de meth, l'instance sur laquelle le pointeur de méthode doit être utilisé est explicitement passée en tant que paramètre. |
|
Il est possible de ramener la syntaxe d'utilisation d'un pointeur de méthode unaire à celle d'une fonction unaire en liant le premier paramètre lors de l'appel à une instance spécifique. Ici, nous y arrivons en utilisant std::bind(), et en indiquant que x doit être utilisé en tant que premier paramètre à l'appel de meth (le std::placeholders::_1 représente conceptuellement ce premier paramètre). |
|
Enfin, quelques exemples d'utilisation de fonctions en tant que paramètres à des fonctions sont proposés, pour compléter le portrait. |
|
Le détail de notre implémentation à l'aide du langage Java suit.
Contrairement à C# ou à C++, Java ne supporte ni les délégués, ni les λ – les λ sont l'un des ajouts les plus attendus pour la version 8 du langage. Ce qui ressemble le plus à un délégué en Java est une classe anonyme, implémentant une interface donnée. Nous en utiliserons deux ici, soit Appelable, dont la méthode f() prend un double et retourne un int, et AppelableGénérique<T,U>, dont la méthode f() prend un T et retourne un U. |
|
Pour les besoins de la démonstration, nous utilisons une classe X (qualifiée static car elle sera utilisée dans la méthode de classe main()), qui a un attribut privé, un constructeur paramétrique public et (surtout, pour nos fins) une méthode d'instance f et une méthode de classe g, toutes deux de même signature. |
|
Nous utiliserons deux méthodes de test pour nos interfaces concrète et générique, soit tester(app,arg) qui appellera app.f(arg) et testerGénérique<T,U>(app,arg) qui fera de même, mais sur un app générique. |
|
La méthode de classe créer() montre un idiome de Java, soit l'instanciation « juste à temps » d'une classe anonyme. Par classe anonyme, on entend une classe qui n'a pas de nom explicite dans le programme. Ici, l'expression new Appelable() { ... } crée un dérivé d'une classe anonyme implémentant Appelable, ce qui est légal dans la mesure où toutes les méthodes déclarées dans Appelable y sont bel et bien implémentées. Ici, on parle de la méthode f prenant en paramètre un double et retournant un int. Notez qu'une classe anonyme ne peut avoir de constructeur paramétrique. Ceci aura des conséquences dans la méthode générer(n), plus bas. |
|
L'interface Générateur expose un service prochain() retournant un entier. Elle pourrait servir de porte d'entrée pour les services d'un générateur d'entiers séquentiels ou aléatoires, par exemple. |
|
La méthode de classe générer(n) retourne une instance d'une classe interne à la méthode mais implémentant Générateur. La raison pour laquelle nous devons utiliser une classe nommée ici est que nous ne pouvons utiliser de constructeur paramétrique pour une classe anonyme si celle-ci instancie une implémentation d'une interface. |
|
Le programme de test procède comme suit :
|
|
Le détail de notre implémentation à l'aide du langage JavaScript suit. Puisque JavaScript est de prime abord un langage fonctionnel, les fonctions y sont une entité normale et naturelle à manipuler : il y est simple et idiomatique d'écrire des fonctions qui prennent des fonctions en paramètre et qui retournent des fonctions.
L'implémentation proposée ici se décline en trois temps. Le premier est fait :
Dans testA(), la fonction passée à appliquer() est une variable locale nommée f, associée à une fonction unaire. Cet exemple montre qu'il est simple de mettre une fonction dans une variable et de passer celle-ci en paramètre à une autre fonction qui, enfin, l'appellera. |
|
Le deuxième est fait :
|
|
Enfin, le troisième est fait :
Ici, il faut comprendre que generer(n) n'incrémente pas n; en pratique, generer(n) crée et retourne une fonction qui incrémentera une copie de n à chaque appel. La fonction retournée a donc un état (la copie de n), un peu comme un objet. |
|
En espérant que ce tour d'horizon succinct vous ait intéressé, malgré son caractère non-exhaustif.