Le contrat sémantique d'une interface (POO) est-il plus informatif qu'une signature de fonction (FP)?

16

Certains disent que si vous prenez les principes SOLIDES à l'extrême, vous vous retrouvez à la programmation fonctionnelle . Je suis d'accord avec cet article mais je pense que certaines sémantiques sont perdues dans la transition de l'interface / objet à la fonction / fermeture, et je veux savoir comment la programmation fonctionnelle peut atténuer la perte.

De l'article:

De plus, si vous appliquez rigoureusement le principe de séparation des interfaces (ISP), vous comprendrez que vous devriez privilégier les interfaces de rôle par rapport aux interfaces d'en-tête.

Si vous continuez à orienter votre conception vers des interfaces de plus en plus petites, vous arriverez finalement à l'interface de rôle ultime: une interface avec une seule méthode. Cela m'arrive beaucoup. Voici un exemple:

public interface IMessageQuery
{
    string Read(int id);
}

Si je prends une dépendance sur un IMessageQuery, une partie du contrat implicite est que l'appel Read(id)recherche et renvoie un message avec l'ID donné.

Comparez cela à prendre une dépendance à l' égard de sa signature fonctionnelle équivalente, int -> string. Sans indices supplémentaires, cette fonction pourrait être simple ToString(). Si vous avez implémenté IMessageQuery.Read(int id)avec un ToString()je peux vous accuser d'être délibérément subversif!

Alors, que peuvent faire les programmeurs fonctionnels pour préserver la sémantique d'une interface bien nommée? Est-il conventionnel, par exemple, de créer un type d'enregistrement avec un seul membre?

type MessageQuery = {
    Read: int -> string
}
AlexFoxGill
la source
5
Une interface OOP ressemble plus à une classe de types FP , pas à une seule fonction.
9000
2
Vous pouvez avoir trop d'une bonne chose, appliquer ISP «rigoureusement» pour se retrouver avec 1 méthode par interface va tout simplement trop loin.
gbjbaanb
3
@gbjbaanb en fait la majorité de mes interfaces n'ont qu'une seule méthode avec de nombreuses implémentations. Plus vous appliquez les principes SOLID, plus vous en voyez les avantages. Mais c'est hors sujet pour cette question
AlexFoxGill
1
@jk .: Eh bien, dans Haskell, ce sont des classes de type, dans OCaml vous utilisez soit un module ou un foncteur, dans Clojure vous utilisez un protocole. Dans tous les cas, vous ne limitez généralement pas votre analogie d'interface à une seule fonction.
9000
2
Without any additional clues... c'est peut-être pourquoi la documentation fait partie du contrat ?
SJuan76

Réponses:

8

Comme le dit Telastyn, en comparant les définitions statiques des fonctions:

public string Read(int id) { /*...*/ }

à

let read (id:int) = //...

Vous n'avez vraiment rien perdu de la POO à la PF.

Cependant, ce n'est qu'une partie de l'histoire, car les fonctions et les interfaces ne sont pas uniquement mentionnées dans leurs définitions statiques. Ils sont également diffusés . Disons que notre a MessageQueryété lu par un autre morceau de code, a MessageProcessor. Ensuite nous avons:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

Maintenant, nous ne pouvons pas voir directement le nom de la méthode IMessageQuery.Readou son paramètre int id, mais nous pouvons y arriver très facilement via notre IDE. Plus généralement, le fait que nous passons une IMessageQueryinterface plutôt qu'une simple interface avec une méthode une fonction de int à chaîne signifie que nous conservons les idmétadonnées de nom de paramètre associées à cette fonction.

En revanche, pour notre version fonctionnelle nous avons:

let read (id:int) (messageReader : int -> string) = // ...

Alors qu'avons-nous gardé et perdu? Eh bien, nous avons toujours le nom du paramètre messageReader, ce qui rend probablement le nom du type (l'équivalent de IMessageQuery) inutile. Mais maintenant, nous avons perdu le nom du paramètre iddans notre fonction.


