Retour aux articles

async fn et impl Trait dans les traits

Laurent Wouters

2024-01-05

fdsfdsf

Les traits et fonctions asynchrones

La version 1.75 de Rust stabilise enfin une fonctionnalité très demandée, le support des fonctions asynchrones dans les traits. Avant cette version, les traits ne pouvaient avoir que des fonctions synchrones, dont le type de retour est connu ou alors spécifié par un type associé. Maintenant vous pouvez écrire ceci :

trait DataGetter {
    async fn get_some_data(&self) -> String;
//  ^^^^^^^^ fonction asynchrone
 
    fn get_some_other_data(&self) -> impl Future<Output = String>;
//    retourne in type opaque        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}

Cette fonctionnalité a été rendue possible par la stabilisation de la fonctionnalité des types associés génériques dans la version 1.65. Pourquoi était-ce nécessaire ? Que peux-t-on faire ? Quelles sont les limites ? On vous explique tout.

Fonction asynchrone classique

Les fonctions asynchrones peuvent se réécrire sous la forme de fonctions avec -> impl Future en retour (un type opaque). Par exemple :

async fn get_some_data() -> String {}

peut se réécrire sous la forme presque équivalente :

fn get_some_data() -> impl Future<Output = String> {}

Ces deux formes sont en effet presque équivalentes. Le diable se cache dans les détails. La différence entre les deux est que :

  • avec une fonction async, le compilateur peut dériver les auto-traits sur le type de sortie générée par le compilateur.
  • avec un type opaque en sortie impl Future<Output = String>, l’appelant de la fonction ne peut s’appuyer que sur le trait Future.

Si l’on avait voulu avoir Send, il faut écrire :

fn get_some_data() -> impl Future<Output = String> + Send {}
//                                                 ^^^^^^ ici

Il est intéressant de noter deux points :

  • Une fonction asynchrone n’est que une fonction « classique » qui retourne une Future.
  • Une fonction asynchrone n’est pas du sucre syntaxique pour la version avec le type opaque en sortie (-> impl Future<Output = String>), puisqu’il y a une différence sur les auto-traits.

Problème des fonctions asynchrones dans les traits

Lorsque l’on écrit un trait, le type de retour des fonctions / méthodes doit être connu, fixé par la fonction, ou paramétré avec de la généricité ou un type associé. Or dans le cas d’une fonction asynchrone, le type de retour est donné par l’implémentation de la fonction. En effet, un type de retour spécifique va être généré par le compilateur au regard du code contenu. Autrement dit, le problème auquel le compilateur est confronté pour les fonctions asynchrones dans les traits, lorsque l’on écrit ceci :

trait DataGetter {
    async fn get_some_data(&self) -> String;
}

est que le type de retour de get_some_data n’est pas encore connu du compilateur puisque c’est l’implémentation qui va le donner. Pour que cela fonctionne, il faut donc paramétrer le trait avec le type de retour de la fonction, d’une manière ou d’une autre.

Par exemple en écrivant manuellement ceci :

trait DataGetter {
    type GetSomeDataReturn: Future<Output = String>;
 
    fn get_some_data(&self) -> Self::GetSomeDataReturn;
}

Ici c’est l’implémentation du trait qui va fixer le type de retour GetSomeDataReturn de get_some_data. Avec le désavantage qu’il faudra pouvoir écrire ce type concret, ce qui peut être compliqué dans le cas général.

Avec Rust 1.75

Depuis Rust 1.75, on peut donc écrire des fonctions / méthodes asynchrones dans les traits. Comment est-ce que cela fonctionne ? Tout d’abord, les traits supportent aussi maintenant l’utilisation de type opaque en retour des fonctions, autrement dit la notation -> impl Trait.

Type opaque -> impl Trait en retour dans les traits

Lorsque la notation -> impl Trait en retour d’une fonction est utilisée dans un trait, cela se traduit par l’ajout d’un type associé implicite au trait. Par exemple :

trait DataGetter {
    fn get_some_data(&self) -> impl Future<Output = String>;
}

se traduit vers :

trait DataGetter {
    type GetSomeDataReturn: Future<Output = String>;
 
