Tentando a programação de contratos C ++ 20 agora


No C ++ 20, a programação do contrato apareceu. Até o momento, nenhum compilador ainda implementou o suporte para esse recurso.


Mas agora existe uma maneira de tentar usar contratos do C ++ 20, conforme descrito no padrão.


TL; DR


Há um garfo que suporta contratos. Usando o exemplo dele, eu digo a você como usar contratos para que, assim que um recurso aparecer no seu compilador favorito, você possa começar a usá-lo imediatamente.


Muito já foi escrito sobre programação de contratos, mas, em poucas palavras, vou lhe dizer o que é e para que serve.


Lógica do Hoar


O paradigma dos contratos é baseado na lógica de Hoar ( 1 , 2 ).


A lógica Hoar é uma maneira de provar formalmente a correção de um algoritmo.
Opera com conceitos como pré-condição, pós-condição e invariáveis.
Do ponto de vista prático, o uso da lógica de Hoar é, em primeiro lugar, uma maneira de provar formalmente a correção de um programa nos casos em que erros podem levar a desastre ou perda de vidas. Em segundo lugar, uma maneira de aumentar a confiabilidade do programa, juntamente com análises e testes estáticos.


Programação de contratos


( 1 , 2 )


A idéia principal dos contratos é que, por analogia com os contratos nos negócios, os contratos sejam descritos para cada função ou método. Essas disposições devem ser observadas pelo chamador e pelo chamador.
Uma parte integrante dos contratos é de pelo menos dois modos de montagem - depuração e mercearia. Os contratos devem se comportar de maneira diferente, dependendo do modo de compilação. A prática mais comum é verificar contratos no conjunto de depuração e ignorá-los no supermercado.


Às vezes, os contratos também são verificados na montagem do produto e seu não cumprimento pode, por exemplo, levar à geração de uma exceção.


A principal diferença entre o uso de contratos da abordagem “clássica” é que o chamador deve cumprir as pré-condições do chamado, descritas no contrato, e o chamador deve cumprir suas pós-condições e invariantes.
Consequentemente, a parte chamada não é obrigada a verificar a correção de seus parâmetros. Esta obrigação é atribuída ao chamador pelo contrato.


O não cumprimento de contratos deve ser detectado no estágio de teste e complementa todos os tipos de testes: integração modular, etc.


À primeira vista, o uso de contratos dificulta o desenvolvimento e diminui a legibilidade do código. De fato, o exato oposto é verdadeiro. Os adeptos da tipagem estática acharão mais fácil avaliar os benefícios dos contratos, porque sua opção mais simples é descrever tipos na assinatura de métodos e funções.


Então, quais são os benefícios dos contratos:


  • Melhore a legibilidade do código através de documentação explícita.
  • Melhore a confiabilidade do código complementando os testes.
  • Permita que os compiladores usem otimizações de baixo nível e gerem código mais rápido com base na conformidade do contrato. Neste último caso, o não cumprimento do contrato na montagem de liberação pode levar à UB.

Programação de contrato em C ++


A programação de contratos é implementada em vários idiomas. Os exemplos mais impressionantes são Eiffel , onde o paradigma foi implementado pela primeira vez, e D , em D, os contratos fazem parte da linguagem.


No C ++, antes do padrão C ++ 20, os contratos podiam ser usados ​​como bibliotecas separadas.


Essa abordagem tem várias desvantagens:


  • Sintaxe muito desajeitada usando macros.
  • A falta de um estilo único.
  • Incapacidade de usar contratos pelo compilador para otimizar o código.

As implementações de bibliotecas geralmente são baseadas no uso das boas e antigas diretivas de declaração e pré-processador que verificam o sinalizador de compilação.


O uso de contratos dessa forma realmente torna o código feio e ilegível. Essa é uma das razões pelas quais o uso de contratos em C ++ é pouco praticado.


No futuro, mostrarei como será o uso de contratos em C ++ 20.
E então, analisaremos tudo isso com mais detalhes:


