Une interface exposant les fonctions asynchrones est-elle une abstraction qui fuit?

13

Je lis le livre Principes, pratiques et modèles d'injection de dépendance et j'ai lu sur le concept d'abstraction qui fuit qui est bien décrit dans le livre.

Ces jours-ci, je refactorise une base de code C # en utilisant l'injection de dépendance afin que les appels asynchrones soient utilisés au lieu de bloquer ceux-ci. Ce faisant, je considère certaines interfaces qui représentent des abstractions dans ma base de code et qui doivent être repensées afin que les appels asynchrones puissent être utilisés.

Par exemple, considérons l'interface suivante représentant un référentiel pour les utilisateurs d'applications:

public interface IUserRepository 
{
  Task<IEnumerable<User>> GetAllAsync();
}

Selon la définition du livre, une abstraction qui fuit est une abstraction conçue avec une implémentation spécifique à l'esprit, de sorte que certains détails d'implémentation "fuient" à travers l'abstraction elle-même.

Ma question est la suivante: pouvons-nous considérer une interface conçue avec async à l'esprit, comme IUserRepository, comme exemple d'une abstraction qui fuit?

Bien sûr, toutes les implémentations possibles n'ont rien à voir avec l'asynchronie: seules les implémentations hors processus (comme une implémentation SQL) le font, mais un référentiel en mémoire ne nécessite pas l'asynchronie (l'implémentation d'une version en mémoire de l'interface est probablement plus difficile si l'interface expose des méthodes asynchrones, par exemple, vous devrez probablement renvoyer quelque chose comme Task.CompletedTask ou Task.FromResult (utilisateurs) dans les implémentations de méthode).

Qu'est ce que tu penses de ça ?

Enrico Massone
la source
@Neil J'ai probablement compris. Une interface exposant des méthodes renvoyant Task ou Task <T> n'est pas une abstraction qui fuit en soi, est simplement un contrat avec une signature impliquant des tâches. Une méthode renvoyant une tâche ou une tâche <T> n'implique pas d'avoir une implémentation asynchrone (par exemple si je crée une tâche terminée en utilisant Task.CompletedTask, je ne fais pas une implémentation asynchrone). Inversement, l'implémentation asynchrone en C # nécessite que le type de retour d'une méthode asynchrone soit de type Task ou Task <T>. Autrement dit, le seul aspect "qui fuit" de mon interface est le suffixe asynchrone dans les noms
Enrico Massone
@Neil il y a en fait une directive de nommage qui stipule que toutes les méthodes asynchrones doivent avoir un nom se terminant par "Async". Mais cela n'implique pas qu'une méthode renvoyant une tâche ou une tâche <T> doit être nommée avec le suffixe Async car elle pourrait être implémentée en n'utilisant aucun appel asynchrone.
Enrico Massone du
6
Je dirais que le «asyncness» d'une méthode est indiqué par le fait qu'elle renvoie a Task. Les directives pour suffixer les méthodes asynchrones avec le mot async consistaient à distinguer les appels API par ailleurs identiques (répartition C # ne peut pas être basée sur le type de retour). Dans notre entreprise, nous avons tout abandonné ensemble.
richzilla
Il existe un certain nombre de réponses et de commentaires expliquant pourquoi la nature asynchrone de la méthode fait partie de l'abstraction. Une question plus intéressante est de savoir comment un langage ou une API de programmation peut séparer la fonctionnalité d'une méthode de la façon dont elle est exécutée, au point où nous n'avons plus besoin de valeurs de retour de tâche ou de marqueurs asynchrones. Les programmeurs fonctionnels semblent avoir mieux compris cela. Considérez comment les méthodes asynchrones sont définies en F # et dans d'autres langages.
Frank Hileman
2
:-) -> Les "gens de la programmation fonctionnelle" ha. Async n'est pas plus étanche que synchrone, cela semble juste parce que nous sommes habitués à écrire du code de synchronisation par défaut. Si nous avons tous codé async par défaut, une fonction synchrone peut sembler fuir.
StarTrekRedneck

Réponses:

8

On peut, bien sûr, invoquer la loi des abstractions qui fuient , mais ce n'est pas particulièrement intéressant car cela postule que toutes les abstractions sont fuyantes. On peut argumenter pour et contre cette conjecture, mais cela n'aide pas si nous ne partageons pas une compréhension de ce que nous entendons par abstraction et de ce que nous entendons par fuite . Par conséquent, je vais d'abord essayer de définir comment je vois chacun de ces termes:

Abstractions

Ma définition préférée des abstractions est dérivée de l' APPP de Robert C. Martin :

"Une abstraction est l'amplification de l'essentiel et l'élimination de l'inutile."

