ponteiros nulos vs. padrão de objeto nulo

27

Atribuição: isso surgiu de uma questão P.SE relacionada

Minha formação é em C / C ++, mas trabalhei bastante em Java e atualmente estou codificando C #. Por causa do meu background C, a verificação de ponteiros passados ​​e retornados é de segunda mão, mas reconheço que isso influencia meu ponto de vista.

Vi recentemente menção do Null Object Pattern, onde a idéia é que um objeto seja sempre retornado. Caso normal retorna o objeto esperado e preenchido e o caso de erro retorna o objeto vazio em vez de um ponteiro nulo. A premissa é que a função de chamada sempre terá algum tipo de objeto para acessar e, portanto, evitará violações de memória de acesso nulo.

  • Então, quais são os prós / contras de uma verificação nula versus o uso do Null Object Pattern?

Posso ver um código de chamada mais limpo com o NOP, mas também posso ver onde ele criaria falhas ocultas que, de outra forma, não seriam geradas. Prefiro que meu aplicativo falhe bastante (também conhecido como exceção) enquanto o desenvolvo do que cometer um erro silencioso escapar para a natureza.

  • O padrão de objeto nulo não pode ter problemas semelhantes ao não executar uma verificação nula?

Muitos dos objetos com os quais trabalhei retêm objetos ou contêineres próprios. Parece que eu teria que ter um caso especial para garantir que todos os contêineres do objeto principal tivessem seus próprios objetos vazios. Parece que isso pode ficar feio com várias camadas de aninhamento.

Comunidade
fonte
Não é o "caso de erro", mas "em todos os casos um objeto válido".
@ ThorbjørnRavnAndersen - bom ponto, e editei a pergunta para refletir isso. Informe-me se ainda estou distorcendo a premissa da NOP.
6
A resposta curta é que "padrão de objeto nulo" é um nome impróprio. É chamado com mais precisão de "anti-padrão de objeto nulo". Você geralmente está melhor com um "objeto de erro" - um objeto válido que implementa toda a interface da classe, mas qualquer uso dela causa morte súbita e alta.
Jerry Coffin
1
@JerryCoffin nesse caso deve ser indicado por uma exceção. A idéia é que você nunca pode obter um nulo e, portanto, não precisa procurar nulo.
1
Eu não gosto desse "padrão". Mas, possivelmente, ele esteja enraizado em bancos de dados relacionais com excesso de restrições, onde é mais fácil se referir a "linhas nulas" especiais em chaves estrangeiras durante a preparação de dados editáveis, antes da atualização final, quando todas as chaves estrangeiras não forem perfeitamente nulas.

Respostas:

24

Você não usaria um Padrão de Objeto Nulo em locais onde é retornado nulo (ou Objeto Nulo) porque houve uma falha catastrófica. Nesses lugares, continuaria retornando nulo. Em alguns casos, se não houver recuperação, você também pode travar, porque pelo menos o despejo de travamento indicará exatamente onde o problema ocorreu. Nesses casos, quando você adiciona seu próprio tratamento de erros, você ainda mata o processo (novamente, eu disse para casos em que não há recuperação), mas o tratamento de erros mascarará informações muito importantes que um despejo de memória teria fornecido.

O Padrão de Objeto Nulo é mais para locais onde há um comportamento padrão que pode ser adotado em um caso em que o objeto não é encontrado. Por exemplo, considere o seguinte:

User* pUser = GetUser( "Bob" );

if( pUser )
{
    pUser->SetAddress( "123 Fake St." );
}

Se você usa o NOP, você escreveria:

GetUser( "Bob" )->SetAddress( "123 Fake St." );

Observe que o comportamento desse código é "se Bob existir, quero atualizar seu endereço". Obviamente, se seu aplicativo exige que Bob esteja presente, você não deseja ter sucesso silencioso. Mas há casos em que esse tipo de comportamento seria apropriado. E nesses casos, o NOP não produz um código muito mais limpo e conciso?

