Comment pourrais-je concevoir une interface de telle sorte qu'il soit clair quelles propriétés peuvent changer leur valeur et lesquelles resteront constantes?

12

J'ai un problème de conception concernant les propriétés .NET.

interface IX
{
    Guid Id { get; }
    bool IsInvalidated { get; }
    void Invalidate();
}

Problème:

Cette interface a deux propriétés en lecture seule, Idet IsInvalidated. Le fait qu'ils soient en lecture seule, cependant, n'est pas en soi une garantie que leurs valeurs resteront constantes.

Disons que j'avais l'intention de dire très clairement que…

  • Id représente une valeur constante (qui peut donc être mise en cache en toute sécurité), tandis que
  • IsInvalidatedpeut changer sa valeur pendant la durée de vie d'un IXobjet (et ne doit donc pas être mis en cache).

Comment pourrais-je modifier interface IXpour rendre ce contrat suffisamment explicite?

Mes trois tentatives de solution:

  1. L'interface est déjà bien conçue. La présence d'une méthode appelée Invalidate()permet à un programmeur de déduire que la valeur de la propriété portant le même nom IsInvalidatedpourrait en être affectée.

    Cet argument ne s'applique que dans les cas où la méthode et la propriété sont nommées de manière similaire.

  2. Augmentez cette interface avec un événement IsInvalidatedChanged:

    bool IsInvalidated { get; }
    event EventHandler IsInvalidatedChanged;

    La présence d'un …Changedévénement pour IsInvalidatedindique que cette propriété peut changer sa valeur, et l'absence d'un événement similaire pour Idest une promesse que cette propriété ne changera pas sa valeur.

    J'aime cette solution, mais c'est beaucoup de choses supplémentaires qui peuvent ne pas être utilisées du tout.

  3. Remplacez la propriété IsInvalidatedpar une méthode IsInvalidated():

    bool IsInvalidated();

    Cela pourrait être un changement trop subtil. C'est censé être un indice qu'une valeur est calculée à chaque fois - ce qui ne serait pas nécessaire si c'était une constante. La rubrique MSDN "Choisir entre les propriétés et les méthodes" a ceci à dire à ce sujet:

    Utilisez une méthode plutôt qu'une propriété dans les situations suivantes. […] L'opération renvoie un résultat différent à chaque appel, même si les paramètres ne changent pas.

À quel genre de réponses dois-je m'attendre?

  • Je suis plus intéressé par des solutions entièrement différentes au problème, avec une explication sur la façon dont elles ont battu mes tentatives ci-dessus.

  • Si mes tentatives sont logiquement viciées ou présentent des inconvénients importants non encore mentionnés, de sorte qu'il ne reste qu'une seule solution (ou aucune), je voudrais savoir où je me suis trompé.

    Si les défauts sont mineurs et qu'il reste plus d'une solution après en avoir pris en considération, veuillez commenter.

  • À tout le moins, j'aimerais avoir des commentaires sur quelle est votre solution préférée et pour quelle (s) raison (s).

stakx
la source
N'a pas IsInvalidatedbesoin d'un private set?
James
2
@James: Pas nécessairement, ni dans la déclaration d'interface, ni dans une implémentation:class Foo : IFoo { private bool isInvalidated; public bool IsInvalidated { get { return isInvalidated; } } public void Invalidate() { isInvalidated = true; } }
stakx
Les propriétés @James dans les interfaces ne sont pas les mêmes que les propriétés automatiques, même si elles utilisent la même syntaxe.
svick
Je me demandais: les interfaces "ont-elles le pouvoir" d'imposer un tel comportement? Ce comportement ne devrait-il pas être délégué à chaque implémentation? Je pense que si vous voulez appliquer des choses à ce niveau, vous devriez envisager de créer une classe abstraite afin que les sous-types en héritent, au lieu d'implémenter une interface donnée. (soit dit en passant, ma justification est-elle logique?)
heltonbiker
@heltonbiker Malheureusement, la plupart des langues (je sais) ne permettent pas d'exprimer de telles contraintes sur les types, mais elles le devraient certainement . C'est une question de spécification, pas d'implémentation. Dans mon opition, ce qui manque, c'est la possibilité d'exprimer les invariants de classe et de post-condition directement dans la langue. Idéalement, nous ne devrions jamais avoir à nous soucier de InvalidStateExceptions, par exemple, mais je ne suis pas sûr que ce soit même théoriquement possible. Ce serait bien, cependant.
proskor

