Os perigos dos designers

Olá Habr! Apresento a você a tradução do artigo "Perigos dos construtores", de Aleksey Kladov.


Um dos meus posts favoritos do Rust é o Things Rust Shipped Without, de Graydon Hoare . Para mim, a falta de qualquer recurso no idioma que possa disparar na perna é geralmente mais importante do que expressividade. Neste ensaio ligeiramente filosófico, quero falar sobre meu recurso particularmente favorito que falta a Rust - sobre construtores.


O que é um construtor?


Construtores são comumente usados ​​em idiomas OO. A tarefa do construtor é inicializar completamente o objeto antes que o resto do mundo o veja. À primeira vista, parece uma boa ideia:


  1. Você define os invariantes no construtor.
  2. Cada método cuida da conservação de invariantes.
  3. Juntas, essas duas propriedades significam que você pode pensar em objetos como invariantes, e não como estados internos específicos.

O construtor aqui desempenha o papel de uma base de indução, sendo a única maneira de criar um novo objeto.


Infelizmente, há um buraco nesses argumentos: o próprio designer observa o objeto em um estado inacabado, o que cria muitos problemas.


Esse valor


Quando o construtor inicializa o objeto, ele começa com algum estado vazio. Mas como você define esse estado vazio para um objeto arbitrário?


A maneira mais fácil de fazer isso é definir todos os campos com seus valores padrão: false para bool, 0 para números, nulo para todos os links. Mas essa abordagem exige que todos os tipos tenham valores padrão e introduz o nulo infame no idioma. Este é o caminho que o Java seguiu: no início da criação do objeto, todos os campos são 0 ou nulos.


Com essa abordagem, será muito difícil se livrar do nulo posteriormente. Um bom exemplo para aprender é o Kotlin. O Kotlin usa tipos não anuláveis ​​por padrão, mas é forçado a trabalhar com a semântica pré-existente da JVM. O design da linguagem oculta bem esse fato e é bem aplicável na prática, mas é insustentável . Em outras palavras, usando construtores, é possível ignorar as verificações nulas no Kotlin.


A principal característica do Kotlin é o incentivo à criação dos chamados "construtores primários" que simultaneamente declaram um campo e atribuem um valor a ele antes que qualquer código personalizado seja executado:


class Person( val firstName: String, val lastName: String ) { ... } 

Outra opção: se o campo não for declarado no construtor, o programador deve inicializá-lo imediatamente:


 class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" } 

Tentativa de usar um campo antes da inicialização ser negada estaticamente:


 class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName) // :     fullName = "$firstName $lastName" } } 

Mas com um pouco de criatividade, qualquer um pode contornar essas verificações. Por exemplo, uma chamada de método é adequada para isso:


 class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x) //  null } fun main() { A() } 

Pegar isso com um lambda (criado no Kotlin da seguinte maneira: {args -> body}) também é adequado:


 class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x) //  null } 

Exemplos como esses parecem irreais na realidade (e é), mas eu encontrei erros semelhantes no código real (regra de probabilidade 0-1 de Kolmogorov no desenvolvimento de software: em um banco de dados suficientemente grande, é quase garantido que qualquer parte do código existe, pelo menos se não houver) proibido estaticamente pelo compilador; nesse caso, quase certamente não existe).


O motivo pelo qual o Kotlin pode existir com essa falha é o mesmo que com as matrizes covariantes Java: as verificações ainda ocorrem em tempo de execução. No final, eu não gostaria de complicar o sistema do tipo Kotlin para tornar os casos acima incorretos no estágio de compilação: considerando as limitações existentes (semântica da JVM), a relação preço / benefício das validações em tempo de execução é muito melhor do que a dos estáticos.


Mas e se o idioma não tiver um valor padrão razoável para cada tipo? Por exemplo, em C ++, onde tipos definidos pelo usuário não são necessariamente referências, você não pode simplesmente atribuir nulo a cada campo e dizer que isso funcionará! Em vez disso, o C ++ usa sintaxe especial para definir valores iniciais para os campos: listas de inicialização:


 #include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; }; 

