A correspondência de padrões com os tipos é idiomática ou com design ruim?

18

Parece que o código F # geralmente corresponde a padrões em relação aos tipos. Certamente

match opt with 
| Some val -> Something(val) 
| None -> Different()

parece comum.

Mas, do ponto de vista da OOP, isso se parece muito com o fluxo de controle com base em uma verificação do tipo de tempo de execução, que normalmente seria desaprovada. Para explicar, no POO você provavelmente preferiria usar sobrecarga:

type T = 
    abstract member Route : unit -> unit

type Foo() = 
    interface T with
        member this.Route() = printfn "Go left"

type Bar() = 
    interface T with
        member this.Route() = printfn "Go right"

Este é certamente mais código. OTOH, parece-me ter vantagens estruturais:

  • a extensão para uma nova forma de Té fácil;
  • Não preciso me preocupar em encontrar duplicação do fluxo de controle de escolha de rota; e
  • A escolha da rota é imutável, no sentido em que, uma vez que tenho uma Foomão na mão, nunca mais preciso me preocupar com Bar.Route()a implementação da

Existem vantagens na correspondência de padrões com tipos que não estou vendo? É considerado idiomático ou é um recurso que não é comumente usado?

Larry OBrien
fonte
3
Qual o sentido de ver uma linguagem funcional a partir de uma perspectiva OOP? De qualquer forma, o verdadeiro poder da correspondência de padrões vem com padrões aninhados. Só é possível verificar o construtor externo, mas de maneira alguma a história toda.
Ingo
Isso But from an OOP perspective, that looks an awful lot like control-flow based on a runtime type check, which would typically be frowned on.parece muito dogmático. Às vezes, você deseja separar seus ops da hierarquia: talvez 1) você não possa adicionar um op a uma hierarquia porque não é o proprietário da hierarquia; 2) as classes que você deseja que o op não correspondam à sua hierarquia; 3) você pode adicionar o op à sua hierarquia, mas não quer b / c, não quer desorganizar a API da sua hierarquia com um monte de porcaria que a maioria dos clientes não usa.
4
Apenas para esclarecer, Somee Nonenão são tipos. Ambos são construtores cujos tipos são forall a. a -> option ae forall a. option a(desculpe, não sei qual é a sintaxe das anotações de tipo em F #).

Respostas:

20

Você está certo de que as hierarquias de classe OOP estão muito relacionadas a uniões discriminadas em F # e que a correspondência de padrões está intimamente relacionada a testes de tipo dinâmico. De fato, é assim que o F # compila uniões discriminadas no .NET!

Em relação à extensibilidade, existem dois lados do problema:

  • OO permite adicionar novas subclasses, mas dificulta a adição de novas funções (virtuais)
  • O FP permite adicionar novas funções, mas dificulta a adição de novos casos de união

Dito isto, o F # emitirá um aviso quando você perder um caso na correspondência de padrões, portanto, adicionar novos casos de união não é tão ruim assim.

Em relação à localização de duplicações na escolha da raiz - o F # emitirá um aviso quando houver uma correspondência duplicada, por exemplo:

match x with
| Some foo -> printfn "first"
| Some foo -> printfn "second" // Warning on this line as it cannot be matched
| None -> printfn "third"

O fato de "a escolha da rota ser imutável" também pode ser problemático. Por exemplo, se você deseja compartilhar a implementação de uma função entre Fooe Barcasos, mas faz outra coisa para o Zoocaso, é possível codificar isso facilmente usando a correspondência de padrões:

match x with
| Foo y | Bar y -> y * 20
| Zoo y -> y * 30

Em geral, o FP está mais focado no primeiro design dos tipos e depois na adição de funções. Portanto, ele realmente se beneficia do fato de poder ajustar seus tipos (modelo de domínio) em algumas linhas em um único arquivo e adicionar facilmente as funções que operam no modelo de domínio.

As duas abordagens - OO e FP são bastante complementares e têm vantagens e desvantagens. O difícil (vindo da perspectiva do OO) é que o F # geralmente usa o estilo FP como padrão. Mas se realmente houver mais necessidade de adicionar novas subclasses, você sempre poderá usar interfaces. Mas na maioria dos sistemas, você também precisa adicionar tipos e funções, para que a escolha realmente não importe tanto - e usar uniões discriminadas no F # é melhor.

Eu recomendaria esta excelente série de blogs para obter mais informações.

Tomas Petricek
fonte
3
Embora você esteja correto, gostaria de acrescentar que isso não é tanto um problema de OO x FP, mas de objetos versus tipos de soma. Obsessão do OOP com eles de lado, não há nada sobre objetos que os tornam não funcionais. E se você passar por bastantes obstáculos, também poderá implementar tipos de soma nos principais idiomas OOP (embora não seja bonito).
Doval
1
"E se você passar por bastantes obstáculos, também poderá implementar tipos de soma nas principais linguagens OOP (embora não seja bonito)." -> Eu acho que você vai acabar com algo semelhante a como F # tipos de soma são codificados em sistema do tipo do .NET :)
Tarmil
7

Você observou corretamente que a correspondência de padrões (essencialmente uma instrução de comutação sobrecarregada) e o envio dinâmico têm semelhanças. Eles também coexistem em alguns idiomas, com um resultado muito agradável. No entanto, existem pequenas diferenças.

Eu poderia usar o sistema de tipos para definir um tipo que só pode ter um número fixo de subtipos:

// pseudocode
data Bool = False | True
data Option a = None | Some item:a
data Tree a = Leaf item:a | Node (left:Tree a) (right:Tree a)

Haverá nunca mais ser outro subtipo de Boolou Option, então subclasses não parece ser útil (algumas línguas como o Scala tem uma noção de subclassificação que pode lidar com isso - uma classe pode ser marcado como fora “final” da unidade de compilação atual, mas subtipos pode ser definido dentro desta unidade de compilação).

Como os subtipos de um tipo como Optionagora são estaticamente conhecidos , o compilador pode avisar se esquecermos de lidar com um caso em nossa correspondência de padrões. Isso significa que uma correspondência de padrão é mais como um downcast especial que nos força a lidar com todas as opções.

Além disso, o envio de método dinâmico (necessário para OOP) também implica uma verificação do tipo de tempo de execução, mas de um tipo diferente. Portanto, é irrelevante se fizermos esse tipo de verificação explicitamente através de uma correspondência de padrões ou implicitamente através de uma chamada de método.

amon
fonte
"Isso significa que uma correspondência de padrões é mais como um downcast especial que nos obriga a lidar com todas as opções" - na verdade, acredito que (desde que você corresponda apenas a construtores e não a valores ou estruturas aninhadas), é isomórfico para colocando um método virtual abstrato na superclasse.
Jules
2

A correspondência de padrões F # geralmente é feita com uma união discriminada, e não com classes (e, portanto, não é tecnicamente uma verificação de tipo). Isso permite que o compilador avise quando você não contabilizar casos em uma correspondência de padrão.

Outro aspecto a ser observado é que, em um estilo funcional, você organiza as coisas por funcionalidade, e não por dados; portanto, as correspondências de padrões permitem reunir as diferentes funcionalidades em um único local, em vez de espalhadas pelas classes. Isso também tem a vantagem de você ver como outros casos são tratados exatamente ao lado de onde você precisa fazer suas alterações.

A adição de uma nova opção se parece com:

  1. Adicione uma nova opção à sua união discriminada
  2. Corrija todos os avisos em correspondências incompletas de padrões
N / D
fonte
2

Parcialmente, você o vê com mais frequência na programação funcional porque usa tipos para tomar decisões com mais frequência. Sei que você provavelmente acabou de escolher exemplos mais ou menos aleatoriamente, mas o OOP equivalente ao seu exemplo de correspondência de padrões se pareceria com mais frequência:

if (opt != null)
    opt.Something()
else
    Different()

Em outras palavras, é relativamente raro usar o polimorfismo para evitar coisas rotineiras, como verificações nulas no OOP. Assim como um programador OO não cria um objeto nulo em todas as situações, um programador funcional nem sempre sobrecarrega uma função, especialmente quando você sabe que sua lista de padrões é exaustiva. Se você usar o sistema de tipos em mais situações, verá que ele é usado de maneiras às quais não está acostumado.

Por outro lado, a programação funcional idiomática equivalente ao seu exemplo de POO provavelmente não usaria correspondência de padrões, mas teria fooRoutee barRoutefunções que seriam passadas como argumentos para o código de chamada. Se alguém usasse a correspondência de padrões nessa situação, geralmente seria considerado errado, assim como alguém que ativasse tipos seria considerado errado no OOP.

Então, quando a correspondência de padrões é considerada um bom código de programação funcional? Quando você está fazendo mais do que apenas olhar para os tipos e ao estender os requisitos, não é necessário adicionar mais casos. Por exemplo, Some valnão apenas verifica se opthá tipo Some, mas também se vincula valao tipo subjacente para uso com segurança de tipo no outro lado do ->. Você sabe que provavelmente nunca precisará de um terceiro caso, por isso é um bom uso.

A correspondência de padrões pode se assemelhar superficialmente a uma instrução de chave orientada a objetos, mas há muito mais acontecendo, especialmente com padrões mais longos ou aninhados. Certifique-se de levar tudo em consideração antes de declarar o equivalente a algum código OOP mal projetado. Muitas vezes, está lidando sucintamente com uma situação que não pode ser representada de maneira limpa em uma hierarquia de herança.

Karl Bielefeldt
fonte
Eu sei que você sabe disso, e provavelmente escorregou sua mente enquanto escrevia a sua resposta, mas nota que Somee Nonenão são tipos, para que não são padrão de correspondência em tipos. Você faz a correspondência de padrões em construtores do mesmo tipo . Não é como perguntar "instanceof".
Andres F.