J'essaie d'adhérer au principe de responsabilité unique (SRP) autant que possible et je me suis habitué à un certain modèle (pour le SRP sur les méthodes) en s'appuyant fortement sur les délégués. Je voudrais savoir si cette approche est valable ou si elle présente de graves problèmes.
Par exemple, pour vérifier l'entrée d'un constructeur, je pourrais introduire la méthode suivante (l'entrée Stream
est aléatoire, peut être n'importe quoi)
private void CheckInput(Stream stream)
{
if(stream == null)
{
throw new ArgumentNullException();
}
if(!stream.CanWrite)
{
throw new ArgumentException();
}
}
Cette méthode (sans doute) fait plus d'une chose
Pour adhérer au SRP, j'ai donc changé la logique en
private void CheckInput(Stream stream,
params (Predicate<Stream> predicate, Action action)[] inputCheckers)
{
foreach(var inputChecker in inputCheckers)
{
if(inputChecker.predicate(stream))
{
inputChecker.action();
}
}
}
Ce qui ne fait qu'une seule chose (n'est-ce pas?): Vérifiez l'entrée. Pour la vérification réelle des entrées et la levée des exceptions, j'ai introduit des méthodes comme
bool StreamIsNull(Stream s)
{
return s == null;
}
bool StreamIsReadonly(Stream s)
{
return !s.CanWrite;
}
void Throw<TException>() where TException : Exception, new()
{
throw new TException();
}
et peut appeler CheckInput
comme
CheckInput(stream,
(this.StreamIsNull, this.Throw<ArgumentNullException>),
(this.StreamIsReadonly, this.Throw<ArgumentException>))
Est-ce mieux que la première option ou est-ce que j'introduis une complexité inutile? Existe-t-il un moyen d'améliorer encore ce modèle, s'il est viable du tout?
SRP est peut-être le principe logiciel le plus mal compris.
Une application logicielle est construite à partir de modules, qui sont construits à partir de modules, qui sont construits à partir de ...
En bas, une seule fonction telle que CheckInput
ne contiendra qu'un tout petit peu de logique, mais à mesure que vous montez, chaque module successif encapsule de plus en plus de logique et c'est normal .
SRP ne consiste pas à effectuer une seule action atomique . Il s'agit d'avoir une seule responsabilité, même si cette responsabilité nécessite plusieurs actions ... et, finalement, il s'agit de maintenance et testabilité :
Le fait que CheckInput
soit implémenté avec deux vérifications et déclenche deux exceptions différentes est non pertinent dans une certaine mesure.
CheckInput
a une responsabilité étroite: s'assurer que l'entrée est conforme aux exigences. Oui, les exigences sont multiples, mais cela ne signifie pas qu'il y a plusieurs responsabilités. Oui, vous pourriez diviser les chèques, mais en quoi cela aiderait-il? À un moment donné, les chèques doivent être répertoriés d'une manière ou d'une autre.
Comparons:
Constructor(Stream stream) {
CheckInput(stream);
// ...
}
contre:
Constructor(Stream stream) {
CheckInput(stream,
(this.StreamIsNull, this.Throw<ArgumentNullException>),
(this.StreamIsReadonly, this.Throw<ArgumentException>));
// ...
}
Maintenant, CheckInput
en fait moins ... mais son appelant en fait plus!
Vous avez déplacé la liste des exigences de CheckInput
, où elles sont encapsulées, vers Constructor
où elles sont visibles.
Est-ce un bon changement? Ça dépend:
CheckInput
n'est appelé que là: c'est discutable, d'une part il rend les exigences visibles, d'autre part il encombre le code;CheckInput
est appelé plusieurs fois avec les mêmes exigences , alors il viole DRY et vous avez un problème d'encapsulation.Il est important de réaliser qu'une seule responsabilité peut impliquer un beaucoup de travail. Le "cerveau" d'une voiture autonome a une seule responsabilité:
Conduire la voiture jusqu'à sa destination.
C'est une responsabilité unique, mais nécessite de coordonner une tonne de capteurs et d'acteurs, de prendre beaucoup de décisions et peut même avoir des exigences contradictoires1...
... cependant, tout est encapsulé. Donc, le client s'en fiche.
1 sécurité des passagers, sécurité des autres, respect des réglementations, ...
Citant l'oncle Bob au sujet du SRP ( https://8thlight.com/blog/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html ):
Le principe de responsabilité unique (SRP) stipule que chaque module logiciel doit avoir une et une seule raison de changer.
... Ce principe concerne les gens.
... Lorsque vous écrivez un module logiciel, vous voulez vous assurer que lorsque des modifications sont demandées, ces modifications ne peuvent provenir que d'une seule personne, ou plutôt d'un groupe de personnes étroitement couplé représentant une seule fonction métier étroitement définie.
... C'est la raison pour laquelle nous ne mettons pas SQL dans les JSP. C'est la raison pour laquelle nous ne générons pas de code HTML dans les modules qui calculent les résultats. C'est la raison pour laquelle les règles métier ne doivent pas connaître le schéma de la base de données. C'est la raison pour laquelle nous séparons les préoccupations.
Il explique que les modules logiciels doivent répondre aux préoccupations spécifiques des parties prenantes. Par conséquent, répondant à votre question:
Est-ce mieux que la première option ou est-ce que j'introduis une complexité inutile? Existe-t-il un moyen d'améliorer encore ce modèle, s'il est viable du tout?
OMI, vous ne regardez qu'une seule méthode, alors que vous devriez regarder à un niveau supérieur (niveau de classe dans ce cas). Peut-être devrions-nous jeter un œil à ce que fait actuellement votre classe (et cela nécessite plus d'explications sur votre scénario). Pour l'instant, votre classe fait toujours la même chose. Par exemple, si demain il y a une demande de changement concernant une validation (par exemple: "maintenant le flux peut être nul"), alors vous devez toujours aller dans cette classe et changer des choses en son sein.
Non, ce changement n'est pas informé par le SRP.
Demandez-vous pourquoi votre vérificateur ne vérifie pas "l'objet transmis est un flux". La réponse est évidente: la langue empêche l'appelant de compiler un programme qui passe dans un non-flux.
Le système de type de C # est insuffisant pour répondre à vos besoins; vos vérifications sont implémentant l'application des invariants qui ne peuvent pas être exprimés dans le système de type aujourd'hui. S'il y avait un moyen de dire que la méthode prend un flux inscriptible non nullable, vous l'auriez écrit, mais ce n'est pas le cas, alors vous avez fait la meilleure chose suivante: vous avez appliqué la restriction de type lors de l'exécution. J'espère que vous l'avez également documenté, afin que les développeurs qui utilisent votre méthode n'aient pas à la violer, à échouer leurs cas de test, puis à résoudre le problème.
Mettre des types sur une méthode n'est pas une violation du principe de responsabilité unique; ni la méthode imposant ses conditions préalables ou affirmant ses postconditions.
Toutes les responsabilités ne sont pas créées égales.
Voici deux tiroirs. Ils ont tous deux une responsabilité. Ils ont chacun des noms qui vous permettent de savoir ce qui leur appartient. L'un est le tiroir à couverts. L'autre est le tiroir à ordures.
Alors quelle est la différence? Le tiroir à couverts montre clairement ce qui n'y appartient pas. Le tiroir à ordures accepte cependant tout ce qui conviendra. Sortir les cuillères du tiroir à couverts semble très mal. Pourtant, j'ai du mal à penser à tout ce qui manquerait s'il était retiré du tiroir à ordures. La vérité est que vous pouvez prétendre que tout a une seule responsabilité, mais qui, selon vous, a la responsabilité unique la plus ciblée?
Un objet ayant une seule responsabilité ne signifie pas qu'une seule chose peut se produire ici. Les responsabilités peuvent s'imbriquer. Mais ces responsabilités de nidification devraient avoir un sens, elles ne devraient pas vous surprendre lorsque vous les trouvez ici et vous devriez les manquer si elles étaient parties.
Alors quand vous offrez
CheckInput(Stream stream);
Je ne me préoccupe pas du fait qu'il vérifie à la fois les entrées et les exceptions. Je serais inquiet s'il vérifiait à la fois les entrées et les sauvegardait également. C'est une mauvaise surprise. Celui que je ne manquerais pas s'il avait disparu.
Lorsque vous vous nouez et écrivez du code bizarre afin de vous conformer à un principe logiciel important, vous avez généralement mal compris le principe (bien que parfois le principe soit faux). Comme le souligne l'excellente réponse de Matthieu, toute la signification de SRP dépend de la définition de la "responsabilité".
Les programmeurs expérimentés voient ces principes et les relient aux mémoires de code que nous avons ratées; les programmeurs moins expérimentés les voient et n'ont peut-être rien à voir du tout. C'est une abstraction flottant dans l'espace, tout sourire et pas de chat. Alors ils devinent, et ça va généralement mal. Avant d'avoir développé le sens du cheval de programmation, la différence entre un code excessivement compliqué et un code normal n'est pas du tout évidente.
Ce n'est pas un commandement religieux auquel vous devez obéir indépendamment des conséquences personnelles. Il s'agit plus d'une règle empirique destinée à formaliser un élément de la programmation du sens du cheval et à vous aider à garder votre code aussi simple et clair que possible. Si cela a l'effet inverse, vous avez raison de chercher des entrées extérieures.
En programmation, vous ne pouvez pas vous tromper beaucoup plus que d'essayer de déduire la signification d'un identifiant à partir des premiers principes en le regardant simplement, et cela vaut pour les identifiants par écrit à propos la programmation autant que les identifiants dans code réel.
Tout d'abord, permettez-moi de mettre l'évidence là-bas, CheckInput
is faisant une chose, même si elle vérifie divers aspects. En fin de compte, il vérifie l'entrée. On pourrait faire valoir que ce n'est pas une chose si vous traitez avec des méthodes appelées DoSomething
, mais je pense qu'il est prudent de supposer que la vérification des entrées n'est pas trop vague.
L'ajout de ce modèle pour les prédicats pourrait être utile si vous ne voulez pas que la logique de vérification de l'entrée soit placée dans votre classe, mais ce modèle semble plutôt détaillé pour ce que vous essayez de réaliser. Il peut être beaucoup plus direct de simplement passer une interface IStreamValidator
avec une seule méthode isValid(Stream)
si c'est ce que vous souhaitez obtenir. Toute classe implémentant IStreamValidator
peut utiliser des prédicats comme StreamIsNull
ou StreamIsReadonly
si elle le souhaite, mais pour en revenir au point central, c'est un changement assez ridicule à faire dans l'intérêt de maintien du principe de responsabilité unique.
C'est mon idée que nous sommes tous autorisés à faire un "test de santé mentale" pour nous assurer que vous traitez au moins avec un Stream qui n'est pas nul et inscriptible, et ce contrôle de base ne fait pas de votre façon une classe validateur de flux. Attention, il serait préférable de laisser des contrôles plus sophistiqués en dehors de votre classe, mais c'est là que la ligne est tracée. Une fois que vous devez commencer à changer l'état de votre flux en le lisant ou en consacrant des ressources à la validation, vous avez commencé à effectuer une formelle validation de votre flux et this est ce qui devrait être tiré dans sa propre classe.
Je pense que si vous appliquez un modèle pour mieux organiser un aspect de votre classe, il mérite d'être dans sa propre classe. Puisqu'un modèle ne convient pas, vous devez également vous demander s'il appartient vraiment à sa propre classe en premier lieu. À mon avis, à moins que vous ne pensiez que la validation du flux sera probablement modifiée à l'avenir, et surtout si vous pensez que cette validation peut même être de nature dynamique, le modèle que vous avez décrit est une bonne idée, même s'il peut être trivial au départ. Sinon, il n'est pas nécessaire de rendre arbitrairement votre programme plus complexe. Appelons un chat un chat. La validation est une chose, mais la vérification d'une entrée nulle n'est pas une validation, et donc je pense que vous pouvez être sûr de le garder dans votre classe sans violer le principe de responsabilité unique.
Le principe n'énonce pas catégoriquement qu'un morceau de code ne devrait "faire qu'une seule chose".
La "responsabilité" dans le SRP doit être comprise au niveau des exigences. La responsabilité du code est de satisfaire aux exigences de l'entreprise. SRP est violé si un objet satisfait plus d'un exigences métier indépendantes. Par indépendant, cela signifie qu'une exigence pourrait changer pendant que l'autre exigence reste en place.
Il est concevable qu'une nouvelle exigence métier soit introduite, ce qui signifie que cet objet particulier ne devrait pas vérifier sa lisibilité, tandis qu'une autre exigence métier nécessite toujours que l'objet soit vérifié pour être lisible? Non, car les exigences métier ne spécifient pas les détails d'implémentation à ce niveau.
Un exemple réel d'une violation SRP serait un code comme celui-ci:
var message = "Your package will arrive before " + DateTime.Now.AddDays(14);
Ce code est très simple, mais il est toujours envisageable que le texte change indépendamment de la date de livraison prévue, car celles-ci sont décidées par différentes parties de l'entreprise.
Votre approche est actuellement procédurale. Vous séparez l'objet Stream
et le validez de l'extérieur. Ne faites pas cela - cela rompt l'encapsulation. Laissez le Stream
être responsable de sa propre validation. Nous ne pouvons pas chercher à appliquer le SRP avant d'avoir des classes auxquelles l'appliquer.
Voici un Stream
qui exécute une action uniquement si elle passe la validation:
class Stream
{
public void someAction()
{
if(!stream.canWrite)
{
throw new ArgumentException();
}
System.out.println("My action");
}
}
Mais maintenant nous violons SRP! "Une classe ne devrait avoir qu'une seule raison de changer." Nous avons un mélange de 1) validation et 2) logique réelle. Nous avons deux raisons pour lesquelles cela pourrait devoir changer.
Nous pouvons résoudre ce problème avec validation des décorateurs . Tout d'abord, nous devons convertir notre Stream
en une interface et l'implémenter en tant que classe concrète.
interface Stream
{
void someAction();
}
class DefaultStream implements Stream
{
@Override
public void someAction()
{
System.out.println("My action");
}
}
Nous pouvons maintenant écrire un décorateur qui encapsule un Stream
, effectue la validation et diffère le Stream
donné pour la logique réelle de l'action.
class WritableStream implements Stream
{
private final Stream stream;
public WritableStream(final Stream stream)
{
this.stream = stream;
}
@Override
public void someAction()
{
if(!stream.canWrite)
{
throw new ArgumentException();
}
stream.someAction();
}
}
Nous pouvons maintenant les composer comme bon nous semble:
final Stream myStream = new WritableStream(
new DefaultStream()
);
Vous voulez une validation supplémentaire? Ajoutez un autre décorateur.
J'aime le point de @ la réponse d'EricLippert :
Demandez-vous pourquoi il n'y a pas de vérification dans votre vérificateur pour l'objet transmis est un flux . La réponse est évidente: la langue empêche l'appelant de de compiler un programme qui passe dans un non-flux.
Le système de type de C # est insuffisant pour répondre à vos besoins; vos vérifications implémentent l'application des invariants qui ne peuvent pas être exprimés dans le système de type aujourd'hui . S'il y avait un moyen de dire que la méthode prend un flux inscriptible non nullable, vous l'auriez écrit, mais ce n'est pas le cas, alors vous avez fait la meilleure chose suivante: vous avez appliqué la restriction de type lors de l'exécution. J'espère que vous l'avez également documenté, afin que les développeurs qui utilisent votre méthode n'aient pas à la violer, à échouer leurs cas de test, puis à résoudre le problème.
EricLippert a raison que ce soit un problème pour le système de type. Et puisque vous souhaitez utiliser le principe de responsabilité unique (SRP), vous avez essentiellement besoin du système de type pour être responsable de ce travail.
Il est en fait possible de faire cela en C #. Nous pouvons attraper les littéraux null
au moment de la compilation, puis attraper les non-littéraux null
au moment de l'exécution. Ce n'est pas aussi bon qu'une vérification complète à la compilation, mais c'est une amélioration stricte par rapport à ne jamais intercepter à la compilation.
Donc, tu sais comment C # a Nullable<T>
? Inversons cela et faisons un NonNullable<T>
:
public struct NonNullable<T> where T : class
{
public T Value { get; private set; }
public NonNullable(T value)
{
if (value == null) { throw new NullArgumentException(); }
this.Value = value;
}
// Ease-of-use:
public static implicit operator T(NonNullable<T> value) { return value.Value; }
public static implicit operator NonNullable<T>(T value) { return new NonNullable<T>(value); }
// Hack-ish overloads that prevent null-literals from being implicitly converted into NonNullable<T>'s.
public static implicit operator NonNullable<T>(Tuple<T> value) { return new NonNullable<T>(value.Item1); }
public static implicit operator NonNullable<T>(Tuple<T, T> value) { return new NonNullable<T>(value.Item1); }
}
Maintenant, au lieu d'écrire
public void Foo(Stream stream)
{
if (stream == null) { throw new NullArgumentException(); }
// ...method code...
}
, Ecrivez:
public void Foo(NonNullable<Stream> stream)
{
// ...method code...
}
Ensuite, il y a trois cas d'utilisation:
L'utilisateur appelle Foo()
avec un Stream
non nul:
Stream stream = new Stream();
Foo(stream);
C'est le cas d'utilisation souhaité, et cela fonctionne avec ou sans NonNullable<>
.
L'utilisateur appelle Foo()
avec un Stream
nul:
Stream stream = null;
Foo(stream);
Il s'agit d'une erreur d'appel. Ici, NonNullable<>
Aide à informer l'utilisateur qu'il ne doit pas faire cela, mais cela ne les arrête pas réellement. Dans les deux cas, cela se traduit par un temps d'exécution NullArgumentException
.
L'utilisateur appelle Foo()
avec null
:
Foo(null);
null
ne sera pas implicitement converti en NonNullable<>
, Donc l'utilisateur obtient une erreur dans l'EDI, avant l'exécution -temps. Ceci délègue la vérification nulle au système de type, comme le SRP le conseillerait.
Vous pouvez également étendre cette méthode pour affirmer d'autres choses sur vos arguments. Par exemple, puisque vous voulez un flux inscriptible, vous pouvez définir un struct WriteableStream<T> where T:Stream
Qui vérifie à la fois null
et stream.CanWrite
Dans le constructeur. Ce serait toujours une vérification de type d'exécution, mais:
Il décore le type avec le qualificatif WriteableStream
, signalant la nécessité aux appelants.
Il effectue la vérification en un seul endroit dans le code, vous n'avez donc pas à répéter la vérification et throw InvalidArgumentException
À chaque fois.
Il se conforme mieux au SRP en poussant les tâches de vérification de type vers le système de type (tel qu'étendu par les décorateurs génériques).
Le travail d'une classe est de fournir un service qui répond à un contrat . Une classe a toujours un contrat: un ensemble d'exigences pour l'utiliser, et promet qu'elle fait de son état et de ses sorties à condition que les exigences soient remplies. Ce contrat peut être explicite, par le biais de documentation et/ou d'assertions, ou implicite, mais il existe toujours.
Une partie du contrat de votre classe est que l'appelant donne au constructeur des arguments qui ne doivent pas être nuls. La mise en œuvre du contrat est la responsabilité de la classe, de sorte que vérifier que l'appelant a rempli sa part du contrat peut facilement être considéré comme faisant partie du champ d'application de la la responsabilité de la classe.
L'idée qu'une classe implémente un contrat est due à Bertrand Meyer , le concepteur du langage de programmation Eiffel et de l'idée de design by contract . La langue Eiffel intègre la spécification et la vérification du contrat dans la langue.
Comme cela a été souligné dans d'autres réponses, SRP est souvent mal compris. Il ne s'agit pas d'avoir du code atomique qui ne fait qu'une seule fonction. Il s'agit de s'assurer que vos objets et méthodes ne font qu'une seule chose, et que la seule chose ne se fait qu'au même endroit.
Regardons un mauvais exemple de pseudo-code.
class Math
private int a;
private int b;
def constructor(int x, int y)
if(x != null)
a = x
else if(x < 0)
a = abs(x)
else if (x == -1)
throw "Some Silly Error"
else
a = 0
end
if(y != null)
b = y
else if(y < 0)
b = abs(y)
else if(y == -1)
throw "Some Silly Error"
else
b = 0
end
end
def add()
return a + b
end
def sub()
return b - a
end
end
Dans notre exemple plutôt absurde, la "responsabilité" du constructeur Math # est de rendre l'objet mathématique utilisable. Il le fait en nettoyant d'abord l'entrée, puis en s'assurant que les valeurs ne sont pas -1.
Il s'agit d'un SRP valide car le constructeur ne fait qu'une seule chose. Il prépare l'objet Math. Cependant, ce n'est pas très maintenable. Il viole SEC.
Prenons donc une autre passe
class Math
private int a;
private int b;
def constructor(int x, int y)
cleanX(x)
cleanY(y)
end
def cleanX(int x)
if(x != null)
a = x
else if(x < 0)
a = abs(x)
else if (x == -1)
throw "Some Silly Error"
else
a = 0
end
end
def cleanY(int y)
if(y != null)
b = y
else if(y < 0)
b = abs(y)
else if(y == -1)
throw "Some Silly Error"
else
b = 0
end
end
def add()
return a + b
end
def sub()
return b - a
end
end
Dans cette passe, nous avons un peu amélioré DRY, mais nous avons encore du chemin à faire avec DRY. SRP en revanche semble un peu décalé. Nous avons maintenant deux fonctions avec le même travail. CleanX et cleanY assainissent les entrées.
Essayons de nouveau
class Math
private int a;
private int b;
def constructor(int x, int y)
a = clean(x)
b = clean(y)
end
def clean(int i)
if(i != null)
return i
else if(i < 0)
return abs(i)
else if (i == -1)
throw "Some Silly Error"
else
return 0
end
end
def add()
return a + b
end
def sub()
return b - a
end
end
Maintenant, ils étaient finalement meilleurs sur DRY, et SRP semble être d'accord. Nous n'avons qu'un seul endroit qui fait le travail de "désinfection".
Le code est en théorie plus maintenable et encore meilleur quand nous allons corriger le bogue et resserrer le code, nous n'avons qu'à le faire en un seul endroit.
class Math
private int a;
private int b;
def constructor(int x, int y)
a = clean(x)
b = clean(y)
end
def clean(int i)
if(i == null)
return 0
else if (i == -1)
throw "Some Silly Error"
else
return abs(i)
end
end
def add()
return a + b
end
def sub()
return b - a
end
end
Dans la plupart des cas du monde réel, les objets seraient plus complexes et SRP serait appliqué à travers un tas d'objets. Par exemple, l'âge peut appartenir au père, à la mère, au fils, à la fille, donc au lieu d'avoir 4 classes qui déterminent l'âge à partir de la date de naissance, vous avez une classe Personne qui fait cela et les 4 classes héritent de cela. Mais j'espère que cet exemple aide à expliquer. SRP ne concerne pas les actions atomiques, mais un "travail" qui se fait.