int f(int x, int y) [[ expects: x > 0 ]] // precondition [[ expects: y > 0 ]] // precondition [[ ensures r: r < x + y ]] // postcondition { int z = (x - x%y) / y; [[ assert: z >= 0 ]]; // assertion return z + y; } 

Experimente


Infelizmente, no momento, nenhum dos compiladores amplamente usados ​​ainda implementou o suporte ao contrato.
Mas há uma saída.


O grupo de pesquisa ARCOS da Universidade Carlos III de Madri implementou suporte experimental para contratos no garfo clang ++.


Para não "escrever código em um pedaço de papel", mas para tentar imediatamente novas oportunidades de negócios, podemos coletar esse garfo e usá-lo para experimentar os exemplos abaixo.


As instruções de montagem são descritas no leia-me do repositório do github
https://github.com/arcosuc3m/clang-contracts


 git clone https://github.com/arcosuc3m/clang-contracts/ mkdir -p clang-contracts/build/ && cd clang-contracts/build/ cmake -G "Unix Makefiles" -DLLVM_USE_LINKER=gold -DBUILD_SHARED_LIBS=ON -DLLVM_USE_SPLIT_DWARF=ON -DLLVM_OPTIMIZED_TABLEGEN=ON ../ make -j8 

Não tive problemas durante a montagem, mas compilar as fontes leva muito tempo.


Para compilar os exemplos, você precisará especificar explicitamente o caminho para o binário clang ++.
Por exemplo, parece algo assim para mim


 /home/valmat/work/git/clang-contracts/build/bin/clang++ -std=c++2a -build-level=audit -g test.cpp -o test.bin 

Eu preparei exemplos para torná-lo conveniente para você examinar contratos usando exemplos de código real. Sugiro que, antes de começar a ler a próxima seção, clone e compile exemplos.


 git clone https://github.com/valmat/cpp20-contracts-examples/ cd cpp20-contracts-examples make CPP=/path/to/clang++ 

Aqui /path/to/clang++ caminho para o binário clang++ do seu conjunto de compilador experimental.


Além do próprio compilador, o grupo de pesquisa ARCOS preparou sua versão do Compiler Explorer para seu fork.


Programação de contrato em C ++ 20


Agora, nada nos impede de começar a pesquisar as possibilidades oferecidas pela programação de contratos e tentar imediatamente essas oportunidades na prática.


Como mencionado acima, os contratos são construídos a partir de pré-condições, pós-condições e invariantes (declarações).


No C ++ 20, atributos com a seguinte sintaxe são usados ​​para esse


 [[contract-attribute modifier identifier: conditional-expression]] 

Onde o contract-attribute pode assumir um dos seguintes valores:
espera , garante ou afirma .


expects usado para pré-condições, ensures pós-condições e assert para instruções.


conditional-expression é uma expressão booleana que é validada em um predicado de contrato.
modifier e identifier podem ser omitidos.


Por que preciso de um modifier , escreverei um pouco mais baixo.


identifier usado apenas com ensures e é usado para representar o valor de retorno.


As pré-condições têm acesso aos argumentos.


As pós-condições têm acesso ao valor retornado pela função. A sintaxe é usada para isso.


 [[ensures return_variable: expr(return_variable)]] 

Onde return_variable qualquer expressão válida para a variável.


Em outras palavras, as pré-condições pretendem declarar restrições impostas aos argumentos aceitos pela função e as pós-condições para declarar restrições impostas ao valor retornado pela função.


Acredita-se que pré - condições e pós - condições façam parte da interface da função, enquanto declarações fazem parte de sua implementação.


Os predicados de pré-condição são sempre avaliados imediatamente antes da execução da função. As pós-condições são satisfeitas imediatamente após a função de controle passar para o código de chamada.


Se uma exceção for lançada em uma função, a pós-condição não será verificada.
As pós-condições serão verificadas apenas se a função for concluída normalmente.