Ainsi, les interfaces ne sont pas, en soi, des abstractions . Ce ne sont des abstractions que si elles mettent à la surface ce qui compte et cache le reste.

Qui fuit

Le livre Dependency Injection Principles, Patterns and Practices définit le terme d' abstraction qui fuit dans le contexte de l'injection de dépendance (DI). Le polymorphisme et les principes SOLID jouent un grand rôle dans ce contexte.

Du principe d'inversion de dépendance (DIP), il s'ensuit, citant encore APPP, que:

"les clients [...] possèdent les interfaces abstraites"

Cela signifie que les clients (code appelant) définissent les abstractions dont ils ont besoin, puis vous implémentez cette abstraction.

Une abstraction qui fuit , à mon avis, est une abstraction qui viole le DIP en incluant en quelque sorte des fonctionnalités dont le client n'a pas besoin .

Dépendances synchrones

Un client qui implémente un élément de logique métier utilise généralement DI pour se dissocier de certains détails d'implémentation, tels que, généralement, les bases de données.

Considérons un objet de domaine qui gère une demande de réservation de restaurant:

public class MaîtreD : IMaîtreD
{
    public MaîtreD(int capacity, IReservationsRepository repository)
    {
        Capacity = capacity;
        Repository = repository;
    }

    public int Capacity { get; }
    public IReservationsRepository Repository { get; }

    public int? TryAccept(Reservation reservation)
    {
        var reservations = Repository.ReadReservations(reservation.Date);
        int reservedSeats = reservations.Sum(r => r.Quantity);

        if (Capacity < reservedSeats + reservation.Quantity)
            return null;

        reservation.IsAccepted = true;
        return Repository.Create(reservation);
    }
}

Ici, la IReservationsRepositorydépendance est déterminée exclusivement par le client, la MaîtreDclasse:

public interface IReservationsRepository
{
    Reservation[] ReadReservations(DateTimeOffset date);
    int Create(Reservation reservation);
}

Cette interface est entièrement synchrone car la MaîtreDclasse n'a pas besoin qu'elle soit asynchrone.

Dépendances asynchrones

Vous pouvez facilement changer l'interface pour qu'elle soit asynchrone:

public interface IReservationsRepository
{
    Task<Reservation[]> ReadReservations(DateTimeOffset date);
    Task<int> Create(Reservation reservation);
}

La MaîtreDclasse, cependant, n'a pas besoin de ces méthodes pour être asynchrones, maintenant le DIP est violé. Je considère cela comme une abstraction qui fuit, car un détail d'implémentation oblige le client à changer. La TryAcceptméthode doit désormais également devenir asynchrone:

public async Task<int?> TryAccept(Reservation reservation)
{
    var reservations =
        await Repository.ReadReservations(reservation.Date);
    int reservedSeats = reservations.Sum(r => r.Quantity);

    if (Capacity < reservedSeats + reservation.Quantity)
        return null;

    reservation.IsAccepted = true;
    return await Repository.Create(reservation);
}

Il n'y a aucune raison inhérente pour que la logique du domaine soit asynchrone, mais pour prendre en charge l'asynchronie de l'implémentation, cela est maintenant requis.

De meilleures options

Au NDC Sydney 2018, j'ai donné une conférence sur ce sujet . Dans ce document, je présente également une alternative qui ne fuit pas. Je donnerai également cette conférence lors de plusieurs conférences en 2019, mais maintenant renommée avec le nouveau titre d' injection Async .

Je prévois également de publier une série de billets de blog pour accompagner la conférence. Ces articles sont déjà écrits et se trouvent dans ma file d'attente d'articles, en attente de publication, alors restez à l'écoute.

Mark Seemann
la source
À mon avis, c'est une question d'intention. Si mon abstraction apparaît comme si elle devait se comporter dans un sens mais qu'un détail ou une contrainte rompt l'abstraction telle que présentée, c'est une abstraction qui fuit. Mais dans ce cas, je vous présente explicitement que l'opération est asynchrone - ce n'est pas ce que j'essaie d'abstraire. C'est différent dans mon esprit de votre exemple où j'essaie (sagement ou non) d'abstraire le fait qu'il existe une base de données SQL et que j'expose toujours une chaîne de connexion. C'est peut-être une question de sémantique / de perspective.
Ant P
Nous pouvons donc dire qu'une abstraction n'est jamais fuyante "en soi", mais plutôt fuyante si certains détails d'une implémentation spécifique s'échappent des membres exposés et contraignent le consommateur à modifier son implémentation, afin de satisfaire la forme d'abstraction .
Enrico Massone
2
Chose intéressante, le point que vous avez souligné dans votre explication est l'un des points les plus mal compris de toute l'histoire de l'injection de dépendance. Parfois, les développeurs oublient le principe d'inversion de dépendance et essaient d'abord de concevoir l'abstraction, puis ils adaptent la conception du consommateur afin de faire face à l'abstraction elle-même. Au lieu de cela, le processus doit être effectué dans l'ordre inverse.
Enrico Massone
11