Il y a deux façons principales de contourner cela:

  • Tout d'abord, en lisant cette signature, vous pouvez déjà deviner assez bien ce qui va se passer. En gardant les fonctions courtes, simples et cohérentes et en utilisant une bonne dénomination, vous faciliterez beaucoup l'intuition ou la recherche de ces informations. Une fois que nous aurions commencé à lire la fonction elle-même, ce serait encore plus simple.

  • Deuxièmement, il est considéré comme une conception idiomatique dans de nombreux langages fonctionnels pour créer de petits types pour envelopper les primitives. Dans ce cas, l'inverse se produit - au lieu de remplacer un nom de type par un nom de paramètre ( IMessageQueryto messageReader), nous pouvons remplacer un nom de paramètre par un nom de type. Par exemple, intpourrait être enveloppé dans un type appelé Id:

    type Id = Id of int
    

    Maintenant, notre readsignature devient:

    let read (id:int) (messageReader : Id -> string) = // ...
    

    Ce qui est tout aussi instructif que ce que nous avions auparavant.

    En remarque, cela nous offre également une partie de la protection du compilateur que nous avions dans la POO. Alors que la version OOP garantissait que nous prenions spécifiquement IMessageQueryplutôt qu'une ancienne int -> stringfonction, nous avons ici une protection similaire (mais différente) que nous prenons Id -> stringplutôt qu'une ancienne int -> string.


Je serais réticent à dire avec 100% de confiance que ces techniques seront toujours aussi bonnes et informatives que d'avoir toutes les informations disponibles sur une interface, mais je pense que d'après les exemples ci-dessus, vous pouvez dire que la plupart du temps, nous peut probablement faire un aussi bon travail.

Ben Aaronson
la source
1
Ceci est ma réponse préférée - que FP encourage l'utilisation de types sémantiques
AlexFoxGill
10

Lorsque je fais de la FP, j'ai tendance à utiliser des types sémantiques plus spécifiques.

Par exemple, votre méthode pour moi deviendrait quelque chose comme:

read: MessageId -> Message

Cela communique beaucoup plus que le ThingDoer.doThing()style OO (/ java)

Daenyth
la source
2
+1. De plus, vous pouvez encoder beaucoup plus de propriétés dans le système de type dans des langages FP typés comme Haskell. Si c'était un type haskell, je saurais qu'il ne fait aucun IO du tout (et référençant ainsi probablement un mappage en mémoire des identifiants aux messages quelque part). Dans les langues POO, je n'ai pas cette information - cette fonction pourrait appeler une base de données dans le cadre de son appel.
Jack
1
Je ne sais pas exactement pourquoi cela communiquerait plus que le style Java. Qu'est read: MessageId -> Message-ce qui vous dit que string MessageReader.GetMessage(int messageId)non?
Ben Aaronson
@BenAaronson le rapport signal / bruit est meilleur. Si c'est une méthode d'instance OO, je n'ai aucune idée de ce qu'elle fait sous le capot, cela pourrait avoir n'importe quel type de dépendance qui n'est pas exprimée. Comme Jack le mentionne, lorsque vous avez des types plus forts, vous avez plus d'assurance.
Daenyth
9

Alors, que peuvent faire les programmeurs fonctionnels pour préserver la sémantique d'une interface bien nommée?

Utilisez des fonctions bien nommées.

IMessageQuery::Read: int -> stringdevient simplement ReadMessageQuery: int -> stringou quelque chose de similaire.

L'essentiel à noter est que les noms ne sont que des contrats dans le sens le plus vague du mot. Ils ne fonctionnent que si vous et un autre programmeur déduisez les mêmes implications du nom et leur obéissez. Pour cette raison, vous pouvez vraiment utiliser n'importe quel nom qui communique ce comportement implicite. OO et la programmation fonctionnelle ont leurs noms à des endroits légèrement différents et sous des formes légèrement différentes, mais leur fonction est la même.

