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 »).
For another example, the Commands v2 framework is built on Command objects that refer to methods defined on various Subsystem classes. Many of the included Command types (such as InstantCommand and RunCommand) work with any function - not just functions associated with a single Subsystem. To support building commands generically, we need to support passing functions from a Subsystem (which interacts with the hardware) to a Command (which interacts with the scheduler).
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 represents functions-as-data as instances of functional interfaces. A « functional interface » is a special kind of interface that has only a single abstract method - since Java was originally designed strictly for object-oriented programming, it has no way of representing a single function detached from a class. Instead, it defines a particular group of classes that only represent single functions. Each type of function signature has its own functional interface, which is an interface with a single function definition of that signature.
This might sound complicated, but in the context of WPILib we don’t really need to worry much about defining functional interfaces ourselves - the code that does that is internal to WPILib. Instead, all we need to know is how to pass a function that we’ve written to a method that takes a functional interface as a parameter.
Evolution of Callbacks in Java¶
To understand why lambda expressions exist, consider a method that accepts a callback to decide when to stop executing:
/**
* Runs until a user-supplied callback tells us to stop.
*/
public void runUntil(BooleanSupplier stop) {
int counter = 0;
while (!stop.getAsBoolean()) {
counter += 1;
System.out.println("Still going at iteration " + counter + "!");
}
}
Java has evolved through three main approaches to passing behavior like this:
1. Named Classes (pre-Java 8)¶
In early versions of Java, passing behavior required defining a named class that implemented the desired interface:
public class RandomCondition implements BooleanSupplier {
@Override
public boolean getAsBoolean() {
return Math.random() >= 0.5;
}
}
// Usage
runUntil(new RandomCondition());
This approach is verbose and requires creating separate top-level or inner classes even for trivial, one-off logic.
2. Anonymous Inner Classes (pre-Java 8)¶
Java later allowed defining classes inline where they are needed without giving them a name:
runUntil(new BooleanSupplier() {
@Override
public boolean getAsBoolean() {
return Math.random() >= 0.5;
}
});
While this keeps the logic close to the call site, it still introduces boilerplate (repeating the interface name, method signature, and annotations) for what is fundamentally a single expression.
3. Lambda Expressions (Java 8 and later)¶
Lambda expressions eliminate this boilerplate by focusing entirely on the input parameters and the executable body:
runUntil(() -> Math.random() >= 0.5);
Because BooleanSupplier has only one method that takes no arguments and returns a boolean, the Java compiler can infer all required type information directly from context.
Passing Existing Methods: Method References¶
For a simple WPILib example, consider the signature of Commands.runOnce (which creates an InstantCommand that, when scheduled, runs the given function once and then terminates):
Note
The requirements parameter is explained in the Commands v2 documentation, and will not be discussed here.
public static Command runOnce(Runnable action, Subsystem... requirements)
runOnce expects us to give it a Runnable parameter (named action). A Runnable is the standard Java functional interface for a function that takes no parameters and returns no value. When we call runOnce, we need to give it a function with no parameters and no return value. There are two ways to do this: we can refer to some existing function using a « method reference », or we can define the function we want inline using a lambda expression.
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é.
Remember that in order for this to work, resetEncoders must match the Runnable signature: it must take no parameters and return no value:
// void because it returns no value, and has an empty parameter list
public void resetEncoders()
If the function signature does not match the functional interface, Java will not be able to interpret the method reference as a Runnable and the code will not compile. Note that all we need to do is make sure that the signature matches the signature of the single method in the Runnable functional interface - we don’t need to explicitly name it as a Runnable.
Expressions Lambda en Java¶
If we do not already have a named function that does what we want, we can define a function inline right inside the call to runOnce using a lambda expression:
// Create an InstantCommand that runs the drive forward at half speed
Command driveHalfSpeed = runOnce(() -> { drivetrain.arcadeDrive(0.5, 0.0); }, drivetrain);
Java calls () -> { drivetrain.arcadeDrive(0.5, 0.0); } a « lambda expression » (also known as an « arrow function » or « anonymous function »). A lambda expression separates the parameter list from the body using an arrow (->):
(param1, param2, ..., paramN) -> {
... body ...
return result;
}
Java infers the return type and parameter types automatically based on the functional interface expected by the method being called (its « target type »).
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¶
Java provides several shortcuts to make lambda expressions concise and readable:
Omission des crochets de fonction pour les lambdas à une ligne¶
If the function body of our lambda expression is only one line, Java lets us omit the curly braces around the function body as well as the trailing semicolon and return keyword:
// Create an InstantCommand that runs the drive forward at half speed
Command driveHalfSpeed = runOnce(() -> drivetrain.arcadeDrive(0.5, 0.0), drivetrain);
Omitting Parameter Types¶
The compiler automatically infers the types of lambda parameters from the functional interface signature, so parameter types can be omitted:
// Explicit parameter types:
(double value) -> Math.sqrt(value)
// Inferred parameter types:
(value) -> Math.sqrt(value)
Omission des parenthèses autour des paramètres lambda uniques¶
If the lambda expression accepts exactly one parameter, parentheses around the parameter name can also be omitted:
// Parameter parentheses omitted:
IntConsumer exampleLambda = a -> System.out.println(a);
Unused Parameters (_)¶
When a functional interface requires a parameter that is not needed in the lambda body, modern Java allows naming the unused parameter with an underscore (_) to indicate to readers and the compiler that it is intentionally ignored:
// Ignoring an unused parameter
DoubleConsumer ignoreInput = _ -> System.out.println("Triggered!");
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.
In WPILibC, function types are represented with the std::function class (https://en.cppreference.com/w/cpp/utility/functional/function). This standard library class is templated on the function’s signature - that means we have to provide it a function type as a template parameter to specify the signature of the function (compare this to Java above, where we have a separate interface type for each kind of 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
The requirements parameter is explained in the Commands v2 documentation, and will not be discussed here.
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++ has somewhat more-powerful semantics than Java. One cost of this is that we generally need to give the C++ compiler some help to figure out how exactly we want it to capture state from the enclosing scope. This is the purpose of the capture list. For the purposes of using the WPILibC Commands v2 framework, it is usually sufficient to use a capture list of [this], which gives access to members of the enclosing class by capturing the enclosing class’s this pointer by value.
Method locals cannot be captured with the this pointer, and must be captured explicitly either by reference or by value by including them in the capture list (or by implicitly by instead specifying a default capture semantics). It is typically safer to capture locals by-value, since a lambda can outlive the lifespan of an object it captures by reference. For more details, consult the C++ standard library documentation on capture semantics.