Réponses:

2

Je préférerais la solution 3 plutôt que 1 et 2.

Mon problème avec la solution 1 est : que se passe-t-il s'il n'y a pas de Invalidateméthode? Supposons une interface avec une IsValidpropriété qui renvoie DateTime.Now > MyExpirationDate; vous pourriez ne pas avoir besoin d'une SetInvalidméthode explicite ici. Et si une méthode affecte plusieurs propriétés? Supposons un type de connexion avec IsOpenet IsConnectedpropriétés - les deux sont affectés par la Closeméthode.

Solution 2 : Si le seul point de l'événement est d'informer les développeurs qu'une propriété portant le même nom peut renvoyer des valeurs différentes à chaque appel, alors je déconseille fortement. Vous devez garder vos interfaces courtes et claires; en outre, toutes les implémentations ne peuvent pas déclencher cet événement pour vous. Je vais réutiliser mon IsValidexemple ci-dessus: vous devez implémenter un Timer et déclencher un événement lorsque vous atteignez MyExpirationDate. Après tout, si cet événement fait partie de votre interface publique, les utilisateurs de l'interface s'attendront à ce que cet événement fonctionne.

Cela étant dit sur ces solutions: elles ne sont pas mauvaises. La présence d'une méthode ou d'un événement indiquera qu'une propriété portant le même nom peut renvoyer des valeurs différentes à chaque appel. Tout ce que je dis, c'est qu'ils ne suffisent pas à eux seuls pour transmettre ce sens.

La solution 3 est ce que j'irais. Comme l'a mentionné aviv, cela peut ne fonctionner que pour les développeurs C #. Pour moi, en tant que C # -dev, le fait qu'il IsInvalidatedne s'agisse pas d'une propriété transmet immédiatement le sens de "ce n'est pas seulement un simple accesseur, quelque chose se passe ici". Cependant, cela ne s'applique pas à tout le monde, et comme l'a souligné MainMa, le cadre .NET lui-même n'est pas cohérent ici.

Si vous vouliez utiliser la solution 3, je recommanderais de la déclarer conventionnelle et de la faire suivre par toute l'équipe. La documentation est également essentielle, je crois. À mon avis, il est en fait assez simple d'indiquer des changements de valeurs: " retourne vrai si la valeur est toujours valide ", " indique si la connexion a déjà été ouverte " vs " retourne vrai si cet objet est valide ". Cela ne ferait pas de mal du tout d'être plus explicite dans la documentation.

Mon conseil serait donc:

Déclarez la solution 3 une convention et suivez-la régulièrement. Indiquez clairement dans la documentation des propriétés si une propriété a ou non des valeurs changeantes. Les développeurs travaillant avec des propriétés simples supposeront correctement qu'elles ne changent pas (c'est-à-dire qu'elles ne changent que lorsque l'état de l'objet est modifié). Les développeurs qui rencontrent une méthode qui sonne comme il pourrait être une propriété ( Count(), Length(), IsOpen()) savent ce qui se passe et (espérons) someting lire la méthode docs pour comprendre exactement ce que fait la méthode et la façon dont il se comporte.

enzi
la source
Cette question a reçu de très bonnes réponses, et c'est dommage que je ne puisse en accepter qu'une seule. J'ai choisi votre réponse parce qu'elle résume bien certains points soulevés dans les autres réponses et parce que c'est plus ou moins ce que j'ai décidé de faire. Merci à tous ceux qui ont posté une réponse!
stakx
8

Il existe une quatrième solution: s'appuyer sur la documentation .

Comment savez-vous que la stringclasse est immuable? Vous le savez simplement, car vous avez lu la documentation MSDN. StringBuilder, d'autre part, est mutable, car, encore une fois, la documentation vous le dit.

/// <summary>
/// Represents an index of an element stored in the database.
/// </summary>
private interface IX
{
    /// <summary>
    /// Gets the immutable globally unique identifier of the index, the identifier being
    /// assigned by the database when the index is created.
    /// </summary>
    Guid Id { get; }