Em lugares onde você realmente não pode viver sem Bob, eu teria GetUser () lançando uma exceção de aplicativo (ou seja, sem violação de acesso ou algo assim) que seria tratado em um nível mais alto e reportaria falhas gerais de operação. Nesse caso, não há necessidade de NOP, mas também não é necessário verificar explicitamente se há NULL. O IMO, essas verificações de NULL, apenas aumentam o código e diminuem a legibilidade. A verificação de NULL ainda é a escolha certa de design para algumas interfaces, mas não tanto quanto muitas pessoas pensam.

DXM
fonte
4
Concorde com o caso de uso para padrões de objetos nulos. Mas por que retornar nulo ao encontrar falhas catastróficas? Por que não lançar exceções?
Apoorv Khurasia
2
@MonsterTruck: inverta isso. Quando escrevo código, certifico-me de validar a maioria das entradas recebidas de componentes externos. No entanto, entre classes internas, se eu escrever código de modo que uma função de uma classe nunca retorne NULL, no lado da chamada não adicionarei verificação nula apenas para "ser mais seguro". A única maneira de esse valor ser NULL é se algum erro lógico catastrófico ocorrer no meu aplicativo. Nesse caso, quero que meu programa trave exatamente nesse local, porque então recebo um bom arquivo de despejo de memória e reverto a pilha e o estado do objeto para identificar o erro lógico.
DXM
2
@MonsterTruck: por "reverter isso", eu quis dizer, não retorne explicitamente NULL quando você identificar um erro catastrófico, mas espere que o NULL ocorra apenas devido a algum erro catastrófico imprevisto.
DXM
Agora entendo o que você quis dizer com erro catastrófico - algo que causa falha na alocação de objetos. Direita? Edit: Não é possível fazer @ DXM porque seu apelido tem apenas 3 caracteres.
Apoorv Khurasia
@MonsterTruck: estranho sobre a coisa "@". Eu sei que outras pessoas já fizeram isso antes. Gostaria de saber se eles alteraram o site recentemente. Não precisa ser alocação. Se eu escrever uma classe e a intenção da API for sempre retornar um objeto válido, não seria necessário que o chamador fizesse uma verificação nula. Mas erro catastrófico pode ser algo como erro de programador (um erro de digitação que causou o retorno de NULL), ou talvez alguma condição de corrida que apagou um objeto antes do tempo, ou talvez alguma corrupção de pilha. Essencialmente, qualquer caminho de código inesperado que levou ao retorno de NULL. Quando isso acontece você quer ...
DXM
7

Então, quais são os prós / contras de uma verificação nula versus o uso do Null Object Pattern?

Prós

  • Uma verificação nula é melhor, pois resolve mais casos. Nem todos os objetos têm um comportamento padrão ou não operacional.
  • Uma verificação nula é mais sólida. Até objetos com padrões sãos são usados ​​em locais onde o padrão são não é válido. O código deve falhar próximo à causa raiz, se falhar. O código deve falhar obviamente se falhar.

Contras

  • Padrões normais geralmente resultam em código mais limpo.
  • Padrões normais geralmente resultam em erros menos catastróficos se eles conseguem entrar na natureza.

Este último "profissional" é o principal diferenciador (na minha experiência) de quando cada um deve ser aplicado. "A falha deve ser barulhenta?" Em alguns casos, você deseja que uma falha seja difícil e imediata; se algum cenário que nunca deveria acontecer de alguma forma acontecer. Se um recurso vital não foi encontrado ... etc. Em alguns casos, você concorda com um padrão sensato, pois não é realmente um erro : obter um valor de um dicionário, mas a chave está ausente, por exemplo.

Como qualquer outra decisão de design, existem vantagens e desvantagens dependendo de suas necessidades.