    fn get_some_data(&self) -> Self::GetSomeDataReturn;
}

Bien sûr, cela est fait implicitement pour nous par le compilateur. Celui-ci se chargera de trouver, lors de l’implémentation du trait, le type concret de retour de get_some_data réellement utilisé, et remplira le type associé implicite. C’est très pratique, surtout quand on ne peut pas écrire nous-même le type concret !

Appliqué aux fonctions async

Le tout ensemble, il est donc possible d’écrire :

trait DataGetter {
    async fn get_some_data(&self) -> String;
}

ce qui se traduit implicitement vers :

trait DataGetter {
    fn get_some_data(&self) -> impl Future<Output = String>;
}

puis en

trait DataGetter {
    type GetSomeDataReturn: Future<Output = String>;
 
    fn get_some_data(&self) -> Self::GetSomeDataReturn;
}

Ainsi, la fonction asynchrone du trait a un type de retour concret qui peut n’être connu que du compilateur et qui sera automatiquement renseigné dans le type associé correspondant lors de l’implémentation du trait.

Première subtilité

Cependant, on voit qu’il existe une différence de traitement de la fonction asynchrone, dans le cas d’un trait ou nom. Pour une fonction async libre, le compilateur peut dériver les auto-traits sur lesquels on peut s’appuyer. Mais dans le cas du trait, la fonction async n’est rien que du sucre syntaxique pour la forme avec le type opaque en retour (-> impl Future<Output = String>). Il ne peut pas en être autrement puisque l’on ne connaitra le type concret que à l’implémentation du trait et le compilateur ne peut pas faire l’hypothèse pour nous que toutes les implémentations du trait renverront une Future qui est aussi Send.

C’est pourquoi, vous avez un warning sur les traits publics avec des fonctions async : use of async fn in public traits is discouraged as auto trait bounds cannot be specified. Précisément car les auto-traits comme Send seront absents. Il conviendra alors d’écrire :

trait DataGetter {
    fn get_some_data(&self) -> impl Future<Output = String> + Send;
}

Mais c’est bien à nous en tant que programmeur de l’écrire pour demander explicitement la borne Send.

Deuxième restriction avec dyn

La deuxième restriction est que pour l’instant on ne peut pas utiliser les traits contenant des fonctions asynchrones comme des objets avec dyn Trait. Pourquoi ? Reprenons notre trait :

trait DataGetter {
    async fn get_some_data(&self) -> String;
}

qui se réécrit implicitement comme :

trait DataGetter {
    type GetSomeDataReturn: Future<Output = String>;
 
    fn get_some_data(&self) -> Self::GetSomeDataReturn;
}

Pour l’utiliser avec dyn, il faudrait spécifier la valeur de GetSomeDataReturn :

fn test(getter: &dyn DataGetter<GetSomeDataReturn=???>) {}
//                              ^^^^^^^^^^^^^^^^^^^^^ ici

En effet, on ne connait pas à la compilation l’implémentation exacte du trait. C’est supposé être résolu au runtime en passant la vtable. Mais pour cela, il faut connaitre le typage exact pour le trait et donc la valeur des types associés.

Mais comme le type associé est implicite, impossible pour nous de le spécifier. Et donc on ne peut écrire dyn DataGetter, sans spécifier le type associé pour le retour de la fonction asynchrone ; ce qui empêche, in-fine, l’utilisation avec dyn.

Conclusion

Le support des fonctions asynchrones dans les traits est bienvenu. Il subsiste encore quelques difficultés potentielles qui nous obligent à comprendre comment cela fonctionne en dessus. Est-ce pour autant la fin de BoxFuture et async_trait ? Pas tout à fait !

Retour aux articles