Le contrat sémantique d'une interface (POO) est-il plus informatif qu'une signature de fonction (FP)?

Pas dans cet exemple. Comme je l'ai expliqué ci-dessus, une seule classe / interface avec une seule fonction n'est pas significativement plus informative qu'une fonction autonome de même nom.

Une fois que vous obtenez plus d'une fonction / champ / propriété dans une classe, vous pouvez déduire plus d'informations à leur sujet car vous pouvez voir leur relation. On peut soutenir que c'est plus informatif que les fonctions autonomes qui prennent les mêmes paramètres / similaires ou les fonctions autonomes organisées par espace de noms ou module.

Personnellement, je ne pense pas que OO soit beaucoup plus informatif, même dans des exemples plus complexes.

Telastyn
la source
2
Qu'en est-il des noms de paramètres?
Ben Aaronson
@BenAaronson - qu'en est-il d'eux? Les langages OO et fonctionnels vous permettent de spécifier des noms de paramètres, c'est donc une poussée. J'utilise simplement le raccourci de signature de type ici pour être cohérent avec la question. Dans une vraie langue, cela ressemblerait à quelque choseReadMessageQuery id = <code to go fetch based on id>
Telastyn
1
Eh bien, si une fonction prend une interface en paramètre, puis appelle une méthode sur cette interface, les noms des paramètres de la méthode d'interface sont facilement disponibles. Si une fonction prend une autre fonction comme paramètre, il n'est pas facile d'attribuer des noms de paramètres pour celle passée dans la fonction
Ben Aaronson
1
Oui, @BenAaronson, c'est l'une des informations perdues dans la signature de la fonction. Par exemple dans let consumer (messageQuery : int -> string) = messageQuery 5, le idparamètre est juste un int. Je suppose qu'un argument est que vous devriez passer un Id, pas un int. En fait, cela ferait une réponse décente en soi.
AlexFoxGill
@AlexFoxGill En fait, j'étais en train d'écrire quelque chose dans ce sens!
Ben Aaronson
0

Je ne suis pas d'accord sur le fait qu'une seule fonction ne peut pas avoir de «contrat sémantique». Considérez ces lois pour foldr:

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

En quel sens n'est-ce pas sémantique ou pas un contrat? Vous n'avez pas besoin de définir un type pour un «foldrer», en particulier parce qu'il foldrest uniquement déterminé par ces lois. Vous savez exactement ce que ça va faire.

Si vous voulez prendre une fonction d'un certain type, vous pouvez faire la même chose:

-- The first argument `f` must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

Vous devez uniquement nommer et capturer ce type si vous avez besoin du même contrat plusieurs fois:

-- Type of functions `f` satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

Le vérificateur de type ne va pas appliquer la sémantique que vous attribuez à un type, donc créer un nouveau type pour chaque contrat est juste passe-partout.

Jonathan Cast
la source
1
Je ne pense pas que votre premier point aborde la question - c'est une définition de fonction, pas une signature. Bien sûr, en regardant l'implémentation d'une classe ou d'une fonction, vous pouvez dire ce qu'elle fait - la question posée vous permet de conserver la sémantique à un niveau d'abstraction (une interface ou une signature de fonction)
AlexFoxGill
@AlexFoxGill - ce n'est pas une définition de fonction, bien qu'elle le détermine de manière unique foldr. Astuce: la définition de foldra deux équations (pourquoi?) Tandis que la spécification donnée ci-dessus en a trois.
Jonathan Cast
Ce que je veux dire, c'est que foldr est une fonction et non une signature de fonction. Les lois ou la définition ne font pas partie de la signature
AlexFoxGill
-1

Presque tous les langages fonctionnels de type statique ont un moyen d'aliaser les types de base d'une manière qui vous oblige à déclarer explicitement votre intention sémantique. Certaines des autres réponses ont donné des exemples. En pratique, les programmeurs fonctionnels expérimentés ont besoin de très bonnes raisons pour utiliser ces types d'encapsuleurs, car ils nuisent à la composabilité et à la réutilisabilité.