Telastyn
fonte
1
Na seção Prós: concorde com o primeiro ponto. O segundo ponto é a falha dos designers, porque mesmo nulo pode ser usado onde não deveria estar. Não diz nada de ruim sobre o padrão, apenas reflete sobre o desenvolvedor que usou o padrão incorretamente.
Apoorv Khurasia
O que é mais falha catastrófica, não ser capaz de enviar dinheiro ou enviar (e, portanto, não ter mais) dinheiro sem que o destinatário os receba e não o conhecesse antes da verificação explícita?
user470365
Contras: os padrões normais podem ser usados ​​para criar novos objetos. Se um objeto não nulo for retornado, alguns de seus atributos poderão ser modificados ao criar outro objeto. Se um objeto nulo for retornado, ele poderá ser usado para criar um novo objeto. Nos dois casos, o mesmo código funciona. Não há necessidade de código separado para copiar e modificar e novo. Isso faz parte da filosofia de que toda linha de código é outro lugar para erros; reduzir as linhas reduz bugs.
shawnhcorey
@shawnhcorey - what?
Telastyn
Um objeto nulo deve ser utilizável como se fosse um objeto real. Por exemplo, considere o NaN do IEEE (não um número). Se uma equação der errado, ela será retornada. E pode ser usado para cálculos adicionais, já que qualquer operação nele retornará NaN. Ele simplifica o código, pois você não precisa verificar o NaN após cada operação aritmética.
shawnhcorey
6

Não sou uma boa fonte de informações objetivas, mas subjetivamente:

  • Eu não quero que meu sistema morra, nunca; para mim é um sinal de sistema mal projetado ou incompleto; o sistema completo deve lidar com todos os casos possíveis e nunca entrar em estado inesperado; para conseguir isso, preciso ser explícito na modelagem de todos os fluxos e situações; o uso de valores nulos não é explícito e agrupa muitos casos diferentes em um umrella; Prefiro o objeto NonExistingUser a nulo, prefiro NaN a nulo ... ... essa abordagem me permite ser explícito sobre essas situações e seu manuseio em tantos detalhes quanto eu quiser, enquanto nulo me deixa com apenas a opção nula; em um ambiente como Java, qualquer objeto pode ser nulo; por que você prefere ocultar nesse conjunto genérico de casos também o seu caso específico?
  • implementações nulas têm comportamento drasticamente diferente de objeto e são coladas principalmente ao conjunto de todos os objetos (por que? por quê? por quê?); para mim, essa diferença drástica parece que o resultado do design de nulos é uma indicação de erro; nesse caso, o sistema provavelmente morrerá (estou surpreso que muitas pessoas realmente prefiram que o sistema morra nas postagens acima), a menos que seja explicitamente tratado; para mim, essa é uma abordagem defeituosa em sua raiz - ela permite que o sistema morra por padrão, a menos que você tenha explicitamente se preocupado com isso, isso não é muito seguro, certo? mas, em qualquer caso, torna impossível escrever um código que trate o valor nulo e o objeto da mesma maneira - apenas alguns operadores básicos incorporados (assign, equal, param, etc.) funcionarão, todo o outro código falhará e você precisará dois caminhos completamente diferentes;
  • usar melhor as escalas de objetos nulos - você pode colocar quantas informações e estrutura quiser; NonExistingUser é um exemplo muito bom - ele pode conter um email do usuário que você tentou receber e sugerir a criação de novo usuário com base nessas informações, enquanto que com a solução nula eu precisaria pensar em como manter o email que as pessoas tentaram acessar próximo à manipulação de resultados nulos;

Em ambientes como Java ou C #, isso não oferece o principal benefício da explicitação - a segurança da solução estar completa, pois você ainda pode receber nulo no lugar de qualquer objeto no sistema e o Java não tem meios (exceto anotações personalizadas para o Beans , etc.) para protegê-lo, exceto ifs explícitos. Porém, no sistema livre de implementações nulas, abordar a solução de problemas dessa maneira (com objetos representando casos excepcionais) oferece todos os benefícios mencionados. Portanto, tente ler sobre algo que não seja a linguagem convencional e você se verá mudando sua maneira de codificar.

Em geral, os objetos Nulo / Erro / tipos de retorno são considerados uma estratégia de tratamento de erros muito sólida e bastante matematicamente correta, enquanto as exceções que tratam a estratégia de Java ou C são apenas um passo acima da estratégia "die ASAP" - então basicamente deixam todo o ônus da instabilidade e imprevisibilidade do sistema no desenvolvedor ou mantenedor.

Também existem mônadas que podem ser consideradas superiores a essa estratégia (também superiores em complexidade de entendimento), mas essas são mais sobre os casos em que você precisa modelar aspectos externos do tipo, enquanto Null Object modela aspectos internos. Portanto, NonExistingUser provavelmente é melhor modelado como mônada talvez_existindo.

