Traiter les fonctions comme des données

Quel que soit le langage de programmation, l’une des premières choses que l’on apprend à faire lors de la programmation d’un ordinateur est d’écrire une fonction (également appelée « méthode » ou « sous-programme »). Les fonctions sont un élément fondamental du code organisé : l’écriture de fonctions nous permet d’éviter de dupliquer encore et encore le même morceau de code. Au lieu d’écrire des sections de code dupliquées, nous appelons une seule fonction qui contient le code que nous voulons exécuter à plusieurs endroits (à condition de bien nommer la fonction, le nom de la fonction est également plus facile à lire que le code lui-même !). Si la section de code a besoin d’informations supplémentaires sur son contexte environnant pour s’exécuter, nous les transmettons à la fonction en tant que « paramètres », et si elle doit renvoyer quelque chose au reste du code une fois terminé, nous appelons cela une « valeur de retour » (ensemble, les paramètres et la valeur de retour sont appelés la « signature » de la fonction) ;

Parfois, nous devons transmettre des fonctions d’une partie du code à une autre partie du code. Cela peut sembler un concept étrange si nous sommes habitués à considérer les fonctions comme faisant partie d’une définition de classe plutôt que comme des objets à part entière. Mais à la base, les fonctions ne sont que des données - de la même manière que nous pouvons stocker un « entier » ou un « double » comme variable et le transmettre à notre programme, nous pouvons faire la même chose avec une fonction. . Une variable dont la valeur est une fonction est appelée « interface fonctionnelle » en Java et « pointeur de fonction » ou « foncteur » en C++.

Pourquoi traiter les fonctions comme des données ?

En règle générale, le code qui appelle une fonction est couplé à (dépend de) la définition de la fonction. Bien que cela se produise tout le temps, cela devient problématique lorsque le code appelant la fonction (par exemple, WPILib) est développé indépendamment et sans connaissance directe du code qui définit la fonction (par exemple, le code d’une équipe FRC). Parfois, nous résolvons ce problème en utilisant des interfaces de classe, lesquelles définissent des collections de données et de fonctions destinées à être utilisées ensemble. Cependant, souvent, nous n’avons en réalité qu’une dépendance sur une seule fonction, plutôt que sur une classe entière.

Par exemple, WPILib propose aux utilisateurs plusieurs façons d’exécuter certains codes chaque fois qu’un bouton du joystick est enfoncé - l’un des moyens les plus simples et les plus propres de le faire est de permettre à l’utilisateur de passer une fonction à l’une des méthodes du joystick WPILib. De cette façon, l’utilisateur n’a qu’à écrire le code qui traite des événements intéressants et spécifiques à l’équipe (par exemple, « bouger mon bras de robot ») et non la chose ennuyeuse, sujette aux erreurs et universelle (« lire correctement les entrées des boutons depuis un joystick standard »).

Pour un autre exemple, le framework basé sur des commandes est construit sur des objets Command qui font référence aux méthodes définies sur diverses classes Subsystem. La plupart des types Command inclus (tels que InstantCommand et RunCommand) fonctionnent avec n’importe quelle fonction - pas seulement avec les fonctions associées à un seul Subsystem. Pour prendre en charge la construction de commandes de manière générique, nous devons prendre en charge le passage de fonctions d’un Subsystem (qui interagit avec le matériel) à une Command (qui interagit avec le planificateur).

Dans ces cas, nous voulons pouvoir transmettre une seule fonction sous forme de données, comme s’il s’agissait d’une variable - cela n’a pas de sens de demander à l’utilisateur de fournir une classe entière, alors que nous voulons simplement qu’il fournisse une seule fonction de conception appropriée.

Il est important de comprendre que passer une fonction n’est pas la même chose que appeler une fonction. Lorsque nous appelons une fonction, nous exécutons le code qu’elle contient et soit nous recevons une valeur de retour, soit nous provoquons des effets secondaires ailleurs dans le code, soit les deux. Lorsque nous passons une fonction, rien de particulier ne se produit immédiatement. Au lieu de cela, en passant la fonction, nous permettons à un autre code d’appeler la fonction dans le futur. Voir le nom d’une fonction dans le code ne signifie pas toujours que le code de la fonction est en cours d’exécution !