Ce n'est pas du tout une abstraction qui fuit.

Être asynchrone est un changement fondamental dans la définition d'une fonction - cela signifie que la tâche n'est pas terminée lorsque l'appel revient, mais cela signifie également que le flux de votre programme se poursuivra presque immédiatement, pas avec un long délai. Une fonction asynchrone et une fonction synchrone effectuant la même tâche sont essentiellement des fonctions différentes. Être asynchrone n'est pas un détail d'implémentation. Cela fait partie de la définition d'une fonction.

Si la fonction révélait comment la fonction était rendue asynchrone, ce serait une fuite. Vous (ne devez / ne devez pas) vous soucier de la façon dont il est mis en œuvre.

gnasher729
la source
5

L' asyncattribut d'une méthode est une balise qui indique qu'un soin et une manipulation particuliers sont nécessaires. En tant que tel, il doit fuir dans le monde. Les opérations asynchrones sont extrêmement difficiles à composer correctement, il est donc important d'avertir l'utilisateur de l'API.

Si, à la place, votre bibliothèque gérait correctement toutes les activités asynchrones en elle-même, alors vous pouviez vous permettre de ne pas laisser async"fuir" l'API.

Le logiciel comporte quatre dimensions de difficulté: les données, le contrôle, l'espace et le temps. Les opérations asynchrones couvrent les quatre dimensions et nécessitent donc le plus de soins.

BobDalgleish
la source
Je suis d'accord avec votre sentiment, mais "fuite" implique quelque chose de mauvais, qui est l'intention du terme "abstraction qui fuit" - quelque chose de indésirable dans l'abstraction. Dans le cas de l'async vs de la synchronisation, rien ne fuit.
StarTrekRedneck
2

une abstraction qui fuit est une abstraction conçue avec une implémentation spécifique à l'esprit, de sorte que certains détails d'implémentation "fuient" à travers l'abstraction elle-même.

Pas assez. Une abstraction est une chose conceptuelle qui ignore certains éléments d'une chose ou d'un problème concret plus compliqué (pour rendre la chose / le problème plus simple, traitable ou en raison d'un autre avantage). En tant que tel, il est nécessairement différent de la chose / du problème réel, et donc cela va être fuyant dans certains sous-ensembles de cas (c'est-à-dire que toutes les abstractions sont fuyantes, la seule question est de savoir dans quelle mesure - c'est-à-dire, dans quels cas est l'abstraction utile pour nous, quel est son domaine d’applicabilité).

Cela dit, en ce qui concerne les abstractions logicielles, parfois (ou peut-être assez souvent?) Les détails que nous avons choisi d'ignorer ne peuvent pas être ignorés car ils affectent certains aspects du logiciel qui sont importants pour nous (performances, maintenabilité, ...) . Donc, une abstraction qui fuit est une abstraction qui a été conçue pour ignorer certains détails (en supposant qu'il était possible et utile de le faire), mais il s'est avéré que certains de ces détails sont importants dans la pratique (ils ne peuvent pas être ignorés, donc ils "fuite").

