Modularizando Componentes com C++20

Published by

on

C++20 trouxe grandes, muito aguardadas e altamente controversas novidades para a linguagem. Neste artigo, vamos o novo mecanismo de módulos através de uma aplicação prática, a transformação dos componentes de um projeto pré-existente em módulos.

Este artigo examinará um projeto imaginário baseado em uma experiência real usando a ferramenta Visual Studio 2022. O resultado será específico do Visual Studio 2022, sem pretensão de “portabilidade” entre ferramentas. As partes do projeto serão o mais típicas que for possível para esta ferramenta, com alterações mínimas a partir do convencional.

Meu negócio é fornecer componentes, onde a manutenção de ABI e API é um fator crítico para o sucesso de qualquer nova versão. Toda quebra de ABI ou API é uma barreira de adoção com o potencial de condenar uma nova versão a jamais ser adotada. Quebrar ABI e API são decisões tomáveis porém somente após profunda deliberação sobre prós, contras e circunstâncias.

Assim, em um exercício como esse, nossa primeira e principal pergunta é: como nós podemos fazer para acrescentar a nova característica preservando ABI e API?

Projeto Exemplar

Nossos produtos possuem uma arquitetura componentizada. Em nosso primeiro exercício, buscamos aplicar o novo mecanismo em componentes internos do nosso projeto. Assim, pudemos avaliar diretamente o impacto da mudança sobre a aplicação, que nós mesmos construímos.

Neste artigo, vamos estudar um projeto exemplar com três componentes: um executável, uma biblioteca estática e uma biblioteca dinâmica. O executável não é interessante e serve apenas como medidor para a manutenção de compatibilidade, que avaliaremos remontando ou recompilando.

Estes projetos foram gerados pelos “wizards” do Visual Studio 2022 e minimamente configurados: “C++ Language Standard” foi ajustado para “ISO C++ 17 Standard”. Nas bibliotecas, “Public Include Directories” foi ajustado para “$(ProjectDir)”. Os arquivos foram obviamente alterados para incluir as definições necessárias. O projeto executável liga-se às bibliotecas pelo mecanismo de referência entre projetos. A biblioteca dinâmica usa a técnica costumeira de definir uma macro FOO_API para marcar os símbolos públicos com __declspec(dllexport) ao construir e __declspec(dllimport) ao consumir.

Este projeto inicial está disponível no branch main do repositório: https://github.com/pedrolamarao/cxx-modules-exercise

Preparando para Módulos

A primeira coisa a fazer é atualizar a versão da linguagem do projeto para C++20 e corrigir as eventuais falhas de construção devido incompatibilidades.

Em seguida, vamos introduzir um module interface unit em cada biblioteca. Por exemplo, na biblioteca estática, criamos o arquivo bar.ixx com o seguinte conteúdo.

export module bar;

Construir o projeto neste momento fracassa com este erro: unexpected end of file while looking for precompiled header

O Visual Studio 2022 não faz diferença entre unidades tradicionais e unidades de módulos com relação ao mecanismo de precompiled header. Para prosseguir rapidamente, incluímos a precompiled header no global module fragment.

module;

#include "pch"

export module bar;

Construir o projeto neste momento deve concluir com sucesso. Observe que nenhuma mudança foi aplicada no projeto executável e que este projeto continua construindo e executando como antes. A introdução de um module interface unit por si só não introduz incompatibilidade.

Vamos experimentar importar módulos no executável. Primeiro, atualizamos a versão da linguagem para C++20. Em seguida, introduzimos import no código-fonte.

// static library
#include <device.h>
import bar;

// dynamic library
#include <object.h>
import foo;

Construir o projeto neste momento deve fracassar com este erro: could not find module 'foo'

Isto é uma consequência das convenções do Visual Studio 2022. A configuração de bibliotecas estáticas considera todos os módulos como públicos enquanto as de bibliotecas dinâmicas considera todos os módulos como privados. Para ajustar esta configuração do projeto, mude o valor de “All Modules are Public”. Alternativamente, defina “Public C++ Module Directories” para “$(ProjectDir)”.

Aplicando o ajuste necessário, construir o projeto deve concluir com sucesso. A partir deste ponto, podemos definir variáveis, funções, classes e os demais elementos do C++ no módulo e consumir estas coisas no executável.

O código-fonte alterado para incluir módulos vazios está no branch empty-modules do repositório: https://github.com/pedrolamarao/cxx-modules-exercise

bar.ixx

// ...

export module bar;

export namespace bar
{
  char const * hello () { return "Hello World!\n"; }
}

executable.cpp

// ...

import bar;

// ...

