O contrato semântico de uma interface (OOP) é ​​mais informativo que uma assinatura de função (FP)?

16

Alguns dizem que, se você levar os princípios do SOLID ao extremo, você termina em programação funcional . Eu concordo com este artigo, mas acho que algumas semânticas são perdidas na transição da interface / objeto para a função / fechamento, e quero saber como a Programação Funcional pode mitigar a perda.

Do artigo:

Além disso, se você aplicar rigorosamente o Princípio de Segregação de Interface (ISP), entenderá que deve favorecer as Interfaces de Função em vez das Interfaces de Cabeçalho.

Se você continuar dirigindo seu design para interfaces cada vez menores, chegará à Role Interface: uma interface com um único método. Isso acontece muito comigo. Aqui está um exemplo:

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

Se eu aceitar uma dependência IMessageQuery, parte do contrato implícito é que a chamada Read(id)procurará e retornará uma mensagem com o ID fornecido.

Compare isso com a dependência de sua assinatura funcional equivalente int -> string,. Sem nenhuma dica adicional, essa função pode ser simples ToString(). Se você implementou IMessageQuery.Read(int id)com um, ToString()posso acusá-lo de ser deliberadamente subversivo!

Então, o que os programadores funcionais podem fazer para preservar a semântica de uma interface bem nomeada? É convencional, por exemplo, criar um tipo de registro com um único membro?

type MessageQuery = {
    Read: int -> string
}
AlexFoxGill
fonte
5
Uma interface OOP é mais como a classe de tipo FP , não uma única função.
9000
2
Você pode ter muita coisa boa, aplicar o ISP 'rigorosamente' para terminar com 1 método por interface está indo longe demais.
Gbjbaanb
3
@gbjbaanb na verdade, a maioria das minhas interfaces tem apenas um método com muitas implementações. Quanto mais você aplica os princípios do SOLID, mais consegue ver os benefícios. Mas isso é fora de tópico para esta questão #
AlexFoxGill
1
@jk .: Bem, em Haskell, são classes de tipo, no OCaml você usa um módulo ou um functor, no Clojure você usa um protocolo. De qualquer forma, você normalmente não limita a analogia da interface a uma única função.
9000
2
Without any additional clues... talvez seja por isso que a documentação faça parte do contrato ?
SJuan76

Respostas:

8

Como Telastyn diz, comparando as definições estáticas de funções:

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

para

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

Você realmente não perdeu nada de OOP para FP.

No entanto, isso é apenas parte da história, porque funções e interfaces não são apenas referidas em suas definições estáticas. Eles também são passados . Então, digamos que nosso MessageQueryfoi lido por outro pedaço de código, a MessageProcessor. Então nós temos:

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

Agora não podemos ver diretamente o nome do método IMessageQuery.Readou seu parâmetro int id, mas podemos chegar lá com muita facilidade por meio do nosso IDE. De maneira mais geral, o fato de estarmos passando uma IMessageQueryinterface em vez de qualquer interface com um método de int para string significa que mantemos os idmetadados do nome do parâmetro associados a essa função.

Por outro lado, para a nossa versão funcional, temos:

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

Então, o que mantivemos e perdemos? Bem, ainda temos o nome do parâmetro messageReader, o que provavelmente torna IMessageQuerydesnecessário o nome do tipo (o equivalente a ). Mas agora perdemos o nome do parâmetro idem nossa função.


Há duas maneiras principais de contornar isso:

  • Primeiro, ao ler essa assinatura, você já pode adivinhar o que está acontecendo. Ao manter as funções curtas, simples e coesas e usar uma boa nomeação, você facilita muito a intuição ou a localização dessas informações. Uma vez que começamos a ler a própria função real, seria ainda mais simples.

  • Em segundo lugar, é considerado design idiomático em muitas linguagens funcionais a criação de tipos pequenos para agrupar primitivas. Nesse caso, está acontecendo o contrário - em vez de substituir um nome de tipo por um nome de parâmetro ( IMessageQuerypara messageReader), podemos substituir um nome de parâmetro por um nome de tipo. Por exemplo, intpoderia ser agrupado em um tipo chamado Id:

    type Id = Id of int
    

    Agora nossa readassinatura se torna:

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

    O que é tão informativo quanto o que tínhamos antes.

    Como uma observação lateral, isso também fornece uma parte da proteção do compilador que tínhamos no OOP. Enquanto a versão OOP garantiu que adotássemos especificamente uma função, IMessageQuerye não apenas uma int -> stringfunção antiga , aqui temos uma proteção semelhante (mas diferente) que estamos usando, em Id -> stringvez de uma função antiga int -> string.