Ainsi, une interface exposant un détail d'une implémentation n'est pas une fuite en soi (ou plutôt, une interface, considérée isolément, n'est pas en soi une abstraction qui fuit); au lieu de cela, la fuite dépend du code qui implémente l'interface (est-il capable de prendre en charge réellement l'abstraction représentée par l'interface), ainsi que des hypothèses émises par le code client (qui équivalent à une abstraction conceptuelle qui complète celle exprimée par l'interface, mais ne peut pas être elle-même exprimée en code (par exemple, les fonctionnalités du langage ne sont pas assez expressives, nous pouvons donc la décrire dans la documentation, etc.)).

Filip Milovanović
la source
2

Considérez les exemples suivants:

Il s'agit d'une méthode qui définit le nom avant son retour:

public void SetName(string name)
{
    _dataLayer.SetName(name);
}

Il s'agit d'une méthode qui définit le nom. L'appelant ne peut pas supposer que le nom est défini tant que la tâche renvoyée n'est pas terminée ( IsCompleted= true):

public Task SetName(string name)
{
    return _dataLayer.SetNameAsync(name);
}

Il s'agit d'une méthode qui définit le nom. L'appelant ne peut pas supposer que le nom est défini tant que la tâche renvoyée n'est pas terminée ( IsCompleted= true):

public async Task SetName(string name)
{
    await _dataLayer.SetNameAsync(name);
}

Q: Lequel n'appartient pas aux deux autres?

R: La méthode asynchrone n'est pas celle qui est autonome. Celui qui est seul est la méthode qui renvoie le vide.

Pour moi, la "fuite" ici n'est pas le asyncmot - clé; c'est le fait que la méthode retourne une tâche. Et ce n'est pas une fuite; cela fait partie du prototype et fait partie de l'abstraction. Une méthode asynchrone qui renvoie une tâche fait exactement la même promesse faite par une méthode synchrone qui renvoie une tâche.

Donc non, je ne pense pas que l'introduction des asyncformes soit une abstraction qui fuit en soi. Mais vous devrez peut-être changer le prototype pour renvoyer une tâche, qui "fuit" en changeant l'interface (l'abstraction). Et comme cela fait partie de l'abstraction, ce n'est pas une fuite, par définition.

John Wu
la source
0

Il s'agit d'une abstraction qui fuit si et seulement si vous n'avez pas l' intention que toutes les classes d'implémentation créent un appel asynchrone. Vous pouvez créer plusieurs implémentations, par exemple, une pour chaque type de base de données que vous prenez en charge, et ce serait tout à fait correct en supposant que vous n'ayez jamais besoin de connaître l'implémentation exacte utilisée dans votre programme.

Et bien que vous ne puissiez pas appliquer strictement une implémentation asynchrone, le nom implique qu'elle devrait l'être. Si les circonstances changent et qu'il peut s'agir d'un appel synchrone pour quelque raison que ce soit, vous devrez peut-être envisager un changement de nom, donc mon conseil serait de le faire uniquement si vous ne pensez pas que cela sera très probable dans le avenir.

Neil
la source
0

Voici un point de vue opposé.

Nous ne sommes pas allés de retour Fooen retour Task<Foo>parce que nous avons commencé à vouloir le Taskau lieu de juste le Foo. Certes, nous interagissons parfois avec le Taskmais dans la plupart des codes du monde réel, nous l'ignorons et utilisons simplement le Foo.

De plus, nous définissons souvent des interfaces pour prendre en charge le comportement asynchrone même lorsque l'implémentation peut être asynchrone ou non.

En effet, une interface qui retourne un Task<Foo>vous indique que l'implémentation est peut-être asynchrone, qu'elle le soit vraiment ou non, même si vous vous en souciez ou non. Si une abstraction nous en dit plus que ce dont nous avons besoin de savoir sur sa mise en œuvre, elle fuit.

Si notre implémentation n'est pas asynchrone, nous la modifions pour être asynchrone, puis nous devons changer l'abstraction et tout ce qui l'utilise, c'est une abstraction très fuyante.

Ce n'est pas un jugement. Comme d'autres l'ont souligné, toutes les abstractions fuient. Celui-ci a un impact plus important car il nécessite un effet d'entraînement asynchrone / attend dans tout notre code simplement parce que quelque part à la fin, il pourrait y avoir quelque chose qui est réellement asynchrone.

Cela ressemble-t-il à une plainte? Ce n'est pas mon intention, mais je pense que c'est une observation exacte.

Un point connexe est l'affirmation qu '"une interface n'est pas une abstraction". Ce que Mark Seeman a succinctement déclaré a été un peu abusé.

La définition de «abstraction» n'est pas «interface», même dans .NET. Les abstractions peuvent prendre de nombreuses autres formes. Une interface peut être une abstraction médiocre ou elle peut refléter son implémentation si étroitement que, dans un sens, ce n'est guère une abstraction.

Mais nous utilisons absolument des interfaces pour créer des abstractions. Donc, jeter "les interfaces ne sont pas des abstractions" car une question mentionne les interfaces et les abstractions n'est pas éclairant.

Scott Hannen
la source
-2

Est-ce GetAllAsync()réellement asynchrone? Je veux dire que "async" est dans le nom, mais cela peut être supprimé. Alors je demande à nouveau ... Est-il impossible d'implémenter une fonction qui renvoie un Task<IEnumerable<User>>qui est résolu de manière synchrone?

Je ne connais pas les spécificités du Tasktype de .Net , mais s'il est impossible d'implémenter la fonction de manière synchrone, alors assurez-vous que c'est une abstraction qui fuit (de cette façon) mais sinon pas. Je ne sais que si elle était IObservableplutôt que d' une tâche, il pourrait être mis en œuvre de manière synchrone ou asynchrone donc rien en dehors de la fonction sait et il est donc ne fuit pas ce fait particulier.

Daniel T.
la source
Task<T> signifie asynchrone. Vous obtenez l'objet de tâche immédiatement, mais devrez peut-être attendre la séquence d'utilisateurs
Caleth
Le fait de devoir attendre ne signifie pas nécessairement que c'est asynchrone. Doit attendre signifierait async. Vraisemblablement, si la tâche sous-jacente a déjà été exécutée, vous n'avez pas à attendre.
Daniel T.