int main (int argc, char* argv)
{
  fprintf(stdout, "%s\n", bar::hello());

// ...

Construir o projeto neste momento deve concluir com sucesso. A palavra-chave export define quais elementos devem ser exportados pelo módulo. Neste caso estamos usando export no bloco inteiro.

Exportar Diretiva Using não Existe

Nesta etapa, nós assumimos que seria possível simplesmente tomar nossa API pré-existente, trazida via #include, e exportá-la a partir do módulo. A final, nós podemos trazer nossos #include para o global module fragment e usar export em namespaces. Para a biblioteca estática, a expectativa era essa.

module;

#include "pch.h"
#include "device.h"

export module bar;

export namespace bar;

Infelizmente construir isso fracassa com o erro — syntax error: ';' — no final da última linha. Realmente, esta construção não existe em C++. Algo parecido com isso seria uma diretiva using.

module;

#include "pch.h"
#include "device.h"

export module bar;

export using namespace bar;

Construir o projeto neste momento deve concluir com sucesso.

Assumindo que agora todos os elementos do namespace bar estão exportados pelo módulo, vamos trocar o #include <device.h> do arquivo executable.cpp por uma declaração import.

// static library
// #include <device.h> -- disabled
import bar;

// ...

Construir o projeto neste momento deve fracassar com diversos erros típicos de nome não encontrado, em particular: 'bar' is not a class or namespace name

Ocorre que não existe exportar uma diretiva using.

Mes e export namespace bar { }? Isto não exporta a totalidade dos elementos do namespace bar? Não, não exporta. O que isso faz é exportar estes elementos que estão declarados aqui neste bloco, que por acaso estão sendo declarados no namespace bar.

Exportar Declaração Using Existe, Mas…

Consultando a documentação, descobrimos que não existe exportar uma diretiva using mas existe exportar uma declaração using.

module;

#include "pch.h"
#include "device.h"

export module bar;


export using bar::family;
export using bar::file;
export using bar::protocol;
export using bar::socket;

Construir o projetos da biblioteca estática neste momento completa com sucesso, mas, construir o projeto do executável fracassa com o erro: 'bar': is not a class or namespace name

Forçamos a barra exportando explicitamente o namespace bar do módulo.

module;

#include "pch.h"
#include "device.h"

export module bar;

export namespace bar { };
export using bar::family;
export using bar::file;
export using bar::protocol;
export using bar::socket;

Construir o projeto executável neste momento fracassa com o erro: 'file': is not a member of 'bar'

Nossa intuição sobre o significado de export está muito incorreta. Pensando bem sobre este C++, aparte a presença de export, a declaração using está trazendo bar::family etc. para o namespace global.

Exportar uma Redeclaração Using Existe

Vamos tentar redeclarar os mesmos nomes no mesmo namespace.

module;

#include "pch.h"
#include "device.h"

export module bar;

export namespace bar 
{
    using ::bar::family;
    using ::bar::file;
    using ::bar::protocol;
    using ::bar::socket;
};

Construir os projetos neste momento completa com sucesso e o executável dá o resultado esperado. Não sei por quê isso funciona.

Vamos aplicar a mesma técnica na biblioteca dinâmica.

module;

#include "pch.h"
#include "object.h"

export module foo;

export namespace foo
{
    using ::foo::object;
    using ::foo::make_null;
    using ::foo::make_string;
    using ::foo::make_number;
}

Construir os projetos neste momento completa com fracasso: syntax error: identifier 'FILE'

Este é o efeito de uma das principais características de uma module interface: não vazar o estado do pré-processador. Os cabeçalhos incluídos no global module fragment do arquivo foo.ixx contribuem nada para o cliente deste módulo. É preciso portanto acrescentar diretivas #include para os cabeçalhos que o cliente necessita e usava implicitamente. Neste caso, é preciso incluir <cstdio> e <memory> em executable.cpp.

Problemas com internal linkage

Após fazer este ajuste, construir os projetos completa com fracasso: static function 'std::shared_ptr foo::make_null(void)' declared but not defined

Consultando os fontes, vemos que make_null está declarado como static inline, ou seja, com internal linkage. Este é um truque típico para forçar a otimização chamada inlining: a definição inserida via pré-processador em executable.cpp será considerada como tendo internal linkage justamente ali.

O erro é um dos efeitos colaterais dos módulos: não vazar elementos com internal linkage. Por natureza, internal linkage significa uma ligação privada entre elementos de uma unidade. Ao incluir esta definição em um module interface unit, ela estará disponível nesta unidade, mas não vazará para fora.

É preciso providenciar uma definição com external linkage.

Vamos tentar redefinir make_null no módulo.

module;

#include "pch.h"
#include "object.h"

export module foo;

export namespace foo
{
    using ::foo::object;
    using ::foo::make_string;
    using ::foo::make_number;

    std::shared_ptr<object> make_null () { return std::make_shared<foo::null_object>(); }
}

Construir o projeto neste momento completa com fracasso: function 'std::shared_ptr foo::make_null(void)' already has a body

Providenciar esta definição em object.cpp dá o mesmo resultado.

É concebivel que criar uma nova unidade tradicional, não incluir <object.h> e de alguma maneira redefinir make_null pelas costas do compilador vá funcionar. Isso significa um aumento drástico no custo de manutenção, o que nos solicita reavaliar as nossas diretrizes.

Qual é o impacto na ABI e na API de alterar a linkage de make_null retirando static e acrescentando DYNAMIC_LIBRARY_API?

Um novo símbolo será adicionado à lista de símbolos exportados por foo.dll.

A avaliação da expressão (& foo::make_null) que toma o endereço de make_null dará um resultado diferente: anteriormente este seria um endereço na imagem de executable.exe, agora será um endereço na imagem de foo.dll.

Como nós consideramos estas mudanças como compatíveis, ficamos satisfeitos com a técnica desenvolvida, que adiciona suporte a módulos em um projeto pré-existente com mínimo impacto na manutenção e nenhuma quebra de compatibilidade.

O código-fonte com os módulos completos está no branch modules do repositório: https://github.com/pedrolamarao/cxx-modules-exercise

Para finalizar, preciso deixar claro que esta técnica me parece na realidade muito estranha e eu não descartaria que trata-se de um exploit de um defeito no Visual Studio 2022. Espero poder em breve verificar se a mesma técnica funciona no Clang.

Deixe um comentário