Se uma exceção ocorreu durante a verificação da expressão no contrato, std::terminate() será chamado.


As pré-condições e pós-condições são sempre descritas fora do corpo da função e não podem ter acesso a variáveis ​​locais.


Se pré-condições e pós-condições descrevem um contrato para um método de classe pública, eles não podem ter acesso aos campos de classe privada e protegida. Se o método da classe estiver protegido, haverá acesso aos dados públicos e protegidos da classe, mas não ao privado.
A última limitação é completamente lógica, dado que o contrato faz parte da interface do método.


As declarações (invariantes) são sempre descritas no corpo de uma função ou método. Por design, eles fazem parte da implementação. E, consequentemente, eles podem ter acesso a todos os dados disponíveis. Incluindo variáveis ​​de função local e campos de classe privada e protegida.


exemplo 1


Definimos duas pré-condições, uma pós-condição e uma invariante:


 int foo(int x, int y) [[ expects: x > y ]] // precondition #1 [[ expects: y > 0 ]] // precondition #2 [[ ensures r: r < x ]] // postcondition #3 { int z = (x - x%y) / y; [[ assert: z >= 0 ]]; // assertion return z; } int main() { std::cout << foo(117, 20) << std::endl; std::cout << foo(10, 20) << std::endl; // <-- contract violation #1 std::cout << foo(100, -5) << std::endl; // <-- contract violation #2 return 0; } 

exemplo 2