Como essa é uma sintaxe especial, o restante do idioma não funciona perfeitamente. Por exemplo, é difícil colocar operações arbitrárias nas listas de inicialização, pois o C ++ não é uma linguagem orientada a expressões (o que é normal por si só). Para trabalhar com exceções que ocorrem nas listas de inicialização, você precisa usar outro recurso obscuro do idioma .


Chamando métodos do construtor


Como os exemplos de Kotlin sugerem, tudo se quebra em chips assim que tentamos chamar um método do construtor. Basicamente, os métodos esperam que o objeto acessível através dele já esteja totalmente construído e correto (consistente com os invariantes). Mas no Kotlin ou Java, nada impede que você invoque métodos do construtor, e dessa maneira podemos operar acidentalmente em um objeto semi-construído. O designer promete estabelecer invariantes, mas, ao mesmo tempo, esse é o local mais fácil para possíveis violações.


Coisas particularmente estranhas acontecem quando o construtor da classe base chama um método substituído em uma classe derivada:


 abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x) //  null! } 

Pense bem: o código de uma classe arbitrária é executado antes de chamar seu construtor! Um código C ++ semelhante levará a resultados ainda mais interessantes. Em vez de chamar a função da classe derivada, a função da classe base será chamada. Isso faz pouco sentido, porque a classe derivada ainda não foi inicializada (lembre-se, não podemos apenas dizer que todos os campos são nulos). No entanto, se a função na classe base for pura virtual, sua chamada levará ao UB.


Designer Signature


A violação de invariantes não é o único problema para designers. Eles têm uma assinatura com um nome fixo (vazio) e tipo de retorno (a própria classe). Isso dificulta as sobrecargas de design para as pessoas entenderem.


Pergunta de preenchimento: a que std :: vector <int> xs (92, 2) corresponde?

a. Vetor de dois comprimentos 92

b. [92, 92]

c. [92,2]

Problemas com o valor de retorno surgem, como regra, quando é impossível criar um objeto. Você não pode simplesmente retornar Result <MyClass, io :: Error> ou nulo do construtor!


Isso geralmente é usado como argumento a favor do fato de que é difícil usar C ++ sem exceções e que o uso de construtores também obriga a usar exceções. No entanto, não acho que esse argumento esteja correto: os métodos de fábrica resolvem esses dois problemas, porque podem ter nomes arbitrários e retornar tipos arbitrários. Acredito que o seguinte padrão às vezes pode ser útil nas linguagens OO:


  • Crie um construtor privado que tome os valores de todos os campos como argumentos e simplesmente os atribua. Assim, esse construtor funcionaria como uma estrutura literal no Rust. Ele também pode verificar se há invariantes, mas não deve fazer mais nada com argumentos ou campos.


  • métodos públicos de fábrica são fornecidos para a API pública com nomes e tipos de retorno apropriados.



Um problema semelhante com os construtores é que eles são específicos e, portanto, não podem ser generalizados. No C ++, "existe um construtor padrão" ou "existe um construtor de cópia" não pode ser expresso mais simplesmente do que "certas sintaxes funcionam". Compare isso com Rust, onde esses conceitos possuem assinaturas adequadas:


 trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; } 

Vida sem designers


O Rust possui apenas uma maneira de criar uma estrutura: fornecer valores para todos os campos. Funções de fábrica, como a nova geralmente aceita, desempenham o papel de construtores, mas, o mais importante, elas não permitem que você chame nenhum método até que você tenha pelo menos uma instância mais ou menos correta da estrutura.


A desvantagem dessa abordagem é que qualquer código pode criar uma estrutura; portanto, não há um local único, como um construtor, para manter os invariantes. Na prática, isso é facilmente resolvido pela privacidade: se os campos da estrutura forem privados, essa estrutura poderá ser criada apenas no mesmo módulo. Dentro de um módulo, não é difícil aderir ao acordo "todos os métodos de criação de uma estrutura devem usar o novo método". Você pode até imaginar uma extensão de idioma que permita marcar algumas funções com o atributo # [construtor], para que a sintaxe literal da estrutura esteja disponível apenas nas funções marcadas. Mas, novamente, mecanismos lingüísticos adicionais parecem redundantes para mim: seguir as convenções locais exige pouco esforço.