Eu ficaria relutante em dizer com 100% de confiança que essas técnicas sempre serão tão boas e informativas quanto ter todas as informações disponíveis em uma interface, mas acho que pelos exemplos acima, você pode dizer que na maioria das vezes, nós provavelmente pode fazer um trabalho tão bom.

Ben Aaronson
fonte
1
Esta é a minha resposta favorita - que FP incentiva o uso de tipos semânticos
AlexFoxGill
10

Ao fazer FP, costumo usar tipos semânticos mais específicos.

Por exemplo, seu método para mim se tornaria algo como:

read: MessageId -> Message

Este comunica bastante mais do que o OO (/ java) de estilo ThingDoer.doThing()estilo

Daenyth
fonte
2
+1. Além disso, você pode codificar muito mais propriedades no sistema de tipos em linguagens FP digitadas como Haskell. Se esse fosse um tipo de haskell, eu saberia que ele não está executando nenhuma IO (e, portanto, provavelmente referenciando algum mapeamento de IDs na memória a mensagens em algum lugar). Nas linguagens OOP, não tenho essas informações - essa função pode ligar para um banco de dados como parte de chamá-lo.
Jack
1
Não sei exatamente por que isso se comunicaria mais do que o estilo Java. O que read: MessageId -> Messagediz a você que string MessageReader.GetMessage(int messageId)não diz?
Ben Aaronson
@BenAaronson a relação sinal / ruído é melhor. Se for um método de instância OO, não tenho idéia do que está fazendo sob o capô, ele pode ter qualquer tipo de dependência que não seja expressa. Como Jack menciona, quando você tem tipos mais fortes, você tem mais garantias.
Daenyth
9

Então, o que os programadores funcionais podem fazer para preservar a semântica de uma interface bem nomeada?

Use funções bem nomeadas.

IMessageQuery::Read: int -> stringsimplesmente se torna ReadMessageQuery: int -> stringou algo semelhante.

O ponto principal a ser observado é que os nomes são apenas contratos no sentido mais amplo da palavra. Eles só funcionam se você e outro programador inferirem as mesmas implicações do nome e as obedecerem. Por esse motivo, você pode realmente usar qualquer nome que comunique esse comportamento implícito. OO e programação funcional têm seus nomes em lugares ligeiramente diferentes e em formas ligeiramente diferentes, mas sua função é a mesma.

O contrato semântico de uma interface (OOP) é ​​mais informativo que uma assinatura de função (FP)?

Não neste exemplo. Como expliquei acima, uma única classe / interface com uma única função não é significativamente mais informativa do que uma função autônoma com o mesmo nome.

Depois de obter mais de uma função / campo / propriedade em uma classe, você pode inferir mais informações sobre elas, porque pode ver a relação delas. É discutível se isso é mais informativo do que funções independentes que usam os mesmos parâmetros / funções similares ou funções independentes organizadas por namespace ou módulo.

Pessoalmente, não acho que o OO seja significativamente mais informativo, mesmo em exemplos mais complexos.

Telastyn
fonte
2
E os nomes dos parâmetros?
precisa
@BenAaronson - e eles? OOO e os idiomas funcionais permitem especificar nomes de parâmetros, o que é um empurrão. Estou apenas usando o atalho de assinatura de tipo aqui para ser consistente com a pergunta. Em um idioma real, seria algo parecido comReadMessageQuery id = <code to go fetch based on id>
Telastyn 16/06/2015
1
Bem, se uma função usa uma interface como parâmetro, e então chama um método nessa interface, os nomes dos parâmetros para o método da interface estão prontamente disponíveis. Se uma função assume outra função como parâmetro, não é fácil atribuir nomes de parâmetros para a função passada na função #
9132
1
Sim, @BenAaronson, essa é uma das informações perdidas na assinatura da função. Por exemplo let consumer (messageQuery : int -> string) = messageQuery 5, em , o idparâmetro é apenas um int. Eu acho que um argumento é que você deveria passar um Id, não um int. De fato, isso daria uma resposta decente por si só.
AlexFoxGill
@AlexFoxGill Na verdade, eu estava apenas escrevendo algo nesse sentido!
precisa
0

Discordo que uma única função não pode ter um 'contrato semântico'. Considere estas leis para 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

Em que sentido isso não é semântico ou não é um contrato? Você não precisa definir um tipo para um 'dobrador', principalmente porque foldré determinado exclusivamente por essas leis. Você sabe exatamente o que isso vai fazer.

Se você deseja assumir uma função de algum tipo, pode fazer o mesmo:

-- 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;

Você só precisa nomear e capturar esse tipo se precisar do mesmo contrato várias vezes:

