Olá colegas.
Há pouco tempo, nossa atenção foi atraída pelo livro
quase pronto da Manning Publishing House “Programação com tipos”, que detalha a importância da digitação adequada e seu papel na escrita de códigos limpos e duradouros.

Ao mesmo tempo, no blog do autor, encontramos um artigo escrito, aparentemente, nos estágios iniciais do trabalho do livro e permitindo impressionar seu material. Sugerimos discutir como as idéias do autor são interessantes e, potencialmente, o livro inteiro
Mars orbiter climáticoA sonda Mars Climate Orbiter caiu durante o pouso e desmoronou na atmosfera marciana, porque o componente de software da Lockheed forneceu o valor do momento, medido em libra-força s. seg
Você pode imaginar o componente desenvolvido pela NASA aproximadamente da seguinte forma:
Você também pode imaginar que o componente Lockheed chamou o código acima assim:
void main() { trajectory_correction(1.5 ); }
Libra-força-segundo (lbfs) é de aproximadamente 4.448222 newtons por segundo (Ns). Assim, do ponto de vista de Lockheed, passar 1,5 lbfs para a
trajectory_correction
deve ser perfeitamente normal: 1,5 lbfs é aproximadamente 6,672333 Ns, bem acima do limite de 2 Ns.
O problema é a interpretação dos dados. Como resultado, o componente da NASA compara lbfs com Ns sem conversão e interpreta erroneamente a entrada em lbfs como entrada em Ns. Como 1,5 é menor que 2, o orbitador entrou em colapso. Este é um antipadrão bem conhecido chamado obsessão primitiva.
Obsessão com primitivosUma fixação em primitivas se manifesta quando usamos um tipo de dados primitivo para representar um valor em um domínio de problema e permitir situações como as descritas acima. Se você representa códigos postais como números, números de telefone como sequências, Ns e lbfs como números de precisão dupla, é exatamente isso que acontece.
Seria muito mais seguro definir um tipo simples de
Ns
:
struct Ns { double value; }; bool operator<(const Ns& a, const Ns& b) { return a.value < b.value; }
Da mesma forma, você pode definir um tipo simples de
lbfs
:
struct lbfs { double value; }; bool operator<(const lbfs& a, const lbfs& b) { return a.value < b.value; }
Agora você pode implementar uma variante segura de tipo de
trajectory_correction
:
Se você chamar isso com
lbfs
, como no exemplo acima, o código simplesmente não será compilado devido à incompatibilidade de tipo:
void main() { trajectory_correction(lbfs{ 1.5 }); }
Observe como as informações do tipo de valor, normalmente indicadas nos comentários, (
2 /*Ns */, /* lbfs */
) agora são inseridas no sistema de tipos e expressas no código: (
Ns{ 2 }, lbfs{ 1.5 }
) .
Obviamente, é possível fornecer uma redução de
lbfs
para
Ns
na forma de um operador explícito:
struct lbfs { double value; explicit operator Ns() { return value * 4.448222; } };
Armado com esta técnica, você pode chamar
trajectory_correction
usando uma conversão estática:
void main() { trajectory_correction(static_cast<Ns>(lbfs{ 1.5 })); }
Aqui, a correção do código é obtida multiplicando por um coeficiente. Uma conversão também pode ser realizada implicitamente (usando a palavra-chave implícita); nesse caso, a conversão será aplicada automaticamente. Como regra empírica, você pode usar um dos coans do Python aqui:
Explícito é melhor que implícito
A moral dessa história é que, embora hoje tenhamos mecanismos de verificação de tipo muito inteligentes, eles ainda precisam fornecer informações suficientes para detectar esse tipo de erro. Essas informações entram no programa se declararmos tipos levando em consideração as especificidades de nossa área de assunto.
Espaço de estadoO problema ocorre quando um programa termina em um
estado ruim . Os tipos ajudam a restringir o campo para sua ocorrência. Vamos tentar tratar o tipo como o conjunto de valores possíveis. Por exemplo, bool é o conjunto
{true, false}
, no qual uma variável desse tipo pode receber um desses dois valores. Da mesma forma,
uint32_t
é o conjunto
{0 ...4294967295}
. Considerando os tipos dessa maneira, podemos definir o espaço de estados do nosso programa como o produto dos tipos de todas as variáveis vivas em um determinado momento.
Se tivermos uma variável do tipo
bool
e uma variável do tipo
uint32_t
, nosso espaço de estado será
{true, false} X {0 ...4294967295}
. Significa apenas que ambas as variáveis podem estar em qualquer estado possível para elas e, como temos duas variáveis, o programa pode terminar em qualquer estado combinado desses dois tipos.
Tudo se torna muito mais interessante se considerarmos as funções que inicializam os valores:
bool get_momentum(Ns& momentum) { if (!some_condition()) return false; momentum = Ns{ 3 }; return true; }
No exemplo acima, tomamos Ns por referência e inicializamos se alguma condição for atendida. A função retorna
true
se o valor foi inicializado corretamente. Se a função, por algum motivo, não puder definir o valor, ela retornará
false
.
Considerando essa situação do ponto de vista do espaço de estados, podemos dizer que o espaço de estados é um produto de
bool X Ns
. Se a função retornar verdadeira, significa que o impulso foi definido e é um dos valores possíveis de
Ns
. O problema é este: se a função retornar
false
, significa que o impulso não foi definido. De uma forma ou de outra, o momento pertence ao conjunto de valores possíveis de Ns, mas não é um valor válido. Muitas vezes, existem bugs nos quais o seguinte estado inaceitável acidentalmente começa a se espalhar:
void example() { Ns momenum; get_momentum(momentum); trajectory_correction(momentum); }
Em vez disso, basta fazer o seguinte:
void example() { Ns momentum; if (get_momentum(momentum)) { trajectory_correction(momentum); } }
No entanto, existe uma maneira melhor de fazer isso à força:
std::optional<Ns> get_momentum() { if (!some_condition()) return std::nullopt; return std::make_optional(Ns{ 3 }); }
Se você usar
optional
, o espaço de estados dessa função diminuirá significativamente: em vez de
bool X Ns
obtemos
Ns + 1
. Esta função retornará um
nullopt
Ns
ou
nullopt
válido para indicar nenhum valor. Agora, simplesmente não podemos ter um
Ns
inválido que se espalharia no sistema. Agora também é impossível esquecer o valor de retorno, pois o opcional não pode ser implicitamente convertido em
Ns
- precisaremos descompactá-lo especialmente:
void example() { auto maybeMomentum = get_momentum(); if (maybeMomentum) { trajectory_correction(*maybeMomentum); } }
Basicamente, nos esforçamos para que nossas funções retornem um resultado ou erro, em vez de resultado e erro. Assim, excluímos as condições em que temos erros e também estamos seguros de resultados inaceitáveis, que poderiam vazar para cálculos adicionais.
Desse ponto de vista, lançar exceções é normal, pois corresponde ao princípio descrito acima: uma função retornará um resultado ou lançará uma exceção.
RAIIRAII significa Aquisição de Recursos É Inicialização, mas em maior medida esse princípio está associado à liberação de recursos. O nome apareceu pela primeira vez em C ++, no entanto, esse padrão pode ser implementado em qualquer idioma (consulte, por exemplo,
IDisposable
from .NET). RAII fornece limpeza automática de recursos.
O que são recursos? Aqui estão alguns exemplos: memória dinâmica, conexões com o banco de dados, descritores do SO. Em princípio, um recurso é algo retirado do mundo exterior e sujeito a retorno depois que não precisamos mais dele. Retornamos o recurso usando a operação apropriada: libere-o, exclua-o, feche-o etc.
Como esses recursos são externos, eles não são expressos explicitamente em nosso sistema de tipos. Por exemplo, se selecionarmos um fragmento de memória dinâmica, obteremos um ponteiro pelo qual teremos que chamar
delete
:
struct Foo {}; void example() { Foo* foo = new Foo(); delete foo; }
Mas o que acontece se esquecermos de fazer isso ou algo nos impede de chamar
delete
?
void example() { Foo* foo = new Foo(); throw std::exception(); delete foo; }
Nesse caso, não chamamos mais de
delete
e temos um vazamento de recursos. Em princípio, essa limpeza manual de recursos é indesejável. Para memória dinâmica, temos
unique_ptr
para nos ajudar a gerenciá-la:
void example() { auto foo = std::make_unique<Foo>(); throw std::exception(); }
Nosso
unique_ptr
é um objeto de pilha; portanto, se ele
unique_ptr
escopo (quando a função lança uma exceção ou quando a pilha se desenrola quando uma exceção foi lançada), seu destruidor é chamado. É esse destruidor que implementa a chamada de
delete
. Portanto, não precisamos mais gerenciar o recurso de memória - transferimos esse trabalho para o wrapper, que é o proprietário e o responsável por seu lançamento.
Wrappers semelhantes existem (ou podem ser criados) para quaisquer outros recursos (por exemplo, OS HANDLE do Windows podem ser agrupados em um tipo, caso em que seu destruidor chamará
CloseHandle
).
A principal conclusão nesse caso é nunca fazer a limpeza manual dos recursos; Use o wrapper existente ou, se não houver um wrapper adequado para o seu cenário específico, nós o implementaremos.
ConclusãoIniciamos este artigo com um exemplo conhecido que demonstra a importância da digitação e examinamos três aspectos importantes do uso de tipos para ajudar a escrever código mais seguro:
- Declarar e usar tipos mais fortes (em oposição à obsessão por primitivos).
- Reduzindo o espaço de estado, retornando um resultado ou erro, não um resultado ou erro.
- RAII e gerenciamento automático de recursos.
Portanto, os tipos ajudam muito a tornar o código mais seguro e adaptá-lo para reutilização.