Eric Lippert fez um ponto muito interessante em sua discussão sobre por que o C # usa um null
e não um Maybe<T>
tipo :
A consistência do sistema de tipos é importante; podemos sempre saber que uma referência não anulável nunca é, sob nenhuma circunstância, considerada inválida? E o construtor de um objeto com um campo não anulável do tipo de referência? 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? Um sistema de tipos que mente para você sobre suas garantias é perigoso.
Isso foi um pouco de abrir os olhos. Os conceitos envolvidos me interessam, e eu já brinquei com compiladores e sistemas de tipos, mas nunca pensei sobre esse cenário. Como os idiomas que têm um tipo Maybe em vez de um identificador nulo de casos extremos, como inicialização e recuperação de erros, nos quais uma referência não nula supostamente garantida não está, de fato, em um estado válido?
fonte
Type?
(talvez) eType
(não null)Maybe T
, não deve serNone
e, portanto, você não pode inicializar seu armazenamento no ponteiro nulo.Respostas:
Essa citação aponta para um problema que ocorre se a declaração e a atribuição de identificadores (aqui: membros da instância) são separadas uma da outra. Como um esboço rápido de pseudocódigo:
O cenário agora é que, durante a construção de uma instância, um erro será gerado, portanto a construção será abortada antes que a instância tenha sido totalmente construída. Essa linguagem oferece um método destruidor que será executado antes que a memória seja desalocada, por exemplo, para liberar manualmente recursos que não sejam de memória. Também deve ser executado em objetos parcialmente construídos, porque os recursos gerenciados manualmente já podem ter sido alocados antes da interrupção da construção.
Com nulos, o destruidor pode testar se uma variável foi atribuída como
if (foo != null) foo.cleanup()
. Sem nulos, o objeto está agora em um estado indefinido - qual é o valorbar
?No entanto, esse problema existe devido à combinação de três aspectos:
null
ou inicialização garantida para as variáveis de membro.let
declaração como vista em linguagens funcionais) é fácil forçar a inicialização garantida - mas restringe o idioma de outras maneiras.É fácil escolher outro design que não exiba esses problemas, por exemplo, sempre combinando declaração com atribuição e fazendo com que o idioma ofereça vários blocos finalizadores em vez de um único método de finalização:
Portanto, não há problema com a ausência de nulo, mas com a combinação de um conjunto de outros recursos com ausência de nulo.
A questão interessante agora é por que o C # escolheu um design, mas não o outro. Aqui, o contexto da citação lista muitos outros argumentos para um nulo na linguagem C #, que podem ser resumidos principalmente como "familiaridade e compatibilidade" - e essas são boas razões.
fonte
null
s: a ordem de finalização não é garantida, devido à possibilidade de ciclos de referência. Mas acho que seuFINALIZE
design também resolve isso: sefoo
já foi finalizado, suaFINALIZE
seção simplesmente não será executada.Da mesma maneira que você garante que qualquer outro dado esteja em um estado válido.
Pode-se estruturar a semântica e controlar o fluxo de forma que você não possa ter uma variável / campo de algum tipo sem criar totalmente um valor para ela. Em vez de criar um objeto e permitir que um construtor atribua valores "iniciais" a seus campos, você só pode criar um objeto especificando valores para todos os seus campos de uma só vez. Em vez de declarar uma variável e depois atribuir um valor inicial, você só pode introduzir uma variável com uma inicialização.
Por exemplo, no Rust, você cria um objeto do tipo struct via em
Point { x: 1, y: 2 }
vez de escrever um construtor que faz issoself.x = 1; self.y = 2;
. Obviamente, isso pode colidir com o estilo de linguagem que você tem em mente.Outra abordagem complementar é usar a análise de animação para impedir o acesso ao armazenamento antes de sua inicialização. Isso permite declarar uma variável sem inicializá-la imediatamente, desde que seja comprovadamente atribuída antes da primeira leitura. Também pode capturar alguns casos relacionados a falhas, como
Tecnicamente, você também pode definir uma inicialização padrão arbitrária para objetos, por exemplo, zerar todos os campos numéricos, criar matrizes vazias para campos de matriz, etc.
fonte
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 chamadosMaybe
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 aMaybe
, 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:
Sim.
Int
eMaybe Int
são dois tipos completamente separados. EncontrarNothing
em uma planícieInt
seria comparável a encontrar a string "peixe" em um arquivoInt32
.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.
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:Para aqueles que não estão familiarizados com Haskell, leia isto como "A
Maybe
é umNothing
ou umJust a
". Especificamente:Maybe
é o construtor de tipos : pode ser pensado (incorretamente) como uma classe genérica (ondea
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 tipoa
e retorna um valor do tipoMaybe a
que contém o valor. Portanto, o códigox = Just 17
é análogo aint? x = 17;
.Nothing
é outro construtor de valor, mas não aceita argumentos e oMaybe
retorno não tem outro valor além de "Nothing".x = Nothing
é análogo aint? x = null;
(supondo que restringimos o nossoa
em HaskellInt
, o que pode ser feito por escritox = 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: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:
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: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 umDouble
? Use afromIntegral
função). A única possibilidade de ocorrência de um valor inválido no tempo de execução é o usoPrelude.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):
(
foo
ebar
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 aFoo
e aBar
(que não são anuláveis) eNotSoBroken
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 oNotSoBroken
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.(a primeira linha é uma declaração de assinatura de tipo:
makeNotSoBroken
pega aFoo
e aBar
como argumentos e produz aMaybe NotSoBroken
).O tipo de retorno deve ser
Maybe NotSoBroken
e não simplesmenteNotSoBroken
porque pedimos para avaliarNothing
, que é um construtor de valor paraMaybe
. 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 aNotSoBroken
como argumento:(
useNotSoBroken
aceita aNotSoBroken
como argumento e produz aWhatever
).E use-o assim:
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 aMaybe NotSoBroken
, masuseNotSoBroken
espera aNotSoBroken
. 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 doMaybe
valor (usando um recurso chamado correspondência de padrão ):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:
makeNotSoBroken
é avaliado, que é garantido para produzir um valor do tipoMaybe NotSoBroken
.case
instrução inspeciona a estrutura desse valor.Nothing
, o código "manipular situação aqui" é avaliado.Just
valor, a outra ramificação será executada. Observe como a cláusula correspondente identifica simultaneamente o valor como umaJust
construção e vincula seuNotSoBroken
campo interno a um nome (neste caso,x
).x
pode então ser usado como oNotSoBroken
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.
fonte
Eu acho que sua citação é um argumento de palhaço.
Atualmente, as linguagens modernas (incluindo C #) garantem que o construtor seja totalmente concluído ou não.
Se houver uma exceção no construtor e o objeto for deixado parcialmente não inicializado, ter
null
ouMaybe::none
para o estado não inicializado não faz diferença real no código do destruidor.Você apenas terá que lidar com isso de qualquer maneira. Quando houver recursos externos para gerenciar, você deverá gerenciá-los explicitamente de qualquer maneira. Idiomas e bibliotecas podem ajudar, mas você terá que pensar um pouco nisso.
Btw: Em C #, o
null
valor é praticamente equivalente aMaybe::none
. Você pode atribuirnull
apenas às variáveis e membros do objeto que, em um nível de tipo, são declarados como nulos :Isso não é diferente do seguinte trecho:
Portanto, em conclusão, não vejo como a nulidade é de forma alguma oposta aos
Maybe
tipos. Eu até sugeriria que o C # se infiltrou em seu próprioMaybe
tipo e o chamouNullable<T>
.Com os métodos de extensão, é ainda fácil obter a limpeza do Nullable para seguir o padrão monádico:
fonte
null
como membro implícito de todo tipo eMaybe<T>
é que, comMaybe<T>
, você também pode ter justT
, que não tem nenhum valor padrão.null
não é um membro implícito de todos os tipos. Paranull
ser um valor lebal, você precisa definir o tipo para ser anulável explicitamente, o que torna umT?
(açúcar de sintaxe paraNullable<T>
) essencialmente equivalente aMaybe<T>
.O C ++ faz isso acessando o inicializador que ocorre antes do corpo do construtor. O C # executa o inicializador padrão antes do corpo do construtor, atribui 0 a tudo,
floats
torna-se 0,0,bools
torna-se falso, as referências tornam-se nulas etc. No C ++, você pode executar um inicializador diferente para garantir que um tipo de referência não nulo nunca seja nulo. .fonte
null
, e a única maneira de indicar a ausência de um valor é usar umMaybe
tipo (também conhecido comoOption
), que o AFAIK C ++ não possui no biblioteca padrão. A ausência de nulos nos permite garantir que um campo será sempre válido como uma propriedade do sistema de tipos . Essa é uma garantia mais forte do que garantir manualmente que não exista um caminho de código onde uma variável ainda possa estarnull
.