Par exemple, supposons qu'un client souhaite une implémentation d'une requête de message qui soit appuyée par une liste. Dans Haskell, l'implémentation pourrait être aussi simple que cela:

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

L'utiliser newtype Message = Message Stringserait beaucoup moins simple, laissant cette implémentation ressembler à quelque chose comme:

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

Cela peut ne pas sembler être un gros problème, mais vous devez soit effectuer cette conversion de type dans les deux sens partout , soit configurer une couche limite dans votre code où tout ce qui se trouve ci-dessus est Int -> Stringensuite converti en Id -> Messagepour passer à la couche ci-dessous. Supposons que je veuille ajouter l'internationalisation, la formater dans toutes les majuscules ou ajouter un contexte de journalisation, ou autre. Ces opérations sont toutes simples à composer avec un Int -> String, et ennuyeuses avec un Id -> Message. Ce n'est pas qu'il n'y a jamais de cas où les restrictions de type accrues sont souhaitables, mais la gêne vaut mieux le compromis.

Vous pouvez utiliser un synonyme de type au lieu d'un wrapper (dans Haskell typeau lieu de newtype), ce qui est beaucoup plus courant et ne nécessite pas les conversions partout, mais il ne fournit pas non plus les garanties de type statique comme votre version OOP, juste un peu d'encapsulation. Les wrappers de type sont principalement utilisés lorsque le client n'est pas censé manipuler la valeur, il suffit de la stocker et de la renvoyer. Par exemple, un descripteur de fichier.

Rien ne peut empêcher un client d'être "subversif". Vous créez simplement des cerceaux pour sauter à travers, pour chaque client. Une simple maquette pour un test unitaire commun nécessite souvent un comportement étrange qui n'a pas de sens en production. Vos interfaces doivent être écrites pour ne pas s'en soucier, si possible.

Karl Bielefeldt
la source
1
Cela ressemble plus à un commentaire sur les réponses de Ben / Daenyth - que vous pouvez utiliser des types sémantiques mais pas à cause des inconvénients? Je ne vous ai pas déçu, mais ce n'est pas une réponse à cette question.
AlexFoxGill
-1 Cela ne nuirait pas du tout à la composabilité ou à la réutilisabilité. Si Idet Messagesont de simples wrappers pour Intet String, c'est trivial de les convertir entre eux.
Michael Shaw
Oui, c'est trivial, mais toujours ennuyeux à faire partout. J'ai ajouté un exemple et un peu décrivant la différence entre l'utilisation de synonymes de type et de wrappers.
Karl Bielefeldt
Cela semble être une question de taille. Dans les petits projets, ces types de types de wrapper ne sont probablement pas très utiles de toute façon. Dans les grands projets, il est naturel que les limites soient une petite proportion du code global, donc faire ces types de conversions à la frontière et ensuite contourner les types enveloppés partout ailleurs n'est pas trop difficile. De plus, comme Alex l'a dit, je ne vois pas en quoi cela répond à la question.
Ben Aaronson
J'ai expliqué comment le faire, puis pourquoi les programmeurs fonctionnels ne le feraient pas. C'est une réponse, même si ce n'est pas celle que les gens voulaient. Cela n'a rien à voir avec la taille. Cette idée qu'il est en quelque sorte une bonne chose de rendre intentionnellement plus difficile la réutilisation de votre code est décidément un concept de POO. Créer un type de produit qui ajoute de la valeur? Tout le temps. Créer un type exactement comme un type largement utilisé, sauf que vous ne pouvez l'utiliser que dans une seule bibliothèque? Pratiquement inconnu, sauf peut-être dans le code FP qui interagit fortement avec le code OOP, ou écrit par quelqu'un qui connaît surtout les idiomes OOP.
Karl Bielefeldt