Aqui está como Haskell faz isso: (não exatamente um contador das declarações de Lippert, pois Haskell não é uma linguagem orientada a objetos).
AVISO: longa resposta de um fã sério de Haskell à frente.
TL; DR
Este exemplo ilustra exatamente como o Haskell é diferente do C #. Em vez de delegar a logística da construção da estrutura a um construtor, ele deve ser tratado no código circundante. Não há como um Nothing
valor de valor nulo (Ou em Haskell) surgir onde esperamos um valor não nulo, porque valores nulos só podem ocorrer em tipos de invólucros especiais chamados Maybe
que não são intercambiáveis com / diretamente conversíveis em regulares, não- tipos anuláveis. Para usar um valor tornado anulável envolvendo-o em a Maybe
, devemos primeiro extrair o valor usando a correspondência de padrões, o que nos força a desviar o fluxo de controle para um ramo em que sabemos com certeza que temos um valor não nulo.
Portanto:
podemos sempre saber que uma referência não anulável nunca é, sob nenhuma circunstância, considerada inválida?
Sim. Int
e Maybe Int
são dois tipos completamente separados. Encontrar Nothing
em uma planície Int
seria comparável a encontrar a string "peixe" em um arquivo Int32
.
E o construtor de um objeto com um campo não anulável do tipo de referência?
Não é um problema: os construtores de valor em Haskell não podem fazer nada além de pegar os valores dados e reuni-los. Toda a lógica de inicialização ocorre antes que o construtor seja chamado.
E no finalizador de um objeto desse tipo, onde o objeto é finalizado porque o código que deveria preencher a referência lançou uma exceção?
Não há finalizadores em Haskell, então não posso realmente resolver isso. Minha primeira resposta ainda permanece, no entanto.
Resposta completa :
Haskell não possui nulo e usa o Maybe
tipo de dados para representar nulos. Talvez seja um tipo de dados algabraico definido assim:
data Maybe a = Just a | Nothing
Para aqueles que não estão familiarizados com Haskell, leia isto como "A Maybe
é um Nothing
ou um Just a
". Especificamente:
Maybe
é o construtor de tipos : pode ser pensado (incorretamente) como uma classe genérica (onde a
está a variável de tipo). A analogia em C # é class Maybe<a>{}
.
Just
é um construtor de valor : é uma função que pega um argumento do tipo a
e retorna um valor do tipo Maybe a
que contém o valor. Portanto, o código x = Just 17
é análogo a int? x = 17;
.
Nothing
é outro construtor de valor, mas não aceita argumentos e o Maybe
retorno não tem outro valor além de "Nothing". x = Nothing
é análogo a int? x = null;
(supondo que restringimos o nosso a
em Haskell Int
, o que pode ser feito por escrito x = Nothing :: Maybe Int
).
Agora que os conceitos básicos Maybe
estão fora do caminho, como Haskell evita os problemas discutidos na pergunta do OP?
Bem, Haskell é realmente diferente da maioria das línguas discutidas até agora, então começarei explicando alguns princípios básicos da linguagem.
Primeiro, em Haskell, tudo é imutável . Tudo. Os nomes referem-se a valores, não a locais de memória onde os valores podem ser armazenados (isso por si só é uma enorme fonte de eliminação de erros). Ao contrário em C #, onde declaração variável e atribuição são duas operações separadas, em Haskell valores são criados por definindo o seu valor (por exemplo x = 15
, y = "quux"
, z = Nothing
), que nunca pode mudar. Portanto, código como:
ReferenceType x;
Não é possível em Haskell. Não há problemas em inicializar valores null
porque tudo deve ser explicitamente inicializado em um valor para que ele exista.
Secundariamente, Haskell não é uma linguagem orientada a objetos : é uma linguagem puramente funcional ; portanto, não há objetos no sentido estrito da palavra. Em vez disso, existem simplesmente funções (construtores de valor) que recebem seus argumentos e retornam uma estrutura amalgamada.
Em seguida, não há absolutamente nenhum código de estilo imperativo. Com isso, quero dizer que a maioria dos idiomas segue um padrão mais ou menos assim:
do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
do thing 6
return thing 7
O comportamento do programa é expresso como uma série de instruções. Nas linguagens orientadas a objetos, as declarações de classe e função também desempenham um papel importante no fluxo do programa, mas é essencial que a "carne" da execução de um programa tenha a forma de uma série de instruções a serem executadas.
Em Haskell, isso não é possível. Em vez disso, o fluxo do programa é ditado inteiramente por funções de encadeamento. Até a do
anotação de aparência imperativa é apenas um açúcar sintático para transmitir funções anônimas ao >>=
operador. Todas as funções assumem a forma de:
<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression
Onde body-expression
pode ser qualquer coisa que avalie um valor. Obviamente, existem mais recursos de sintaxe disponíveis, mas o ponto principal é a completa ausência de sequências de instruções.
Por fim, e provavelmente o mais importante, o sistema de tipos de Haskell é incrivelmente rigoroso. Se eu tivesse que resumir a filosofia de design central do sistema de tipos de Haskell, eu diria: "Faça com que o máximo de coisas possível dê errado no tempo de compilação, e o mínimo possível dê errado no tempo de execução". Não há conversões implícitas (quer promover um Int
para um Double
? Use a fromIntegral
função). A única possibilidade de ocorrência de um valor inválido no tempo de execução é o uso Prelude.undefined
(que aparentemente só precisa estar lá e é impossível removê-lo ).
Com tudo isso em mente, vamos examinar o exemplo "quebrado" de amon e tentar reexprimir esse código em Haskell. Primeiro, a declaração de dados (usando a sintaxe do registro para campos nomeados):
data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar }
( foo
e bar
são realmente funções de acessador para campos anônimos aqui em vez de campos reais, mas podemos ignorar esse detalhe).
O NotSoBroken
construtor de valor é incapaz de executar qualquer ação que não seja a Foo
e a Bar
(que não são anuláveis) e NotSoBroken
eliminá-las. Não há lugar para colocar código imperativo ou mesmo atribuir manualmente os campos. Toda a lógica de inicialização deve ocorrer em outro lugar, provavelmente em uma função de fábrica dedicada.
No exemplo, a construção de Broken
sempre falha. Não há como quebrar o NotSoBroken
construtor de valor de maneira semelhante (simplesmente não há onde escrever o código), mas podemos criar uma função de fábrica com defeito semelhante.
makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing
(a primeira linha é uma declaração de assinatura de tipo: makeNotSoBroken
pega a Foo
e a Bar
como argumentos e produz a Maybe NotSoBroken
).
O tipo de retorno deve ser Maybe NotSoBroken
e não simplesmente NotSoBroken
porque pedimos para avaliar Nothing
, que é um construtor de valor para Maybe
. Os tipos simplesmente não se alinham se escrevemos algo diferente.
Além de ser absolutamente inútil, essa função nem sequer cumpre seu objetivo real, como veremos quando tentarmos usá-la. Vamos criar uma função chamada useNotSoBroken
que espera a NotSoBroken
como argumento:
useNotSoBroken :: NotSoBroken -> Whatever
( useNotSoBroken
aceita a NotSoBroken
como argumento e produz a Whatever
).
E use-o assim:
useNotSoBroken (makeNotSoBroken)
Na maioria dos idiomas, esse tipo de comportamento pode causar uma exceção de ponteiro nulo. Em Haskell, os tipos não correspondem: makeNotSoBroken
retorna a Maybe NotSoBroken
, mas useNotSoBroken
espera a NotSoBroken
. Esses tipos não são intercambiáveis e o código falha ao compilar.
Para contornar isso, podemos usar uma case
instrução para ramificar com base na estrutura do Maybe
valor (usando um recurso chamado correspondência de padrão ):
case makeNotSoBroken of
Nothing -> --handle situation here
(Just x) -> useNotSoBroken x
Obviamente, esse trecho precisa ser colocado dentro de algum contexto para ser realmente compilado, mas demonstra o básico de como Haskell lida com valores nulos. Aqui está uma explicação passo a passo do código acima:
- Primeiro,
makeNotSoBroken
é avaliado, que é garantido para produzir um valor do tipo Maybe NotSoBroken
.
- A
case
instrução inspeciona a estrutura desse valor.
- Se o valor for
Nothing
, o código "manipular situação aqui" é avaliado.
- Se o valor corresponder a um
Just
valor, a outra ramificação será executada. Observe como a cláusula correspondente identifica simultaneamente o valor como uma Just
construção e vincula seu NotSoBroken
campo interno a um nome (neste caso, x
). x
pode então ser usado como o NotSoBroken
valor normal que é.
Portanto, a correspondência de padrões fornece um recurso poderoso para reforçar a segurança de tipos, uma vez que a estrutura do objeto está inseparavelmente ligada à ramificação do controle.
Espero que essa seja uma explicação compreensível. Se não faz sentido, vá para Learn You A Haskell For Great Good! , um dos melhores tutoriais de idiomas online que eu já li. Espero que você veja a mesma beleza nessa linguagem que eu.