À l’intérieur du code qui transmet une fonction, nous verrons une syntaxe qui soit fait référence au nom d’une fonction existante d’une manière spéciale, soit définit une nouvelle fonction à transmettre à l’intérieur de l’expression d’appel. La syntaxe spécifique nécessaire (et les règles qui l’entourent) dépend du langage de programmation que nous utilisons.

Traiter les fonctions comme des données en Java

Java représente les fonctions en tant que données comme des instances d”interfaces fonctionnelles. Une « interface fonctionnelle » est un type particulier de classe qui n’a qu’une seule méthode. Puisque Java a été initialement conçu strictement pour la programmation orientée objet, il n’a aucun moyen de représenter une seule fonction détachée d’une classe. Au lieu de cela, il définit un groupe particulier de classes qui représentent seulement des fonctions uniques. Chaque type de signature de fonction possède sa propre interface fonctionnelle, qui est une interface avec une définition de fonction unique de cette signature.

Cela peut sembler compliqué, mais dans le contexte de WPILib, nous n’avons pas vraiment besoin de nous soucier de l’utilisation des interfaces fonctionnelles elles-mêmes : le code qui fait cela est interne à WPILib. Au lieu de cela, tout ce que nous devons savoir, c’est comment transmettre une fonction que nous avons écrite à une méthode qui prend une interface fonctionnelle comme paramètre. Pour un exemple simple, considérons la signature de Commands.runOnce (qui crée une InstantCommand qui, lorsqu’elle est planifiée, exécute la fonction donnée une seule fois puis se termine) :

Note

Le paramètre requirements est expliqué dans la Documentation basée sur les commandes, et ne sera pas abordé ici.

public static Command runOnce(Runnable action, Subsystem... requirements)

runOnce attend que nous lui donnions un paramètre Runnable (nommé action). Un Runnable est le terme Java désignant une fonction qui ne prend aucun paramètre et ne renvoie aucune valeur. Lorsque nous appelons runOnce, nous devons lui donner une fonction sans paramètres ni valeur de retour. Il existe deux manières de procéder : nous pouvons faire référence à une fonction existante en utilisant une « référence de méthode », ou nous pouvons définir la fonction souhaitée en ligne à l’aide d’une « expression lambda ».

Références de méthodes

Une référence de méthode nous permet de passer une fonction déjà existante comme notre Runnable :

// Create an InstantCommand that runs the `resetEncoders` method of the `drivetrain` object
Command disableCommand = runOnce(drivetrain::resetEncoders, drivetrain);

L’expression drivetrain::resetEncoders est une référence à la méthode resetEncoders de l’objet drivetrain. Ce n’est pas un appel de méthode - cette ligne de code ne réinitialise pas elle-même les encodeurs de la transmission. Au lieu de cela, elle renvoie une ``Command``qui le fera quand cela est planifié.

Rappelez-vous que pour que cela fonctionne, resetEncoders doit être un Runnable - c’est-à-dire qu’il ne doit prendre aucun paramètre et ne renvoyer aucune valeur. Ainsi, sa signature doit ressembler à ceci :

// void because it returns no parameters, and has an empty parameter list
public void resetEncoders()

Si la signature de la fonction ne correspond pas à ceci, Java ne pourra pas interpréter la référence de méthode comme un Runnable et le code ne sera pas compilé. Notez que tout ce que nous devons faire est de nous assurer que la signature correspond à la signature de la méthode unique dans l’interface fonctionnelle Runnable - nous n’avons pas besoin de la nommer explicitement comme Runnable.

Expressions Lambda en Java

Si nous n’avons pas déjà une fonction nommée qui fait ce que nous voulons, nous pouvons définir une fonction « inline » - c’est-à-dire juste à l’intérieur de l’appel à runOnce ! Nous faisons cela en écrivant notre fonction avec une syntaxe spéciale qui utilise un symbole « flèche » pour lier la liste d’arguments au corps de la fonction :

// Create an InstantCommand that runs the drive forward at half speed
Command driveHalfSpeed = runOnce(() -> { drivetrain.arcadeDrive(0.5, 0.0); }, drivetrain);