Pessoalmente, acredito que esse compromisso seja exatamente o mesmo para a programação de contratos em geral. Contratos como "não nulo" ou "valor positivo" são melhor codificados nos tipos. Para invariantes complexos, basta escrever assert! (Self.validate ()) em cada método não é tão difícil. Entre esses dois padrões, há pouco espaço para as condições # [pré] e # [pós] implementadas no nível do idioma ou com base em macros.

E o Swift?


Swift é outra linguagem interessante que vale a pena examinar os mecanismos de design. Como o Kotlin, o Swift é uma linguagem segura e nula. Ao contrário do Kotlin, as verificações nulas do Swift são mais fortes, então a linguagem usa truques interessantes para mitigar os danos causados ​​pelos construtores.


Primeiro , o Swift usa argumentos nomeados e ajuda um pouco com "todos os construtores têm o mesmo nome". Em particular, dois construtores com os mesmos tipos de parâmetros não são um problema:


 Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15) 

Segundo , para resolver o problema "o construtor chama o método virtual da classe do objeto que ainda não foi totalmente criado" Swift usa um protocolo de inicialização em duas fases bem pensado. Embora não haja sintaxe especial para as listas de inicialização, o compilador verifica estaticamente se o corpo do construtor possui a forma correta e segura. Por exemplo, chamar métodos é possível somente depois que todos os campos da classe e seus descendentes foram inicializados.


Em terceiro lugar , no nível do idioma, há suporte para construtores, cuja chamada pode falhar. O construtor pode ser designado como anulável, o que torna o resultado da chamada da classe uma opção. O construtor também pode ter um modificador de arremessos, que funciona melhor com a semântica da inicialização em duas fases no Swift do que com a sintaxe das listas de inicialização no C ++.


Swift consegue fechar todos os buracos nos construtores dos quais reclamei. No entanto, isso tem um preço: o capítulo de inicialização é um dos maiores do livro Swift.


Quando os construtores são realmente necessários


Contra todas as probabilidades, posso apresentar pelo menos duas razões pelas quais os construtores não podem ser substituídos por literais de estrutura, como em Rust.


Primeiro , a herança, em um grau ou outro, força a linguagem a ter construtores. Você pode imaginar uma extensão da sintaxe de estruturas com suporte para classes base:


 struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } } 

Mas isso não funcionará em um layout de objeto típico de uma linguagem OO com herança simples! Normalmente, um objeto começa com um título seguido por campos de classe, da base à mais derivada. Portanto, o prefixo do objeto da classe derivada é o objeto correto da classe base. No entanto, para que esse layout funcione, o designer precisa alocar memória para todo o objeto de cada vez. Ele não pode apenas alocar memória apenas para a classe base e anexar campos derivados. Mas tal alocação de memória em partes é necessária se quisermos usar a sintaxe para criar uma estrutura na qual poderíamos especificar um valor para a classe base.


Em segundo lugar , em contraste com a sintaxe literal da estrutura, os designers têm uma ABI que funciona bem ao colocar subobjetos de objetos na memória (ABI otimizada para posicionamento). O construtor trabalha com um ponteiro para isso, que aponta para a área da memória que o novo objeto deve ocupar. Mais importante, um construtor pode facilmente passar um ponteiro para subobjetar construtores, permitindo assim a criação de árvores de valor complexas "no lugar". Em contraste, no Rust, a construção de estruturas semanticamente inclui algumas cópias, e aqui esperamos a graça do otimizador. Não é por acaso que Rust ainda não possui uma proposta de trabalho aceita sobre a colocação de sub-objetos na memória!


Atualização 1: corrigido um erro de digitação. Substituiu o "literal de gravação" por "literal de estrutura".

Source: https://habr.com/ru/post/pt460831/


All Articles