web-dev-qa-db-fra.com

La lecture / écriture implicite de l'état dans OOP nuit à la lisibilité, à la maintenabilité et à la testabilité. Bon moyen d'atténuer ces dommages?

La POO rend les lectures et écritures d'état implicites. Par exemple, en Python:

class Foo:
    def bar(self):
        # This method may read and/or write any number of self.attributes.
        # There is no way to know or limit what self state this method
        # accesses and/or modifies.

Par rapport à:

def bar(qux, baz, flux):
    # This function's only inputs are qux, baz, and flux. Its only output:
    return trax

Ce dernier semble beaucoup plus facile à lire, à entretenir, à tester et à raisonner.

Existe-t-il des solutions à ce problème? Je suis particulièrement intéressé par la façon dont vous résoudriez ce problème dans les langages traditionnels comme Python et C++, bien que signaler tout outil ou langage qui le résout peut également être utile.

5
Dun Peal

Ce n'est pas tout à fait juste.

Je veux dire, vous avez raison, déclarer explicitement les entrées et sorties d'une méthode est très bon. Je pense qu'un langage qui déclare réellement ses types est meilleur que votre Python même.

Mais vous n'avez pas raison que les OO soient implicites. Dans un monde OO les lectures et les écritures sont limitées à l'instance sur laquelle la méthode est activée. Et puisque les données doivent être privé, vous savez que les changements d'état sont limités à cette classe. Et comme bon OO contraint les classes à se concentrer sur une seule responsabilité, il n'y a généralement pas d'état visible pour une méthode mais pas une dépendance de cette méthode.

Il y a une question sur la façon dont cela fonctionne bien dans la pratique, mais la même question s'applique également aux modèles de programmation fonctionnels ou impératifs.

tl; dr - OO limite la portée des changements d'état en limitant littéralement la portée de l'état.

6
Telastyn

La POO atténue ce problème particulier avec encapsulation.

Lorsque vous appelez une méthode (de l'extérieur), vous ne savez pas quels attributs internes peuvent être lus et modifiés. Mais en OO vous vous ne devriez pas savoir ou vous soucier.

Plus généralement, "l'unité" que vous raisonnez et testez est le objet, pas la fonction. Les attributs internes sont donc comme des variables locales à l'intérieur d'une fonction: vous ne vous souciez pas d'eux lorsque vous appelez la fonction, vous vous souciez uniquement du comportement observable et des entrées/sorties.

Lorsque vous testez des objets, vous ne testez pas en inspectant l'état interne. Ce serait en effet lourd et fragile. Au lieu de cela, vous testez le comportement de l'objet via son interface publique.

Je n'aime vraiment pas les exemples de foo/bar, alors prenons un exemple plus réaliste. Disons que vous avez un dictionnaire ordonné:

let dict = OrderedDictionary()
dict.Add("car", "voiture");
dict.Add("horse", "cheval");
print dict["horse"] --> cheval
print dict[0] --> voiture

Ce dictionnaire pourrait être implémenté de plusieurs manières, par exemple une liste chaînée de paires clé-valeur, une table de hachage combinée avec un tableau, etc. Cela pourrait même changer de stratégie en fonction du nombre d'articles. Le fait est que vous ne vous en souciez pas tant que cela fonctionne.

Considérez maintenant si tous les paramètres devaient être explicites:

dict_h = HashTable()
dict_l = Array()
dict_Add(dict_h, dict_l, "car", "voiture")
dict_Add(dict_h, dict_l, "horse", "cheval")
print dict_by_key(dict_h, "horse") --> cheval
print dict_by_index(dict_l, 0) --> voiture

Ici, il est explicite que par ex. dict_by_key utilise uniquement la table de hachage et non le tableau. Mais le prix de cette explication est vraiment élevé: vous transmettez les détails de la complexité et de la mise en œuvre aux clients, ce qui la répartit dans tout le programme et rend la modification de la mise en œuvre beaucoup plus difficile et risquée. (Peu importe qu'une table de hachage en elle-même se compose de plusieurs attributs)

Les langages fonctionnels résolvent généralement ce problème en utilisant des types d'enregistrement qui peuvent contenir plusieurs champs. Mais alors vous revenez au carré, que vous ne savez pas exactement lequel de ces champs est lu ou modifié par un appel de fonction.

2
JacquesB

Ce n'est normalement pas un problème, car dans le monde réel, les méthodes ne seraient pas (ne devraient pas ...) être nommées bar ou d'autres noms dénués de sens mais exprimeraient la sémantique du service fourni par un objet. La façon dont il implémente ce service ne vous regarde pas en tant qu'utilisateur de la classe, il peut s'agir d'une fonction pure ou d'un algorithme complexe qui conserve les résultats en cache, ou il peut déléguer à un service externe, ou autre. Vous pouvez à juste titre supposer qu'une méthode name() ne renverra pas une valeur différente à chaque fois que vous l'utiliserez, tandis que balance() peut renvoyer des valeurs différentes en fonction de deposit() et withdraw() opérations effectuées.

La responsabilité du développeur est de choisir une bonne sémantique et de bons noms (et de préférence les documenter sous une forme facilement compréhensible) et d'éviter les mauvaises surprises pour l'utilisateur. Ce n'est en fait pas très différent de la programmation non-OOP ...

1
Hans-Martin Mosner

Vous avez raison en ce que les conceptions classiques de OOP et la programmation fonctionnelle pure sont quelque peu contraires. Il existe des moyens d'atténuer cela cependant:

Une approche consiste à utiliser des objets immuables et à renvoyer un nouvel objet avec chaque appel de méthode qui modifie l'état. Cela fonctionne jusqu'à un certain point, et si vous utilisez la copie superficielle, ce n'est même pas si inefficace, mais cela devient assez lourd à mettre en œuvre.

Une extension de cette approche consiste à renvoyer de nouveaux objets uniquement lorsque "l'état observable" change, sinon retourner le même objet avec son état modifié - essentiellement une API fluide. Dans un langage purement fonctionnel, cela vous limite à peu près à la mise en cache et aux calculs locaux, mais si vous ne faites que du pragmatisme dans un langage avec état OO, vous pouvez vous donner beaucoup plus de marge de manœuvre .

Par exemple, considérez un modèle de "générateur" dans lequel vous créez un objet, puis mettez-le à jour avec une séquence de méthodes: si vous n'utilisez ce générateur nulle part ailleurs que pour son résultat final, vous pouvez toujours le muter avec chaque appel de méthode et toujours testez-le de manière propre en recréant le générateur avec la même séquence d'appels de méthode. Évidemment, cela ne s'applique que lorsque chaque appel de méthode est déterministe et ne dépend pas de données externes qui peuvent changer en dessous (comme le hasard ou les récupérations de base de données)

Le déterminisme est le mot clé ici: dans l'ensemble, vous n'allez jamais faire sortir la transparence référentielle d'un langage avec état OO, mais si vos méthodes sont déterministes dans la façon dont elles mettent à jour l'état interne, alors vous pouvez testez toujours n'importe quelle séquence d'appels en toute confiance et composez n'importe quel nombre de ces appels dans une méthode utilitaire.

1
Chuck Adams