Java appelle () -> { drivetrain.arcadeDrive(0.5, 0.0); } une « expression lambda » ; elle peut être appelée de manière moins déroutante « fonction flèche », « fonction inline » ou « fonction anonyme » (car elle n’a pas de nom). Bien que cela puisse paraître un peu étrange, il s’agit simplement d’une autre façon d’écrire une fonction : les parenthèses avant la flèche sont la liste des arguments de la fonction, et le code contenu entre parenthèses est le corps de la fonction. L’expression « lambda » représente ici une fonction qui appelle drivetrain.arcadeDrive avec un ensemble spécifique de paramètres - notez encore que cela n”appelle pas la fonction, mais la définit simplement et la transmet à la Command pour être exécuté plus tard lorsque la Command est planifiée.

Comme pour les références de méthode, nous n’avons pas besoin de nommer explicitement l’expression lambda comme Runnable - Java peut déduire que notre expression lambda est un Runnable tant que sa signature correspond à celle de la méthode unique. dans l’interface Runnable. En conséquence, notre lambda ne prend aucun argument et n’a pas d’instruction return - s’il ne correspondait pas au contrat Runnable, notre code ne pourrait pas être compilé.

Capture de l’état dans les expressions lambda en Java

Dans l’exemple ci-dessus, le corps de notre fonction fait référence à un objet qui vit en dehors de la fonction elle-même (à savoir, l’objet drivetrain). C’est ce qu’on appelle une « capture » d’une variable à partir du code environnant (qui est parfois appelé « portée externe » ou « portée englobante »). Habituellement, les variables capturées sont soit des variables locales du corps de la méthode englobante dans laquelle l’expression lambda est définie, soit des champs d’une définition de classe englobante dans laquelle cette méthode est définie.

En Java, la capture d’un état est une chose assez sûre à faire en général, avec toutefois une mise en garde majeure : nous ne pouvons capturer que l’état « effectivement final ». Cela signifie qu’il n’est légal de capturer une variable de la portée englobante que si cette variable n’est jamais réaffectée après l’initialisation. Notez que cela ne signifie pas que l’état capturé ne peut pas changer : rappelez-vous que les objets Java sont des références, donc l’objet vers lequel la référence pointe peut changer après la capture - mais la référence elle-même ne peut pas pointer vers un autre objet.

Cela signifie que nous ne pouvons capturer les types primitifs (comme int, double et boolean) que s’ils sont des constantes. Si nous voulons capturer une variable d’état qui peut changer, elle doit être enveloppée dans un objet mutable.

Sucre syntaxique pour les expressions lambda en Java

La syntaxe complète de l’expression lambda peut être inutilement verbeuse dans certains cas. Pour nous aider, Java nous permet de prendre quelques raccourcis (appelés « sucre syntaxique ») dans les cas où une partie de la notation est redondante.

Omission des crochets de fonction pour les lambdas à une ligne

Si le corps de la fonction de notre expression lambda ne comporte qu’une seule ligne, Java nous permet d’omettre les crochets autour du corps de la fonction. Lorsque nous omettons les parenthèses de fonction, nous omettons également les points-virgules de fin de ligne et le mot-clé return.

Ainsi, notre lambda Runnable ci-dessus pourrait à la place être écrit :

// Create an InstantCommand that runs the drive forward at half speed
Command driveHalfSpeed = runOnce(() -> drivetrain.arcadeDrive(0.5, 0.0), drivetrain);

Omission des parenthèses autour des paramètres lambda uniques

Si l’expression lambda est destinée à une interface fonctionnelle qui ne prend qu’un seul argument, nous pouvons omettre la parenthèse autour de la liste des paramètres:

// We can write this lambda with no parenthesis around its single argument
IntConsumer exampleLambda = (a -> System.out.println(a));

Traiter les fonctions comme des données en C++

C++ propose plusieurs façons de traiter les fonctions comme des données. Dans le cadre de cet article, nous ne parlerons que des parties pertinentes pour l’utilisation de WPILibC.