    /// <summary>
    /// Gets a value indicating whether the index is invalidated, which means that the
    /// indexed element is no longer valid and should be reloaded from the database. The
    /// index can be invalidated by calling the <see cref="Invalidate()" /> method.
    /// </summary>
    bool IsInvalidated { get; }

    /// <summary>
    /// Invalidates the index, without forcing the indexed element to be reloaded from the
    /// database.
    /// </summary>
    void Invalidate();
}

Votre troisième solution n'est pas mauvaise, mais .NET Framework ne la suit pas . Par exemple:

StringBuilder.Length
DateTime.Now

sont des propriétés, mais ne vous attendez pas à ce qu'elles restent constantes à chaque fois.

Ce qui est systématiquement utilisé dans .NET Framework lui-même est le suivant:

IEnumerable<T>.Count()

est une méthode, tandis que:

IList<T>.Length

est une propriété. Dans le premier cas, faire des choses peut nécessiter un travail supplémentaire, y compris, par exemple, interroger la base de données. Ce travail supplémentaire peut prendre un certain temps. Dans le cas d'une propriété, cela devrait prendre un peu de temps: en effet, la longueur peut changer pendant la durée de vie de la liste, mais il ne faut pratiquement rien pour la renvoyer.


Avec les classes, le simple fait de regarder le code montre clairement que la valeur d'une propriété ne changera pas pendant la durée de vie de l'objet. Par exemple,

public class Product
{
    private readonly int price;

    public Product(int price)
    {
        this.price = price;
    }

    public int Price
    {
        get
        {
            return this.price;
        }
    }
}

est clair: le prix restera le même.

Malheureusement, vous ne pouvez pas appliquer le même modèle à une interface, et étant donné que C # n'est pas assez expressif dans ce cas, la documentation (y compris la documentation XML et les diagrammes UML) doit être utilisée pour combler l'écart.

Arseni Mourzenko
la source
Même la directive «pas de travail supplémentaire» n'est pas utilisée de manière absolument cohérente. Par exemple, le Lazy<T>.Valuecalcul peut prendre très longtemps (lors de son premier accès).
svick
1
À mon avis, peu importe si le framework .NET respecte la convention de la solution 3. Je m'attends à ce que les développeurs soient assez intelligents pour savoir s'ils travaillent avec des types internes ou des types de framework. Les développeurs qui ne savent pas qui ne lisent pas les documents et donc la solution 4 n'aideraient pas non plus. Si la solution 3 est implémentée comme une convention entreprise / équipe, alors je pense que peu importe si d'autres API la suivent aussi (bien que ce soit très apprécié, bien sûr).
enzi
4

Mes 2 cents:

Option 3: En tant que non-C # -er, ce ne serait pas vraiment un indice; Cependant, à en juger par la citation que vous apportez, il devrait être clair pour les programmeurs C # compétents que cela signifie quelque chose. Vous devez cependant ajouter des documents à ce sujet.

Option 2: sauf si vous prévoyez d'ajouter des événements à tout ce qui peut changer, n'y allez pas.

Autres options:

  • Renommer l'interface IXMutable, il est donc clair qu'elle peut changer son état. La seule valeur immuable dans votre exemple est la id, qui est généralement supposée immuable même dans les objets mutables.
  • Sans le mentionner. Les interfaces ne décrivent pas aussi bien le comportement que nous le souhaiterions; En partie, c'est parce que le langage ne vous permet pas vraiment de décrire des choses comme «Cette méthode retournera toujours la même valeur pour un objet spécifique» ou «L'appel de cette méthode avec un argument x <0 lèvera une exception».
  • Sauf qu'il existe un mécanisme pour étendre le langage et fournir des informations plus précises sur les constructions - Annotations / Attributs . Ils ont parfois besoin d'un peu de travail, mais ils vous permettent de spécifier des choses arbitrairement complexes sur vos méthodes. Recherchez ou inventez une annotation indiquant "La valeur de cette méthode / propriété peut changer tout au long de la durée de vie d'un objet" et ajoutez-la à la méthode. Vous devrez peut-être jouer quelques astuces pour que les méthodes d'implémentation en "héritent", et les utilisateurs devront peut-être lire le document pour cette annotation, mais ce sera un outil qui vous permettra de dire exactement ce que vous voulez dire, au lieu d'indiquer à elle.