-- 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;

O verificador de tipos não aplicará qualquer semântica que você atribuir a um tipo, portanto, criar um novo tipo para cada contrato é apenas um clichê.

Jonathan Cast
fonte
1
Eu não acho que seu primeiro ponto aborda a questão - é uma definição de função, não uma assinatura. Claro que, olhando para a implementação de uma classe ou uma função você pode dizer o que ele faz - a pergunta pede que você ainda pode preservar a semântica em um nível de abstração (uma assinatura de interface ou função)
AlexFoxGill
@AlexFoxGill - é não uma definição de função, embora não determinam unicamente foldr. Dica: a definição de foldrtem duas equações (por quê?), Enquanto a especificação fornecida acima tem três.
Jonathan fundido
O que quero dizer é que foldr é uma função, não uma assinatura de função. As leis ou a definição não fazem parte da assinatura #
AlexFoxGill
-1

Praticamente todas as linguagens funcionais de tipo estatístico têm como alias os tipos básicos de uma maneira que requer que você declare explicitamente sua intenção semântica. Algumas das outras respostas deram exemplos. Na prática, programadores funcionais experientes precisam muito boas razões para usar esses tipos de mensagens publicitárias, porque eles machucar modularidade e reutilização.

Por exemplo, digamos que um cliente desejasse uma implementação de uma consulta de mensagem apoiada por uma lista. Em Haskell, a implementação poderia ser tão simples como esta:

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

Usar newtype Message = Message Stringisso seria muito menos direto, deixando essa implementação parecida com:

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

Isso pode não parecer grande coisa, mas você precisa fazer essa conversão de um lado para outro em todos os lugares ou configurar uma camada limite no seu código onde tudo está acima, Int -> Stringentão você a converte Id -> Messagepara passar para a camada abaixo. Digamos que eu queira adicionar internacionalização ou formatá-la em maiúsculas ou adicionar contexto de log ou qualquer outra coisa. Todas essas operações são simples de compor com um Int -> String, e irritantes com um Id -> Message. Não é que nunca haja casos em que as restrições de tipo aumentadas sejam desejáveis, mas é melhor que o aborrecimento valha a pena.

Você pode usar um sinônimo de tipo em vez de um invólucro (em Haskell em typevez de newtype), o que é muito mais comum e não requer conversões em todo o lugar, mas não fornece garantias de tipo estático, como na sua versão OOP, apenas um pouco de encapsulamento. Os wrappers de tipo são usados ​​principalmente onde não se espera que o cliente manipule o valor, basta armazená-lo e devolvê-lo. Por exemplo, um identificador de arquivo.

Nada pode impedir que um cliente seja "subversivo". Você está apenas criando aros para atravessar, para cada cliente. Um simples mock para um teste de unidade comum geralmente requer um comportamento estranho que não faz sentido na produção. Suas interfaces devem ser escritas para que não se importem, se possível.

Karl Bielefeldt
fonte
1
Parece mais um comentário sobre as respostas de Ben / Daenyth - que você pode usar tipos semânticos, mas não usa por causa da inconveniência? Não diminuí o voto, mas não é uma resposta para esta pergunta.
AlexFoxGill
-1 Não prejudicaria a composabilidade ou a reutilização. Se Ide Messagesão invólucros simples para Inte String, é trivial converter entre eles.
Michael Shaw
Sim, é trivial, mas ainda é irritante para todo o lado. Adicionei um exemplo e um pouco descrevendo a diferença entre o uso de sinônimos de tipo e wrappers.
Karl Bielefeldt
Parece uma coisa de tamanho. Em pequenos projetos, esses tipos de tipos de wrapper provavelmente não são tão úteis assim. Em grandes projetos, é natural que os limites representem uma pequena proporção do código geral, portanto, fazer esses tipos de conversões no limite e depois passar tipos agrupados em qualquer outro lugar não é muito árduo. Além disso, como Alex disse, não vejo como isso é uma resposta para a pergunta.
Ben Aaronson
Expliquei como fazê-lo, então por que os programadores funcionais não o faziam. Essa é uma resposta, mesmo que não seja a pessoa que as pessoas desejam. Não tem nada a ver com tamanho. Essa ideia de que, de alguma forma, é bom intencionalmente dificultar a reutilização do seu código é decididamente um conceito de POO. Criando um tipo de produto que agrega algum valor? O tempo todo. Criando um tipo exatamente como um tipo amplamente usado, exceto que você só pode usá-lo em uma biblioteca? Praticamente inédito, exceto talvez no código FP que interage fortemente com o código OOP ou escrito por alguém mais familiarizado com os idiomas OOP.
Karl Bielefeldt