E, finalmente, não acho que exista nenhuma conexão entre o padrão Null Object e o tratamento silencioso de situações de erro. Deve ser o contrário - deve forçar você a modelar explicitamente todos os casos de erro e manipulá-lo adequadamente. Agora, é claro que você pode decidir ser desleixado (por qualquer motivo) e pular o tratamento explícito do caso de erro, mas tratá-lo genericamente - bem, essa foi sua escolha explícita.

Bohdan Tsymbala
fonte
9
A razão pela qual eu gosto de meu aplicativo falhar quando algo inesperado acontece é que algo inesperado aconteceu. Como isso aconteceu? Por quê? Recebo um despejo de memória e posso investigar e corrigir um problema potencialmente perigoso, em vez de deixá-lo oculto no código. No C #, é claro que posso capturar todas as exceções inesperadas para tentar recuperar o aplicativo, mas mesmo assim quero registrar o local em que o problema ocorreu e descobrir se é algo que precisa de tratamento separado (mesmo que seja "não registrar esse erro, é realmente esperado e recuperável ").
Luaan
3

Acho interessante notar que a viabilidade de um Objeto Nulo parece ser um pouco diferente, dependendo se o seu idioma é ou não estaticamente digitado. Ruby é uma linguagem que implementa um objeto para Nulo (ou Nilna terminologia da linguagem).

Em vez de retornar um ponteiro para a memória não inicializada, o idioma retornará o objeto Nil. Isso é facilitado pelo fato de o idioma ser digitado dinamicamente. Não é garantido que uma função sempre retorne um int. Pode retornar Nilse é isso que precisa fazer. Torna-se mais complicado e difícil fazer isso em uma linguagem estaticamente tipada, porque você naturalmente espera que nulo seja aplicável a qualquer objeto que possa ser chamado por referência. Você teria que começar a implementar versões nulas de qualquer objeto que pudesse ser nulo (ou seguir a rota Scala / Haskell e ter algum tipo de wrapper "Talvez").

MrLister trouxe à tona o fato de que, em C ++, não é garantido que você tenha uma referência / ponteiro de inicialização ao criá-lo. Por ter um objeto nulo dedicado em vez de um ponteiro nulo bruto, você pode obter alguns recursos adicionais interessantes. Ruby Nilinclui uma to_sfunção (toString) que resulta em texto em branco. Isso é ótimo se você não se importa em obter um retorno nulo em uma string e deseja pular a geração de relatórios sem travar. As Classes abertas também permitem substituir manualmente a saída, Nilse necessário.

Concedido isso tudo pode ser tomado com um grão de sal. Acredito que em uma linguagem dinâmica, o objeto nulo pode ser uma grande conveniência quando usado corretamente. Infelizmente, tirar o máximo proveito disso pode exigir que você edite sua funcionalidade básica, o que pode dificultar a compreensão e a manutenção do programa para as pessoas acostumadas a trabalhar com seus formulários mais padrão.

KChaloux
fonte
1

Para alguns pontos de vista adicionais, dê uma olhada no Objective-C, onde, em vez de ter um objeto Nulo, o valor nulo funciona como um objeto. Qualquer mensagem enviada para um objeto nulo não tem efeito e retorna um valor de 0 / 0.0 / NO (falso) / NULL / nulo, qualquer que seja o tipo de valor de retorno do método. Isso é usado como um padrão muito difundido na programação do MacOS X e iOS, e geralmente os métodos são projetados para que um objeto nulo retornando 0 seja um resultado razoável.

Por exemplo, se você quiser verificar se uma sequência possui um ou mais caracteres, você pode escrever

aString.length > 0

e obtenha um resultado razoável se aString == nil, porque uma sequência inexistente não contém caracteres. As pessoas tendem a demorar um pouco para se acostumar, mas provavelmente levaria um tempo para me acostumar a verificar valores nulos em qualquer lugar em outros idiomas.

(Há também um objeto singleton da classe NSNull que se comporta bem diferente e não é muito utilizado na prática).

gnasher729
fonte
O Objective-C fez a coisa Null Object / Null Pointer muito embaçada. nilobtém #defineNULL e se comporta como um objeto nulo. Esse é um recurso de tempo de execução enquanto em ponteiros nulos em C # / Java emite erros.
Maxthon Chan
0