Dans WPILibC, les types de fonctions sont représentés avec la classe std::function (https://en.cppreference.com/w/cpp/utility/function/function). Cette classe de bibliothèque standard est calquée sur la signature de la fonction - cela signifie que nous devons lui fournir un type de fonction comme paramètre de modèle pour spécifier la signature de la fonction (comparez cela à Java ci-dessus, où nous avons un type d’interface distinct pour chaque type de signature).

Cela semble beaucoup plus compliqué que son utilisation en pratique. Regardons la signature d’appel de cmd::RunOnce (qui crée une InstantCommand qui, lorsqu’elle est planifiée, exécute la fonction donnée une seule fois puis se termine) :

Note

Le paramètre requirements est expliqué dans la Documentation basée sur les commandes, et ne sera pas abordé ici.

CommandPtr RunOnce(
 std::function<void()> action,
 Requirements requirements);

runOnce attend que nous lui donnions un paramètre std::function<void()> (nommé action). Un std::function<void()> est le type C++ pour une std::function qui ne prend aucun paramètre et ne renvoie aucune valeur (le paramètre du modèle, void() est un type de fonction sans paramètres ni valeur de retour). Lorsque nous appelons runOnce, nous devons lui donner une fonction sans paramètres ni valeur de retour. C++ ne dispose pas d’un moyen simple de faire référence aux méthodes de classe existantes d’une manière qui puisse être automatiquement convertie en std::function, donc la manière typique de le faire est de définir une nouvelle fonction en ligne avec une « expression lambda ».

Expressions lambda en C++

Pour passer une fonction à runOnce, nous devons écrire une courte expression de fonction en ligne en utilisant une syntaxe spéciale qui ressemble aux déclarations de fonctions C++ ordinaires, mais qui varie de plusieurs manières importantes:

// Create an InstantCommand that runs the drive forward at half speed
CommandPtr driveHalfSpeed = cmd::RunOnce([this] { drivetrain.ArcadeDrive(0.5, 0.0); }, {drivetrain});

C++ appelle [captures] (params) { body; } une « expression lambda ». Elle comporte trois parties : une liste de capture (crochets), une liste de paramètres facultative (parenthèses) et un corps de fonction (accolades). Cela peut paraître un peu étrange, mais la seule vraie différence entre une expression lambda et une fonction ordinaire (mis à part l’absence de nom de fonction) est l’ajout de la liste de capture.

Puisque RunOnce veut une fonction sans paramètres ni valeur de retour, notre expression lambda n’a pas de liste de paramètres ni d’instruction de retour. L’expression « lambda » représente ici une fonction qui appelle drivetrain.ArcadeDrive avec un ensemble spécifique de paramètres - notez encore que le code ci-dessus n”appelle pas la fonction, mais la définit simplement et la transmet à la `` Command`` à exécuter plus tard lorsque la Command est planifiée.

Capture de l’état dans les expressions lambda en C++

Dans l’exemple ci-dessus, le corps de notre fonction fait référence à un objet qui vit en dehors de la fonction elle-même (à savoir, l’objet drivetrain). C’est ce qu’on appelle une « capture » d’une variable à partir du code environnant (qui est parfois appelé « portée externe » ou « portée englobante »). Habituellement, les variables capturées sont soit des variables locales du corps de la méthode englobante dans laquelle l’expression lambda est définie, soit des champs d’une définition de classe englobante dans laquelle cette méthode est définie.

C++ a une sémantique un peu plus puissante que Java. L’un des coûts de cela est que nous devons généralement aider le compilateur C++ pour déterminer comment exactement nous voulons qu’il capture l’état de la portée englobante. C’est le but de la liste de capture. Pour utiliser le framework basé sur les commandes WPILibC, il suffit généralement d’utiliser une liste de capture de [this], qui donne accès aux membres de la classe englobante en capturant le pointeur this de la classe englobante par valeur.

Les valeurs locales de méthodes ne peuvent pas être capturées avec le pointeur this et doivent être capturées explicitement soit par référence, soit par valeur en les incluant dans la liste de capture (ou implicitement en spécifiant à la place une sémantique de capture par défaut). Il est généralement plus sûr de capturer les valeurs locales par valeur, car une lambda peut survivre à la durée de vie d’un objet qu’elle capture par référence. Pour plus de détails, consultez la documentation de la bibliothèque standard C++ sur la sémantique de capture.