Uma pré-condição de um método público não pode se referir a um campo protegido ou privado:


 struct X { //protected: int m = 5; public: int foo(int n) [[expects: n < m]] { return n*n; } }; 

A modificação de variáveis ​​nas expressões descritas pelos atributos do contrato não é permitida. Se estiver quebrado, haverá UB.


As expressões descritas nos contratos não devem ter efeitos colaterais. Embora os compiladores possam verificar isso, eles não são obrigados a fazê-lo. A violação deste requisito é considerada um comportamento indefinido.


 struct X { int m = 5; int foo(int n) [[ expects: n < m++ ]] // UB: Modifies variable m { int k = n*n; [[ assert: ++k < 100 ]] // UB: Modifies variable k return n*n; } }; 

O requisito de não alterar o estado do programa nas expressões de contrato se tornará óbvio um pouco menor quando falamos sobre os níveis de modificadores de contrato e modos de construção.


Agora, apenas observo que o programa correto deve funcionar como se não houvesse contratos.


Como observei acima, no contrato você pode especificar quantas condições e pós-condições desejar.
Todos eles serão verificados em ordem. Porém, as pré-condições são sempre verificadas antes da execução da função e pós-condições imediatamente após a saída.


Isso significa que as pré-condições são sempre verificadas primeiro, conforme ilustrado no exemplo a seguir:


 int foo(int n) [[ expects: expr(n) ]] // # 1 [[ ensures r: expr(r) ]] // # 4 [[ expects: expr(n) ]] // # 2 [[ expects: expr(n) ]] // # 3 [[ ensures r: expr(r) ]] // # 5 {...} 

Expressões em pós-condições podem se referir não apenas ao valor retornado pela função, mas também aos argumentos da função.


 int foo(int &n) [[ ensures: expr(n) ]]; 

Nesse caso, você pode omitir o identificador de valor de retorno.


Se a pós-condição se referir ao argumento da função, esse argumento será considerado no ponto de saída da função e não no ponto de entrada, como é o caso das pré-condições.


Não há como se referir ao valor original (no ponto de entrada da função) na pós-condição.


exemplo :


 void incr(int &n) [[ expects: 3 == n ]] [[ ensures: 4 == n ]] {++n;} 

Predicados em contratos podem se referir a variáveis ​​locais apenas se o tempo de vida dessas variáveis ​​corresponder ao tempo de cálculo predicado.


Por exemplo, para funções constexpr , as variáveis ​​locais não podem ser referenciadas, a menos que sejam conhecidas no momento da compilação.


exemplo :


 int a = 1; constexpr int b = 100; constexpr int foo(int n) [[ expects: a <= n ]] // error: `a` is not constexpr [[ expects: n < b ]] // OK { [[assert: n > 2*a]]; // error: `a` is not constexpr [[assert: n < 2*b]]; // OK return 2*n; } 

Contratos para ponteiros de função


Você não pode definir contratos para um ponteiro de função, mas pode atribuir o endereço de uma função para a qual um contrato está definido para um ponteiro de função.


exemplo :


 int foo(int n) [[expects: n < 10]] { return n*n; } int (*pfoo)(int n) = &foo; 

Ligar para pfoo(100) violará o contrato.


Contratos de herança


A implementação clássica do conceito de contratos sugere que as pré-condições podem ser enfraquecidas nas subclasses, pós-condições e invariantes podem ser fortalecidas nas subclasses.


Em uma implementação C ++ 20, esse não é o caso.


Primeiro, os invariantes em C ++ 20 fazem parte de uma implementação, não de uma interface. Por esse motivo, eles podem ser fortalecidos e enfraquecidos. Se não houver assert na implementação da função virtual, ela não será herdada.


Em segundo lugar, é necessário que, ao herdar as funções, o ODR seja idêntico.
E, como pré-condições e pós-condições fazem parte da interface, no herdeiro elas devem corresponder exatamente.


Além disso, a descrição de pré-condições e pós-condições durante a herança pode ser omitida. Mas se forem declarados, deverão corresponder exatamente à definição na classe base.


exemplo :


 struct Base { virtual int foo(int n) [[ expects: n < 10 ]] [[ ensures r: r > 100 ]] { return n*n; } }; struct Derived1 : Base { virtual int foo(int n) override [[ expects: n < 10 ]] [[ ensures r: r > 100 ]] { return n*n*2; } }; struct Derived2 : Base { // Inherits contracts from Base virtual int foo(int n) override { return n*3; } }; 

Observação

Infelizmente, o exemplo acima não funciona no compilador experimental conforme o esperado.


Se foo de Derived2 contrato, ele não será herdado da classe base. Além disso, o compilador permite determinar para uma subclasse um contrato que não corresponde ao contrato base.


Outro erro experimental do compilador:


o registro deve estar sintaticamente correto


 virtual int foo(int n) override [[expects: n < 10]] {...} 

No entanto, neste formulário, recebi um erro de compilação


 inheritance1.cpp:20:36: error: expected ';' at end of declaration list virtual int foo(int n) override ^ ; 

e teve que ser substituído por


 virtual int foo(int n) [[expects: n < 10]] override {...} 

Acho que isso se deve à peculiaridade do compilador experimental, e o código correto de sintaxe funcionará nas versões de lançamento dos compiladores.


Modificadores de contrato


As verificações de predicado do contrato podem incorrer em custos adicionais de processamento.
Portanto, uma prática comum é verificar contratos em desenvolvimento e testar compilações e ignorá-los na compilação de liberação.


Para esses fins, o padrão oferece três níveis de modificadores de contrato. Usando modificadores e chaves do compilador, o programador pode controlar quais contatos são verificados na montagem e quais são ignorados.


  • default - esse modificador é usado por padrão. Supõe-se que o custo computacional da verificação da execução de uma expressão com esse modificador seja pequeno comparado ao custo da computação da própria função.
  • audit - esse modificador assume que o custo computacional da verificação da execução de uma expressão é significativo comparado ao custo da computação da própria função.
  • axiom - esse modificador é usado se a expressão for declarativa. Não verificado em tempo de execução. Serve para documentar a interface de uma função, para uso por analisadores estáticos e um otimizador de compilador. Expressões com o modificador de axiom nunca axiom avaliadas em tempo de execução.

Exemplo


 [[expects: expr]] //  default [[expects default: expr]] //  default [[expects axiom : expr]] // Run-time    [[expects audit : expr]] //    

Usando modificadores, você pode determinar quais verificações em quais versões de seus assemblies serão usadas e quais serão desabilitadas.


Vale ressaltar que, mesmo que a verificação não seja realizada, o compilador tem o direito de usar o contrato para otimizações de baixo nível. Embora a verificação do contrato possa ser desabilitada pelo sinalizador de compilação, a violação do contrato leva a um comportamento indefinido do programa.


A critério do compilador, podem ser fornecidas instalações para permitir a axiom expressões marcadas como axiom .


No nosso caso, esta é uma opção do compilador


 -axiom-mode=<mode> 

-axiom-mode=on ativa o modo axioma e, consequentemente, desativa a verificação de reivindicações com o axiom identificador,


-axiom-mode=off o modo axioma e, consequentemente, permite a verificação de instruções com o axiom identificador.


exemplo :


 int foo(int n) [[expects axiom: n < 10]] { return n*n; } 

Um programa pode ser compilado com três níveis diferentes de verificação:


  • off desativa todas as verificações de expressão em contratos
  • default apenas expressões com o modificador default são verificadas
  • modo avançado de audit quando todas as verificações são executadas com o modificador default e de audit

A maneira exata de implementar a instalação do nível de verificação fica a critério dos desenvolvedores do compilador.


No nosso caso, a opção do compilador é usada para esse


 -build-level=<off|default|audit> 

O padrão é -build-level=default


Como já mencionado, o compilador pode usar contratos para otimizações de baixo nível. Por esse motivo, apesar de, no momento da execução, alguns predicados nos contratos (dependendo do nível de verificação) não poderem ser calculados, seu não cumprimento leva a um comportamento indefinido.


Adiarei exemplos da aplicação de níveis de montagem até a próxima seção, onde eles podem ser visualizados.


Interceptação de quebra de contrato


Dependendo de quais opções o programa está indo, em caso de quebra de contrato, pode haver diferentes cenários de comportamento.


Por padrão, uma quebra do contrato leva à falha do programa, uma chamada para std::terminate() . Mas o programador pode substituir esse comportamento fornecendo seu próprio manipulador e indicando ao compilador que é necessário continuar o programa após a violação do contrato.


Na compilação, você pode instalar o manipulador de violações , chamado quando o contrato é violado.


A maneira de implementar a instalação do manipulador fica a critério dos criadores do compilador.


No nosso caso, isso


 -contract-violation-handler=<violation_handler> 

A assinatura do processador deve ser


 void(const std::contract_violation& info) 

ou


 void(const std::contract_violation& info) noexcept 

std::contract_violation equivalente à seguinte definição:


 struct contract_violation { uint_least32_t line_number() const noexcept; std::string_view file_name() const noexcept; std::string_view function_name() const noexcept; std::string_view comment() const noexcept; std::string_view assertion_level() const noexcept; }; 

Assim, o manipulador permite que você obtenha informações bastante abrangentes sobre exatamente onde e sob quais condições ocorreu uma violação do contrato.


Se o manipulador do manipulador de violações for especificado, no caso de uma violação do contrato, por padrão, std::abort() será chamado imediatamente após sua execução (sem especificar o manipulador, std::terminate() será chamado).


O padrão pressupõe que os compiladores fornecem ferramentas que permitem que os programadores continuem executando um programa após quebra de contrato.


A maneira de implementar essas ferramentas depende dos desenvolvedores do compilador.
No nosso caso, esta é uma opção do compilador


 -fcontinue-after-violation 

As -fcontinue-after-violation e -contract-violation-handler podem ser definidas independentemente uma da outra. Por exemplo, você pode definir -fcontinue-after-violation , mas não pode definir -contract-violation-handler . Neste último caso, após a quebra do contrato, o programa simplesmente continuará funcionando.


A capacidade de continuar o programa após uma violação do contrato é especificada pelo padrão, mas é necessário ter cuidado com esse recurso.


Tecnicamente, o comportamento de um programa após quebra de contrato não é definido, mesmo que o programador tenha explicitamente indicado que o programa deve continuar funcionando.


Isso ocorre porque o compilador é capaz de executar otimizações de baixo nível com base na execução do contrato.


Idealmente, se ocorrer uma quebra de contrato, você precisará registrar as informações de diagnóstico o mais rápido possível e encerrar o programa. Você precisa entender exatamente o que está fazendo, permitindo que o programa funcione após a violação.


Defina seu manipulador e use-o para interceptar uma quebra de contrato


 void violation_handler(const std::contract_violation& info) { std::cerr << "line_number : " << info.line_number() << std::endl; std::cerr << "file_name : " << info.file_name() << std::endl; std::cerr << "function_name : " << info.function_name() << std::endl; std::cerr << "comment : " << info.comment() << std::endl; std::cerr << "assertion_level : " << info.assertion_level() << std::endl; } 

E considere um exemplo de quebra de contrato:


 #include "violation_handler.h" int foo(int n) [[expects: n < 10]] { return n*n; } int main() { foo(100); // <-- contract violation return 0; } 

-contract-violation-handler=violation_handler o programa com as opções -contract-violation-handler=violation_handler -fcontinue-after-violation e -fcontinue-after-violation e executamos


 $ bin/example8-handling.bin line_number : 4 file_name : example8-handling.cpp function_name : foo comment : n < 10 assertion_level : default 

Agora podemos dar exemplos demonstrando o comportamento do programa em caso de quebra do contrato em diferentes níveis de montagem e modos de contrato.


Considere o seguinte exemplo :


 #include "violation_handler.h" int foo(int n) [[ expects axiom : n < 100 ]] [[ expects default : n < 200 ]] [[ expects audit : n < 300 ]] { return 2 * n; } int main() { foo(350); // audit foo(250); // default return 0; } 

Se você construí-lo com a opção -build-level=off , conforme o esperado, os contratos não serão verificados.


Reunindo com o nível default (com a opção -build-level=default ), obtemos a seguinte saída:


 $ bin/example9-default.bin line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default 

E a montagem com o nível de audit fornecerá:


  $ bin/example9-audit.bin line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default line_number : 6 file_name : example9.cpp function_name : foo comment : n < 300 assertion_level : audit line_number : 5 file_name : example9.cpp function_name : foo comment : n < 200 assertion_level : default 

Observações


violation_handler pode lançar exceções. Nesse caso, você pode configurar o programa para que a violação do contrato leve ao lançamento de uma exceção.


Se a função cujos contratos forem descritos estiver marcada como noexcept e ao verificar o contrato noexcept violation_handler chamado, o que gera uma exceção, std::terminate() será chamado.


Exemplo


 void violation_handler(const std::contract_violation&) { throw std::exception(); } int foo(int n) noexcept [[ expects: n > 0 ]] { return n*n; } int main() { foo(0); // <-- std::terminate() when violation handler throws an exception return 0; } 

Se o sinalizador for passado para o compilador: não continue executando o programa após a quebra do contrato ( continuation mode=off ), mas o manipulador de violações lança uma exceção, então std::terminate() será forçado.


Conclusão


Os contratos referem-se a verificações não intrusivas de tempo de execução. Eles desempenham um papel muito importante para garantir a qualidade do software lançado.


C ++ é usado muito amplamente. E com certeza haverá um número suficiente de reivindicações para a especificação de contratos. Na minha opinião subjetiva, a implementação acabou sendo bastante conveniente e visual.


Os contratos C ++ 20 tornarão nossos programas ainda mais confiáveis, rápidos e compreensíveis. Aguardo com expectativa a sua implementação em compiladores.




PS
No PM, eles me dizem que provavelmente na versão final do padrão expects e ensures que ensures substituídos por pre e post , respectivamente.

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


All Articles