Não use se você puder evitá-lo. Use uma função de fábrica retornando um tipo de opção, uma tupla ou um tipo de soma dedicado. Em outras palavras, represente um valor potencialmente padrão com um tipo diferente de um valor garantido para não ser padronizado.

Primeiro, vamos listar os desiderata e depois pensar em como podemos expressar isso em algumas linguagens (C ++, OCaml, Python)

  1. captura o uso indesejado de um objeto padrão em tempo de compilação.
  2. torne óbvio se um determinado valor é potencialmente padrão ou não ao ler o código.
  3. escolha nosso valor padrão sensível, se aplicável, uma vez por tipo . Definitivamente, não escolha um padrão potencialmente diferente em cada site de chamada .
  4. facilite a busca de ferramentas de análise estática ou de um ser humano grepem busca de possíveis erros.
  5. Para alguns aplicativos , o programa deve continuar normalmente se receber um valor padrão inesperado. Para outras aplicações , o programa deve parar imediatamente se receber um valor padrão, idealmente de maneira informativa.

Eu acho que a tensão entre o Padrão de Objeto Nulo e os ponteiros nulos vem de (5). Se pudermos detectar erros cedo o suficiente, (5) se torna discutível.

Vamos considerar esse idioma por idioma:

C ++

Na minha opinião, uma classe C ++ geralmente deve ser construtiva por padrão, pois facilita as interações com as bibliotecas e facilita o uso da classe em contêineres. Isso também simplifica a herança, pois você não precisa pensar em qual construtor de superclasse chamar.

No entanto, isso significa que você não pode ter certeza se um valor do tipo MyClassestá no "estado padrão" ou não. Além de inserir um bool nonemptycampo ou um campo semelhante à superfície padrão em tempo de execução, o melhor que você pode fazer é produzir novas instâncias de MyClassuma maneira que obrigue o usuário a verificá-la .

Eu recomendo usar uma função de fábrica que retorne uma std::optional<MyClass>, std::pair<bool, MyClass>ou uma referência de valor r para a, std::unique_ptr<MyClass>se possível.

Se você deseja que sua função de fábrica retorne algum tipo de estado de "espaço reservado" que seja diferente de um construído por padrão MyClass, use a std::paire certifique-se de documentar que sua função faz isso.

Se a função de fábrica tiver um nome exclusivo, será fácil grepidentificá-lo e procurar desleixo. No entanto, é difícil greppara casos em que o programador deveria ter usado a função de fábrica, mas não o fez .

OCaml

Se você estiver usando um idioma como OCaml, poderá usar apenas um optiontipo (na biblioteca padrão do OCaml) ou um eithertipo (não na biblioteca padrão, mas fácil de criar). Ou um defaultabletipo (estou inventando o termo defaultable).

type 'a option =
    | None
    | Some of 'a

type ('a, 'b) either =
    | Left of 'a
    | Right of 'b

type 'a defaultable =
    | Default of 'a
    | Real of 'a

A, defaultablecomo mostrado acima, é melhor que um par, porque o usuário deve corresponder ao padrão para extrair o 'ae não pode simplesmente ignorar o primeiro elemento do par.

O equivalente do defaultabletipo mostrado acima pode ser usado em C ++ usando a std::variantcom duas instâncias do mesmo tipo, mas partes da std::variantAPI não podem ser usadas se os dois tipos forem iguais. Também é um uso estranho, std::variantjá que seus construtores de tipo não têm nome.

Python

Você não recebe nenhuma verificação em tempo de compilação para o Python. Mas, sendo digitado dinamicamente, geralmente não há circunstâncias em que você precise de uma instância de espaço reservado de algum tipo para aplacar o compilador.

Eu recomendaria apenas lançar uma exceção quando você fosse forçado a criar uma instância padrão.

Se isso não for aceitável, eu recomendaria criar um DefaultedMyClassque herda MyClasse retorne esse da sua função de fábrica. Isso oferece flexibilidade em termos de funcionalidade "stubbing out" em instâncias padrão, se necessário.

Gregory Nisbet
fonte