Je me demande toujours s'il est légitime d'utiliser des verbes basés sur des noms en POO.
Je suis tombé sur ce article brillant , bien que je ne sois toujours pas d'accord avec le point soulevé.
Pour expliquer un peu plus le problème, l'article déclare qu'il ne devrait pas y avoir, par exemple, une classe FileWriter
, mais puisque l'écriture est une action ce doit être une méthode de la classe File
. Vous vous rendrez compte que cela dépend souvent du langage, car un programmeur Ruby serait probablement contre l'utilisation d'une classe FileWriter
(Ruby utilise la méthode File.open
pour accéder à un fichier), contrairement à un programmeur Java.
Mon point de vue personnel (et oui, très humble) est que cela enfreindrait le principe de responsabilité unique. Quand j'ai programmé en PHP (parce que PHP est évidemment le meilleur langage pour la POO, non?), J'utilisais souvent ce type de framework:
<?php
// This is just an example that I just made on the fly, may contain errors
class User extends Record {
protected $name;
public function __construct($name) {
$this->name = $name;
}
}
class UserDataHandler extends DataHandler /* knows the pdo object */ {
public function find($id) {
$query = $this->db->prepare('SELECT ' . $this->getFields . ' FROM users WHERE id = :id');
$query->bindParam(':id', $id, PDO::PARAM_INT);
$query->setFetchMode( PDO::FETCH_CLASS, 'user');
$query->execute();
return $query->fetch( PDO::FETCH_CLASS );
}
}
?>
Je crois comprendre que le suffixe DataHandler n'ajoute rien de pertinent ; mais le fait est que le principe de responsabilité unique nous dicte qu'un objet utilisé comme modèle contenant des données (peut-il être appelé un enregistrement) ne devrait pas également avoir la responsabilité de faire des requêtes SQL et un accès à la base de données. Cela invalide en quelque sorte le modèle ActionRecord utilisé par exemple par Ruby on Rails.
Je suis tombé sur ce code C # (yay, quatrième langage objet utilisé dans cet article) l'autre jour:
byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);
Et je dois dire que cela n'a pas beaucoup de sens pour moi qu'une classe Encoding
ou Charset
en fait encode chaînes. Il devrait simplement être une représentation de ce qu'est réellement un encodage.
Ainsi, j'aurais tendance à penser que:
File
d'ouvrir, de lire ou d'enregistrer des fichiers.Xml
de se sérialiser.User
d'interroger une base de données.Cependant, si nous extrapolons ces idées, pourquoi Object
aurait-il une classe toString
? Ce n'est pas la responsabilité d'une voiture ou d'un chien de se convertir en chaîne, n'est-ce pas?
Je comprends que d'un point de vue pragmatique, se débarrasser de la méthode toString
pour la beauté de suivre un formulaire strict SOLID, qui rend le code plus maintenable en le rendant inutile, n'est pas une option acceptable.
Je comprends également qu'il peut ne pas y avoir de réponse exacte (qui serait plus un essai qu'une réponse sérieuse) à cela, ou qu'elle peut être basée sur une opinion. Je voudrais néanmoins savoir si mon approche suit réellement le principe de la responsabilité unique.
Quelle est la responsabilité d'une classe?
Étant donné certaines divergences entre les langues, cela peut être un sujet délicat. Ainsi, je formule les commentaires suivants d'une manière qui essaie d'être aussi complète que possible dans le domaine de l'OO.
Tout d'abord, le soi-disant "principe de responsabilité unique" est un réflexe - explicitement déclaré - du concept de cohésion . En lisant la littérature de l'époque (autour de 70), les gens avaient (et ont toujours du mal) à définir ce qu'est un module et comment les construire de manière à préserver les propriétés niçoises. Donc, ils diraient "voici un tas de structures et de procédures, je vais en faire un module", mais sans aucun critère pour expliquer pourquoi cet ensemble de choses arbitraires sont regroupées, l'organisation pourrait finir par avoir peu de sens - peu de "cohésion". Des discussions sur les critères ont donc émergé.
Donc, la première chose à noter ici est que, jusqu'à présent, le débat porte sur l'organisation et les effets connexes sur la maintenance et la compréhensibilité (peu importe pour un ordinateur si un module "a du sens").
Ensuite, quelqu'un d'autre (M. Martin) est entré et a appliqué la même pensée à l'unité d'une classe comme critère à utiliser pour réfléchir à ce qui devrait ou ne devrait pas lui appartenir, promouvant ce critère en un principe, celui dont nous discutons. ici. Il a fait valoir que "Une classe ne devrait avoir qu'une seule raison de changer" .
Eh bien, nous savons par expérience que de nombreux objets (et de nombreuses classes) qui semblent faire "beaucoup de choses" ont une très bonne raison de le faire. Le cas indésirable serait les classes qui sont gonflées de fonctionnalités au point d'être impénétrables à la maintenance, etc. Et comprendre cette dernière, c'est voir où mr. Martin visait quand il a développé le sujet.
Bien sûr, après avoir lu ce que m. Martin a écrit, il devrait être clair que ce sont des critères de direction et de conception pour éviter les scénarios problématiques, en aucun cas pour poursuivre tout type de conformité, encore moins une forte conformité, spécialement lorsque la "responsabilité" est mal définie (et des questions comme "cela viole-t-il le principe?" sont de parfaits exemples de la confusion généralisée). Ainsi, je trouve regrettable que cela s'appelle un principe , induisant les gens en erreur pour essayer de le porter aux dernières conséquences, où cela ne servirait à rien. M. Martin lui-même a discuté des conceptions qui "font plus d'une chose" qui devraient probablement être conservées de cette façon, car la séparation donnerait de moins bons résultats. En outre, il existe de nombreux défis connus concernant la modularité (et ce sujet en est un cas), nous ne sommes pas au point d'avoir de bonnes réponses, même pour quelques questions simples à ce sujet.
Cependant, si nous extrapolons ces idées, pourquoi Object aurait-il une classe toString? Ce n'est pas la responsabilité d'une voiture ou d'un chien de se convertir en chaîne, n'est-ce pas?
Maintenant, permettez-moi de faire une pause pour dire quelque chose ici à propos de toString
: il y a une chose fondamentale généralement négligée quand on fait cette transition de la pensée des modules aux classes et que l'on réfléchit aux méthodes qui devraient appartenir à une classe. Et la chose est la répartition dynamique (aka, liaison tardive, "polymorphisme").
Dans un monde sans "méthodes de remplacement", choisir entre "obj.toString ()" ou "toString (obj)" est une question de préférence de syntaxe uniquement. Cependant, dans un monde où les programmeurs peuvent changer le comportement d'un programme en ajoutant une sous-classe avec une implémentation distincte d'une méthode existante/remplacée, ce choix n'a plus de goût: faire d'une procédure une méthode peut également en faire un candidat pour la substitution, et la même chose pourrait ne pas être vraie pour les "procédures libres" (les langages qui prennent en charge les méthodes multiples ont un moyen de sortir de cette dichotomie). Par conséquent, il ne s'agit plus uniquement d'une discussion sur l'organisation, mais aussi sur la sémantique. Enfin, à quelle classe la méthode est liée, devient également une décision impactante (et dans de nombreux cas, jusqu'à présent, nous n'avons guère plus que des lignes directrices pour nous aider à décider où les choses appartiennent, car des compromis non évidents émergent de différents choix) .
Enfin, nous sommes confrontés à des langages qui prennent de terribles décisions de conception, par exemple, en forçant un à créer une classe pour chaque petite chose. Ainsi, quelle était jadis la raison canonique et les principaux critères pour avoir des objets (et donc dans le terrain de classe, donc des classes), c'est-à-dire avoir ces "objets" qui sont en quelque sorte des "comportements qui se comportent aussi comme des données" , mais protéger leur représentation concrète (le cas échéant) contre la manipulation directe à tout prix (et c'est la principale indication de ce qui devrait être l'interface d'un objet, du point de vue de ses clients), devient floue et confuse.
[Remarque: je vais parler des objets ici. Les objets sont ce qu'est la programmation orientée objet, après tout, pas les classes.]
La responsabilité d'un objet dépend principalement de votre modèle de domaine. Il existe généralement de nombreuses façons de modéliser le même domaine, et vous choisirez l'une ou l'autre selon la façon dont le système sera utilisé.
Comme nous le savons tous, la méthodologie "verbe/nom" qui est souvent enseignée dans les cours d'introduction est ridicule, car elle dépend tellement de la façon dont vous formulez une phrase. Vous pouvez exprimer presque n'importe quoi sous la forme d'un nom ou d'un verbe, d'une voix active ou passive. Faire en sorte que votre modèle de domaine dépende de ce qui est faux, cela devrait être un choix de conception conscient que quelque chose soit ou non un objet ou une méthode, et pas seulement une conséquence accidentelle de la façon dont vous formulez les exigences.
Cependant, cela montre que, tout comme vous avez de nombreuses possibilités d'exprimer la même chose en anglais, vous avez de nombreuses possibilités d'exprimer la même chose dans votre modèle de domaine… et aucune de ces possibilités n'est intrinsèquement plus correcte que les autres. Ça dépend du contexte.
Voici un exemple qui est également très populaire dans les cours d'introduction OO: un compte bancaire. Qui est généralement modélisé comme un objet avec un champ balance
et un transfer
méthode. En d'autres termes: le solde du compte est des données , et un virement est une opération . C'est une façon raisonnable de modéliser un compte bancaire.
Sauf que ce n'est pas la façon dont les comptes bancaires sont modélisés dans les logiciels bancaires du monde réel (et en fait, ce n'est pas ainsi que les banques fonctionnent dans le monde réel). Au lieu de cela, vous avez un bordereau de transaction et le solde du compte est calculé en additionnant (et en soustrayant) tous les bordereaux de transaction pour un compte. En d'autres termes: le transfert est des données et le solde est une opération ! (Fait intéressant, cela rend également votre système purement fonctionnel, car vous n'avez jamais besoin de modifier le solde, les objets de votre compte et les objets de transaction n'ont jamais besoin de changer, ils sont immuables.)
Quant à votre question spécifique sur toString
, je suis d'accord. Je préfère de loin la solution Haskell d'un Showable
type class . (Scala nous apprend que les classes de type correspondent parfaitement à OO.) Idem pour l'égalité. L'égalité est très souvent pas une propriété d'un objet mais du contexte dans lequel l'objet est utilisé. Pensez simplement aux nombres à virgule flottante: quel devrait être l'epsilon? Encore une fois, Haskell a la classe de type Eq
.
Trop souvent, le principe de responsabilité unique devient un principe de responsabilité zéro, et vous vous retrouvez avec classes anémiques qui ne font rien (sauf les setters et les getters), ce qui conduit à une catastrophe du royaume des noms .
Vous ne l'obtiendrez jamais parfait, mais mieux pour une classe d'en faire un peu trop que trop peu.
Dans votre exemple avec Encoding, IMO, il devrait certainement être capable d'encoder. Que pensez-vous qu'il devrait faire à la place? Le simple fait d'avoir un nom "utf" est sans responsabilité. Maintenant, le nom devrait peut-être être Encoder. Mais, comme l'a dit Konrad, les données (quel encodage) et le comportement (le faire) appartiennent ensemble .
Une instance d'une classe est une fermeture. C'est ça. Si vous pensez de cette façon, tous les logiciels bien conçus que vous regardez seront corrects, et tous les logiciels mal pensés ne le seront pas. Permettez-moi de développer.
Si vous voulez écrire quelque chose à écrire dans un fichier, pensez à toutes les choses que vous devez spécifier (au système d'exploitation): nom de fichier, méthode d'accès (lecture, écriture, ajout), la chaîne que vous souhaitez écrire.
Vous construisez donc un objet File avec le nom de fichier (et la méthode d'accès). L'objet File est maintenant fermé sur le nom de fichier (dans ce cas, il l'aura probablement pris en tant que valeur en lecture seule/const). L'instance File est maintenant prête à prendre des appels à la méthode "write" définie dans la classe. Cela prend juste un argument de chaîne, mais dans le corps de la méthode d'écriture, l'implémentation, il y a également accès au nom de fichier (ou au descripteur de fichier qui a été créé à partir de celui-ci).
Une instance de la classe File regroupe donc certaines données dans une sorte d'objet composite afin que l'utilisation ultérieure de cette instance soit plus simple. Lorsque vous avez un objet File, vous n'avez pas à vous soucier du nom du fichier, vous ne vous préoccupez que de la chaîne que vous mettez en argument dans l'appel en écriture. Pour réitérer - l'objet File encapsule tout ce que vous n'avez pas besoin de savoir où va réellement la chaîne que vous voulez écrire.
La plupart des problèmes que vous souhaitez résoudre sont superposés de cette façon - les choses créées à l'avance, les choses créées au début de chaque itération d'une sorte de boucle de processus, puis différentes choses déclarées dans les deux moitiés d'un if sinon, puis les choses créées à le début d'une sorte de sous-boucle, puis les choses déclarées comme une partie de l'algorithme dans cette boucle, etc. Lorsque vous ajoutez de plus en plus d'appels de fonctions à la pile, vous faites du code de plus en plus fin, mais chaque fois que vous aurez enfermé les données dans les couches inférieures de la pile dans une sorte d'abstraction de Nice qui le rend facile d'accès. Voyez ce que vous faites en utilisant "OO" comme une sorte de cadre de gestion de fermeture pour une approche de programmation fonctionnelle.
La solution de raccourci à certains problèmes implique un état mutable des objets, ce qui n'est pas si fonctionnel - vous manipulez les fermetures de l'extérieur. Vous rendez certaines des choses de votre classe "globales", du moins à la portée ci-dessus. Chaque fois que vous écrivez un setter, essayez de les éviter. Je dirais que c'est là que vous avez eu la responsabilité de votre classe, ou des autres classes dans lesquelles elle opère, mal. Mais ne vous inquiétez pas trop - il est parfois pragmatique d'incorporer un peu d'état mutable pour résoudre rapidement certains types de problèmes, sans trop de charge cognitive, et vraiment faire quoi que ce soit.
Donc en résumé, pour répondre à votre question - quelle est la vraie responsabilité d'une classe? Il s'agit de présenter une sorte de plate-forme, basée sur certaines données sous-jacentes, pour obtenir un type d'opération plus détaillé. Il s'agit d'encapsuler la moitié des données impliquées dans une opération qui change moins fréquemment que l'autre moitié des données (pensez currying ...). Par exemple. nom de fichier vs chaîne à écrire.
Souvent, ces encapsulations ressemblent superficiellement à un objet du monde réel/un objet dans le domaine problématique, mais ne vous y trompez pas. Lorsque vous créez une classe Car, ce n'est pas vraiment une voiture, c'est une collection de données qui forme une plate-forme sur laquelle réaliser certains types de choses que vous pourriez envisager de faire sur une voiture. La formation d'une représentation sous forme de chaîne (toString) est l'une de ces choses - cracher toutes ces données internes. N'oubliez pas que parfois une classe Car peut ne pas être la bonne classe même si le domaine problématique concerne les voitures. Contrairement au royaume des noms, ce sont les opérations, les verbes autour desquels une classe doit être basée.
Je comprends également qu'il peut ne pas y avoir de réponse exacte (qui serait plus un essai qu'une réponse sérieuse) à cela, ou qu'elle peut être basée sur une opinion. Je voudrais néanmoins savoir si mon approche suit réellement le principe de la responsabilité unique.
Bien que ce soit le cas, il ne s'agit pas nécessairement d'un bon code. Au-delà du simple fait que toute sorte de règle suivie aveuglément conduit à un mauvais code, vous partez d'une prémisse invalide: les objets (de programmation) ne sont pas des objets (physiques).
Les objets sont un ensemble d'ensembles cohérents de données et d'opérations (et parfois seulement l'un des deux). Bien que ceux-ci modélisent souvent des objets du monde réel, la différence entre un modèle informatique de quelque chose et cette chose elle-même nécessite des différences.
En prenant cette ligne dure entre un "nom" qui représente une chose et d'autres choses qui les consomment, vous allez à l'encontre d'un avantage clé de la programmation orientée objet (avoir la fonction et l'état ensemble afin que la fonction puisse protéger les invariants de l'état) . Pire, vous essayez de représenter le monde physique dans du code qui, comme l'histoire l'a montré, ne fonctionnera pas bien.
Je suis tombé sur ce code C # (yay, quatrième langage objet utilisé dans cet article) l'autre jour:
byte[] bytes = Encoding.Default.GetBytes(myString); myString = Encoding.UTF8.GetString(bytes);
Et je dois dire que cela n'a pas beaucoup de sens pour moi qu'une classe
Encoding
ouCharset
en fait code cordes. Il devrait simplement être une représentation de ce qu'est réellement un encodage.
En théorie, oui, mais je pense que C # fait un compromis justifié par souci de simplicité (par opposition à l'approche plus stricte de Java).
Si Encoding
ne représentait (ou identifiait) qu'un certain encodage - disons UTF-8 - alors vous auriez évidemment besoin d'une classe Encoder
, aussi, pour pouvoir implémenter GetBytes
dessus - mais alors vous devez gérer la relation entre Encoder
s et Encoding
s, donc nous nous retrouvons avec notre bon vieux '
EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)
si brillamment critiqué dans cet article auquel vous avez lié (bonne lecture, soit dit en passant).
Si je vous comprends bien, vous demandez où est la ligne.
Eh bien, de l'autre côté du spectre est l'approche procédurale, pas difficile à implémenter dans les langages OOP avec l'utilisation de classes statiques:
HelpfulStuff.GetUTF8Bytes(myString)
Le gros problème est que lorsque l'auteur de HelpfulStuff
part et que je suis assis devant le moniteur, je n'ai aucun moyen de savoir où GetUTF8Bytes
est implémenté.
Est-ce que je le recherche dans HelpfulStuff
, Utils
, StringHelper
?
Seigneur, aidez-moi si la méthode est effectivement mise en œuvre indépendamment dans les trois ... ce qui arrive souvent, tout ce qu'il faut, c'est que le gars avant moi ne savait pas non plus où chercher cette fonction et croyait qu'il n'y en avait pas pourtant ils en ont sorti un autre et maintenant nous les avons en abondance.
Pour moi, une bonne conception ne consiste pas à satisfaire des critères abstraits, mais à ma facilité à continuer après m'être assis devant votre code. Maintenant, comment
EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)
taux dans cet aspect? Mal aussi. Ce n'est pas une réponse que je veux entendre quand je crie à mon collègue "comment puis-je encoder une chaîne en une séquence d'octets?" :)
J'irais donc avec une approche modérée et serais libéral sur la responsabilité unique. Je préférerais que la classe Encoding
identifie à la fois un type d'encodage et fasse l'encodage réel: des données non séparées du comportement.
Je pense que cela passe comme un motif de façade, ce qui aide à graisser les engrenages. Ce modèle légitime viole-t-il SOLID? Une question connexe: Le modèle de façade viole-t-il SRP?
Notez que cela pourrait être la façon dont la mise en œuvre des octets codés est implémentée en interne (dans les bibliothèques .NET). Les classes ne sont tout simplement pas visibles publiquement et seule cette API "façadeeuse" est exposée à l'extérieur. Ce n'est pas si difficile de vérifier si c'est vrai, je suis juste un peu trop paresseux pour le faire en ce moment :) Ce n'est probablement pas le cas, mais cela n'invalide pas mon propos.
Vous pouvez choisir de simplifier l'implémentation visible par le public par souci de convivialité tout en conservant une implémentation canonique plus sévère en interne.
Dans Java l'abstraction utilisée pour représenter les fichiers est un flux, certains flux sont unidirectionnels (lecture seule ou écriture uniquement), d'autres sont bidirectionnels. Pour Ruby la classe File est l'abstraction qui représente les fichiers du système d'exploitation. La question devient alors de savoir quelle est sa seule responsabilité. En Java, la responsabilité de FileWriter est de fournir un flux unidirectionnel d'octets qui est connecté à un fichier du système d'exploitation. Dans Ruby, la responsabilité du fichier est de fournir un accès bidirectionnel à un fichier système. Ils remplissent tous deux le PRS, ils ont juste des responsabilités différentes.
Ce n'est pas la responsabilité d'une voiture ou d'un chien de se convertir en chaîne, n'est-ce pas?
Bien sûr, pourquoi pas? Je m'attends à ce que la classe Car représente pleinement une abstraction Car. S'il est logique que l'abstraction de voiture soit utilisée lorsqu'une chaîne est nécessaire, je m'attends à ce que la classe Car prenne en charge la conversion en chaîne.
Répondre directement à la question plus vaste de la responsabilité d'une classe, c'est agir comme une abstraction. Ce travail peut être complexe, mais c'est un peu le point, une abstraction devrait cacher des choses complexes derrière une interface plus facile à utiliser et moins complexe. Le SRP est une ligne directrice de conception, vous vous concentrez donc sur la question "que représente cette abstraction?". Si plusieurs comportements différents sont nécessaires pour abstraire complètement le concept, qu'il en soit ainsi.
La classe peut avoir plusieurs responsabilités. Au moins dans la POO classique centrée sur les données.
Si vous appliquez pleinement le principe de responsabilité unique, vous obtenez conception centrée sur la responsabilité où, fondamentalement, chaque méthode non assistante appartient à sa propre classe.
Je pense qu'il n'y a rien de mal à extraire un morceau de complexité d'une classe de base comme dans le cas de File et FileWriter. L'avantage est que vous obtenez une séparation claire du code qui est responsable d'une certaine opération et que vous pouvez remplacer (sous-classe) juste ce morceau de code au lieu de remplacer toute la classe de base (par exemple, File). Néanmoins, l'appliquer systématiquement me semble exagéré. Vous obtenez simplement beaucoup plus de classes à gérer et pour chaque opération, vous devez appeler sa classe respective. C'est la flexibilité qui a un prix.
Ces classes extraites doivent avoir des noms très descriptifs en fonction de l'opération qu'elles contiennent, par ex. <X>Writer
, <X>Reader
, <X>Builder
. Des noms comme <X>Manager
, <X>Handler
ne décrit pas du tout l'opération et s'ils sont appelés ainsi parce qu'ils contiennent plus d'une opération, alors ce que vous avez même réalisé n'est pas clair. Vous avez séparé la fonctionnalité de ses données, vous enfreignez toujours le principe de responsabilité unique, même dans cette classe extraite, et si vous recherchez une certaine méthode, vous ne savez peut-être pas où la chercher (si dans <X>
ou <X>Manager
).
Maintenant, votre cas avec UserDataHandler est différent car il n'y a pas de classe de base (la classe qui contient des données réelles). Les données de cette classe sont stockées dans une ressource externe (base de données) et vous utilisez une API pour y accéder et les manipuler. Votre classe d'utilisateurs représente un utilisateur au moment de l'exécution, ce qui est vraiment différent d'un utilisateur persistant et la séparation des responsabilités (préoccupations) peut être utile ici, surtout si vous avez beaucoup de logique liée à l'utilisateur au moment de l'exécution.
Vous avez probablement nommé UserDataHandler comme ça parce qu'il n'y a essentiellement que des fonctionnalités dans cette classe et aucune donnée réelle. Cependant, vous pouvez également faire abstraction de ce fait et considérer que les données de la base de données appartiennent à la classe. Avec cette abstraction, vous pouvez nommer la classe en fonction de ce qu'elle est et non de ce qu'elle fait. Vous pouvez lui donner un nom comme UserRepository, qui est assez lisse et suggère également l'utilisation de modèle de référentiel .