Como os idiomas com os tipos Maybe, em vez de nulos, lidam com as condições de borda?

53

Eric Lippert fez um ponto muito interessante em sua discussão sobre por que o C # usa um nulle 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?

Mason Wheeler
fonte
Eu acho que se o Talvez faz parte da linguagem, pode ser que ele seja implementado internamente por meio de um ponteiro nulo e seja apenas açúcar sintático. Mas acho que nenhum idioma realmente faz assim.
Panzi
11
@panzi: usos Ceilão fluxo sensível de digitação para distinguir entre Type?(talvez) e Type(não null)
Lukas Eder
11
@RobertHarvey Não existe um botão "boa pergunta" no Stack Exchange?
User253751
2
@panzi Essa é uma otimização agradável e válida, mas não ajuda com este problema: quando algo não é um Maybe T, não deve ser Nonee, portanto, você não pode inicializar seu armazenamento no ponteiro nulo.
@immibis: Eu já empurrei. Temos poucas e boas perguntas aqui; Eu pensei que este merecia um comentário.
Robert Harvey

Respostas:

45

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:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

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 valor bar?

No entanto, esse problema existe devido à combinação de três aspectos:

  • A ausência de valores padrão como nullou inicialização garantida para as variáveis ​​de membro.
  • A diferença entre declaração e atribuição. Forçar variáveis ​​a serem atribuídas imediatamente (por exemplo, com uma letdeclaração como vista em linguagens funcionais) é fácil forçar a inicialização garantida - mas restringe o idioma de outras maneiras.
  • O sabor específico dos destruidores como um método chamado pelo tempo de execução do idioma.

É 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:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

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.

amon
fonte
Há também outra razão pela qual o finalizador deve lidar com nulls: a ordem de finalização não é garantida, devido à possibilidade de ciclos de referência. Mas acho que seu FINALIZEdesign também resolve isso: se foojá foi finalizado, sua FINALIZEseção simplesmente não será executada.
Svick
14

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 isso self.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

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

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
7

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 Nothingvalor 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 Maybeque 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. Inte Maybe Intsão dois tipos completamente separados. Encontrar Nothingem uma planície Intseria 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 Maybetipo 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 Nothingou um Just a". Especificamente:

  • Maybeé o construtor de tipos : pode ser pensado (incorretamente) como uma classe genérica (onde aestá 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 ae retorna um valor do tipo Maybe aque 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 Mayberetorno não tem outro valor além de "Nothing". x = Nothingé análogo a int? x = null;(supondo que restringimos o nosso aem Haskell Int, o que pode ser feito por escrito x = Nothing :: Maybe Int).

Agora que os conceitos básicos Maybeestã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 nullporque 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 doanotaçã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-expressionpode 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 Intpara um Double? Use a fromIntegralfunçã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 } 

( fooe barsão realmente funções de acessador para campos anônimos aqui em vez de campos reais, mas podemos ignorar esse detalhe).

O NotSoBrokenconstrutor de valor é incapaz de executar qualquer ação que não seja a Fooe a Bar(que não são anuláveis) e NotSoBrokeneliminá-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 Brokensempre falha. Não há como quebrar o NotSoBrokenconstrutor 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: makeNotSoBrokenpega a Fooe a Barcomo argumentos e produz a Maybe NotSoBroken).

O tipo de retorno deve ser Maybe NotSoBrokene não simplesmente NotSoBrokenporque 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 useNotSoBrokenque espera a NotSoBrokencomo argumento:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenaceita a NotSoBrokencomo 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: makeNotSoBrokenretorna a Maybe NotSoBroken, mas useNotSoBrokenespera a NotSoBroken. Esses tipos não são intercambiáveis ​​e o código falha ao compilar.

Para contornar isso, podemos usar uma caseinstrução para ramificar com base na estrutura do Maybevalor (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 caseinstruçã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 Justvalor, a outra ramificação será executada. Observe como a cláusula correspondente identifica simultaneamente o valor como uma Justconstrução e vincula seu NotSoBrokencampo interno a um nome (neste caso, x). xpode então ser usado como o NotSoBrokenvalor 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.

AproximandoEscuridãoPeixe
fonte
TL; DR deve estar no topo :)
andrew.fox
@ andrew.fox Bom ponto. Eu vou editar.
Aproximando-se
0

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 nullou Maybe::nonepara 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 nullvalor é praticamente equivalente a Maybe::none. Você pode atribuir nullapenas às variáveis ​​e membros do objeto que, em um nível de tipo, são declarados como nulos :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Isso não é diferente do seguinte trecho:

Maybe<String> optionalString = getOptionalString();

Portanto, em conclusão, não vejo como a nulidade é de forma alguma oposta aos Maybetipos. Eu até sugeriria que o C # se infiltrou em seu próprio Maybetipo e o chamou Nullable<T>.

Com os métodos de extensão, é ainda fácil obter a limpeza do Nullable para seguir o padrão monádico:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );
Roland Tepp
fonte
2
o que significa "construtor ou completa completamente ou não"? Em Java, por exemplo, a inicialização do campo (não final) no construtor não é protegida da corrida de dados - isso se qualifica como completo ou não?
Gnat
@gnat: o que você quer dizer com "Em Java, por exemplo, a inicialização do campo (não final) no construtor não está protegida da corrida de dados". A menos que você faça algo espetacularmente complexo envolvendo vários threads, as chances de condições de corrida dentro de um construtor são (ou deveriam ser) quase impossíveis. Você não pode acessar um campo de um objeto não construído, exceto de dentro do construtor de objetos. E se a construção falhar, você não terá uma referência ao objeto.
Roland Tepp
A grande diferença entre nullcomo membro implícito de todo tipo e Maybe<T>é que, com Maybe<T>, você também pode ter just T, que não tem nenhum valor padrão.
Svick
Ao criar matrizes, freqüentemente não será possível determinar valores úteis para todos os elementos sem precisar ler alguns, nem verificar estaticamente se nenhum elemento é lido sem que um valor útil tenha sido calculado para ele. O melhor que se pode fazer é inicializar os elementos da matriz de maneira que possam ser reconhecidos como inutilizáveis.
Supercat
@ Rick: Em C # (que era o idioma em questão pelo OP), nullnão é um membro implícito de todos os tipos. Para nullser um valor lebal, você precisa definir o tipo para ser anulável explicitamente, o que torna um T?(açúcar de sintaxe para Nullable<T>) essencialmente equivalente a Maybe<T>.
Roland Tepp
-3

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, floatstorna-se 0,0, boolstorna-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. .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}
ryancerium
fonte
2
A pergunta era sobre línguas com talvez tipos
mosquito
3
Referências se tornam nulas ” - a premissa toda da questão é que não temos null, e a única maneira de indicar a ausência de um valor é usar um Maybetipo (também conhecido como Option), 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 estar null.
amon
Embora o c ++ não tenha tipos talvez explicitamente, coisas como std :: shared_ptr <T> estão próximas o suficiente para que eu ainda seja relevante que o c ++ lide com o caso em que a inicialização de variáveis ​​pode ocorrer "fora do escopo" do construtor e é de fato necessário para tipos de referência (&), pois eles não podem ser nulos.
FryGuy