aviv
la source
3

Une autre option serait de diviser l'interface: en supposant qu'après avoir appelé Invalidate()chaque invocation de IsInvalidateddevrait retourner la même valeur true, il ne semble y avoir aucune raison d'invoquer IsInvalidatedpour la même partie qui a invoqué Invalidate().

Donc, je suggère, soit vous vérifiez l' invalidation, soit vous la provoquez , mais il semble déraisonnable de faire les deux. Par conséquent, il est logique d'offrir une interface contenant l' Invalidate()opération à la première partie et une autre interface pour vérifier ( IsInvalidated) à l'autre. Comme il est assez évident à partir de la signature de la première interface qu'elle entraînera l'invalidation de l'instance, la question restante (pour la deuxième interface) est en effet assez générale: comment spécifier si le type donné est immuable .

interface Invalidatable
{
    bool IsInvalidated { get; }
}

Je sais, cela ne répond pas directement à la question, mais au moins cela la réduit à la question de savoir comment marquer un type immuable , ce qui est calme, un problème commun et compris. Par exemple, en supposant que tous les types sont mutables, sauf indication contraire, vous pouvez conclure que la deuxième interface ( IsInvalidated) est mutable et peut donc modifier la valeur de IsInvalidatedtemps en temps. Par contre, supposons que la première interface ressemble à ceci (pseudo-syntaxe):

[Immutable]
interface IX
{
    Guid Id { get; }
    void Invalidate();
}

Puisqu'il est marqué immuable, vous savez que l'ID ne changera pas. Bien sûr, l'appel de invalidate entraînera la modification de l'état de l'instance, mais cette modification ne sera pas observable via cette interface.

proskor
la source
Pas une mauvaise idée! Cependant, je dirais qu'il est faux de marquer une interface [Immutable]tant qu'elle englobe des méthodes dont le seul but est de provoquer une mutation. Je déplacerais la Invalidate()méthode vers l' Invalidatableinterface.
stakx
1
@stakx je ne suis pas d'accord. :) Je pense que vous confondez les concepts d' immuabilité et de pureté (absence d'effets secondaires). En fait, du point de vue de l'interface proposée IX, il n'y a aucune mutation observable. Chaque fois que vous appelez Id, vous obtiendrez la même valeur à chaque fois. La même chose s'applique à Invalidate()(vous n'obtenez rien, car son type de retour est void). Par conséquent, le type est immuable. Bien sûr, vous aurez des effets secondaires , mais vous ne pourrez pas les observer via l'interface IX, ils n'auront donc aucun impact sur les clients de IX.
proskor
OK, nous pouvons convenir que IXc'est immuable. Et que ce sont des effets secondaires; ils sont simplement répartis entre deux interfaces divisées de telle sorte que les clients n'ont pas besoin de se soucier (dans le cas de IX) ou qu'il est évident quel pourrait être l'effet secondaire (dans le cas de Invalidatable). Mais pourquoi ne pas passer Invalidateà Invalidatable? Cela rendrait IXimmuable et pur, et Invalidatablene deviendrait pas plus mutable, ni plus impur (car ce sont déjà les deux).
stakx
(Je comprends que dans un scénario réel, la nature de la division de l'interface dépendrait probablement plus des cas d'utilisation pratiques que des questions techniques, mais j'essaie de bien comprendre votre raisonnement ici.)
stakx
1
@stakx Tout d'abord, vous avez demandé de nouvelles possibilités pour rendre la sémantique de votre interface plus explicite. Mon idée était de réduire le problème à celui de l'immuabilité, ce qui nécessitait le fractionnement de l'interface. Mais non seulement la séparation était nécessaire pour rendre une interface immuable, mais cela aurait également beaucoup de sens, car il n'y a aucune raison d'invoquer les deux Invalidate()et IsInvalidatedpar le même consommateur de l'interface . Si vous appelez Invalidate(), vous devez savoir qu'il est invalidé, alors pourquoi le vérifier? Ainsi, vous pouvez fournir deux interfaces distinctes à des fins différentes.
proskor