Linguagem C++ / Módulo 2: Gerenciamento de Pacotes / Incluindo uma Biblioteca Manualmente

Incluindo uma Biblioteca Manualmente

Dois formatos, dois problemas diferentes

No capítulo anterior, vimos que uma biblioteca em C++ normalmente chega até você de duas formas bem diferentes: pronta para usar sem compilação própria, ou como código-fonte que precisa ser compilado antes de qualquer coisa. Vamos ver os dois casos na prática, sem nenhum gerenciador de pacotes envolvido — só o g++ e, depois, o CMake.

Caso 1: biblioteca header-only

Algumas bibliotecas são distribuídas inteiramente dentro de arquivos de header, sem nenhum arquivo .cpp próprio para compilar separadamente. Isso é possível porque as funções podem ser definidas inteiramente no header. Uma biblioteca assim é chamada de header-only.

O nlohmann/json é um bom exemplo: uma biblioteca inteira para trabalhar com JSON, distribuída como um único arquivo json.hpp. Vale um cuidado aqui: o repositório tem vários arquivos .hpp espalhados pelas pastas internas do código-fonte (usados durante o desenvolvimento da própria biblioteca), mas o arquivo que você quer é o já pronto para distribuição, em single_include/nlohmann/json.hpp. Ele reúne tudo o que a biblioteca precisa em um só lugar — por isso hoje passa de 26 mil linhas e quase 1 MB, mesmo sendo "só um header".

Baixado esse arquivo específico, basta colocá-lo em uma pasta de terceiros dentro do seu projeto:

meu-projeto/
├── main.cpp
└── third_party/
    └── nlohmann/
        └── json.hpp

Com o header no lugar, o main.cpp já pode incluí-lo e usar a biblioteca normalmente:

#include <iostream>
#include <nlohmann/json.hpp>
 
int main() {
    nlohmann::json config;
    config["nome"] = "programa";
    config["versao"] = 1;
 
    std::cout << config.dump() << "\n";
    return 0;
}

O problema é que #include <nlohmann/json.hpp> não vai funcionar de primeira. O compilador procura headers em um conjunto de diretórios padrão do sistema, e third_party/ não é um deles — é preciso dizer ao g++ onde mais procurar. É para isso que existe a flag -I (de include):

g++ -Ithird_party main.cpp -o programa

-Ithird_party adiciona a pasta third_party à lista de lugares onde o compilador procura por headers. Como json.hpp está em third_party/nlohmann/json.hpp, o #include <nlohmann/json.hpp> do código passa a ser encontrado a partir dali. Repare que não existe nenhuma etapa de linkagem específica para essa biblioteca — como é header-only, o código dela já foi compilado junto com o main.cpp na própria etapa de compilação, e não sobra nenhum símbolo pendente para o linker resolver depois.

Traduzindo isso para o CMakeLists.txt, usando o que já vimos no capítulo de build sobre targets:

cmake_minimum_required(VERSION 3.20)
project(MeuProjeto VERSION 1.0)
 
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
 
add_executable(programa main.cpp)
target_include_directories(programa PRIVATE third_party)

Nada aqui é novo — é o mesmo CMakeLists.txt básico do capítulo de build, com uma única linha a mais: target_include_directories(programa PRIVATE third_party). Ela faz exatamente o mesmo trabalho que a flag -I fazia manualmente, só que anexado ao target programa, em vez de precisar lembrar da flag toda vez que for compilar. O fluxo de compilação também é o mesmo de sempre:

mkdir build && cd build
cmake -G Ninja ..
cmake --build .
./programa

Caso 2: biblioteca que precisa ser compilada

Nem toda biblioteca pode ser só um header. Bibliotecas maiores ou mais complexas costumam ter arquivos .cpp próprios, que precisam ser compilados em um binário antes de qualquer projeto poder linkar contra eles.

O fmt é um exemplo real desse caso — uma biblioteca de formatação de texto, com headers e também arquivos-fonte próprios (src/format.cc, entre outros) que compõem a implementação. Ela existe porque as opções que já vêm com o C++ para formatar texto — o printf do <cstdio> e o std::cout do <iostream> — têm limitações conhecidas: o printf não é type-safe (o compilador não verifica se o tipo do argumento bate com o especificador de formato, um erro clássico de C), e o <iostream> costuma ser mais lento em tempo de execução, além de deixar o código mais verboso para formatações mais elaboradas. O fmt resolve os dois problemas — é type-safe como o <iostream>, mas com desempenho comparável (ou melhor) do que o printf, o que o tornou popular o suficiente para inclusive inspirar a adição de std::format ao próprio C++20, com uma API (Application Programming Interface - conjunto de funções, classes, tipos que a biblioteca expõe para o programador) muito parecida a da biblioteca FMT.

Depois de clonar o repositório para dentro de third_party/fmt, vamos deixar o nosso main.cpp assim:

#include <fmt/core.h>
 
int main() {
    std::string nome = "programa";
    int versao = 1;
 
    fmt::print("{} (v{})\n", nome, versao);
    return 0;
}

Diferente do caso anterior, esse #include <fmt/core.h> não é suficiente sozinho — o fmt::print está declarado no header, mas implementado em src/format.cc. Vale ver isso na prática, tentando compilar e linkar o main.cpp somente ao header fmt/core.h:

g++ -Ithird_party/fmt/include main.cpp -o programa
/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
main.cpp:(.text+0x2e): undefined reference to `fmt::v10::vprint(fmt::v10::basic_string_view<char>, fmt::v10::format_args)'
collect2: error: ld returned 1 exit status

O compilador não reclama de nada — o #include encontrou o header, e todo o código compilou sem erro. O problema aparece só na etapa de linkagem, e a mensagem é bem específica: undefined reference, referência não definida. Ou seja: o compilador sabe que fmt::print existe (porque leu a declaração no header) e gerou uma chamada para ela, mas o linker não encontrou em lugar nenhum o código de verdade daquela função — porque ele ainda não foi compilado.

O primeiro passo, então, é compilar essa fonte em um arquivo objeto, exatamente como fizemos com os arquivos do nosso próprio projeto no capítulo de build:

g++ -Ithird_party/fmt/include -c third_party/fmt/src/format.cc -o format.o

Repare que o -Ithird_party/fmt/include também aparece aqui, mesmo compilando o format.cc que faz parte da própria biblioteca. Isso porque format.cc também tem seus próprios #include dentro dos headers do fmt — é comum que o arquivo-fonte que implementa uma função inclua o header que a declara, para garantir que a definição bate exatamente com a assinatura declarada. Ou seja: os headers não servem só para quem consome a biblioteca, servem também para compilar a própria biblioteca.

Isso deixa claro um ponto que vale reforçar: o header do fmt vai ser necessário nos dois lados dessa história. Ele é necessário aqui, para compilar format.cc na própria biblioteca, e vai ser necessário de novo daqui a pouco, para compilar o nosso main.cpp — porque é o header que ensina ao compilador qual é a assinatura de fmt::print (os tipos de parâmetro, o tipo de retorno), permitindo que ele gere uma chamada compatível com o código dentro do .a.

Um .o sozinho não é uma biblioteca ainda — é só um arquivo objeto, igual aos que já geramos para main.cpp e utils.cpp no capítulo anterior. Para empacotar isso como uma biblioteca estática de verdade, usamos o ar, a ferramenta de arquivamento que cria um .a:

ar rcs libfmt.a format.o

ar rcs reúne um ou mais arquivos objeto em um único arquivo .a — uma biblioteca estática, no mesmo formato que o libstdc++ que o g++ já linka automaticamente para você, permitindo que você utilize as bibliotecas padrão do C++.

Aqui simplificamos compilando um único arquivo-fonte, mas o fmt de verdade tem mais de um .cc dentro de src/. Se fossem cinco, dez, vinte arquivos — o que é comum em bibliotecas maiores —, o processo seria repetir o g++ -c para cada um deles e passar todos os .o resultantes para o mesmo comando ar rcs. É exatamente o mesmo problema de escala que já vimos no capítulo de build, quando um projeto com muitos arquivos torna inviável compilar tudo manualmente, arquivo por arquivo — só que agora o problema está do lado da biblioteca que estamos criando para consumir no nosso projeto.

É por isso que a própria biblioteca fmt já resolve isso por conta própria: dentro do repositório dela existe um CMakeLists.txt, que sabe compilar todos os .cc necessários e gerar o libfmt.a correto, sem que você precise descobrir manualmente quais arquivos fazem parte da build.

Uma opção seria rodar esse CMakeLists.txt isoladamente, de dentro da própria pasta third_party/fmt, só para gerar o .a — e depois linkar contra ele manualmente do jeito que fizemos acima:

cd third_party/fmt
mkdir build && cd build
cmake ..
cmake --build .

Isso já resolve, sozinho, o problema de compilar vários .cc manualmente — o CMakeLists.txt do fmt sabe exatamente quais arquivos fazem parte da build e em que ordem processá-los. Ele também aceita algumas variáveis próprias para controlar o que vai ser gerado. Por padrão, o resultado é uma biblioteca estática (o libfmt.a que já vimos), mas é possível pedir uma biblioteca compartilhada em vez disso:

cmake -DBUILD_SHARED_LIBS=TRUE ..

Depois de compilada, em sistemas Unix-like essa biblioteca ainda pode ser instalada no sistema (headers e binário copiados para um local padrão, como /usr/local) com sudo make install — o mesmo tipo de instalação "no sistema operacional" quando utilizamos um gerenciador de pacotes do sistema operacional apt/brew/choco.

Com libfmt.a pronto — seja compilado manualmente como acima, seja pelo CMakeLists.txt do fmt — sobra compilar o próprio main.cpp e linkar contra essa biblioteca:

g++ -Ithird_party/fmt/include -c main.cpp -o main.o
g++ main.o -Lthird_party/fmt/build -lfmt -o programa

Duas flags novas aparecem aqui. -L (de library path) diz ao linker em quais pastas procurar por bibliotecas, do mesmo jeito que -I diz ao compilador onde procurar headers — nesse caso, a pasta onde deixamos o libfmt.a. -lfmt diz qual biblioteca linkar: o linker procura, nas pastas indicadas por -L, por um arquivo chamado libfmt.a (ou libfmt.so) — o prefixo lib e a extensão são adicionados automaticamente, você só informa o nome do meio.

Esse é o mesmo mecanismo, aliás, por trás da libstdc++: o g++ já passa algo equivalente a -lstdc++ nos bastidores sempre que compila C++, sem você precisar escrever isso manualmente.

Simplificando com o CMakeLists.txt do próprio fmt

Tudo que fizemos até aqui — compilar format.cc, empacotar com ar, linkar manualmente com -L/-lfmt — existe porque estamos montando essa build peça por peça, na mão. Mas já vimos que o fmt distribui um CMakeLists.txt próprio, e que ele sabe gerar a biblioteca sozinho. Dá para ir um passo além do que fizemos na seção anterior: em vez de rodar esse CMakeLists.txt isoladamente e depois linkar à mão, dá para incluí-lo dentro do CMakeLists.txt do nosso próprio projeto, com add_subdirectory, e deixar que uma única build cuide de tudo — compilar o fmt, compilar o programa, e linkar os dois:

cmake_minimum_required(VERSION 3.20)
project(MeuProjeto VERSION 1.0)
 
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
 
add_subdirectory(third_party/fmt)
 
add_executable(programa main.cpp)
target_link_libraries(programa PRIVATE fmt::fmt)

O fluxo de compilação é o mesmo de sempre:

mkdir build && cd build
cmake -G Ninja ..
cmake --build .
./programa

E aqui está a vantagem prática de deixar o CMake cuidar disso: o próprio CMakeLists.txt do fmt, processado pelo add_subdirectory, já sabe gerar .a ou .lib de acordo com o compilador detectado — você não precisa se preocupar com qual dos dois formatos está sendo produzido em cada plataforma.

add_subdirectory(third_party/fmt) processa o CMakeLists.txt que já vem dentro do repositório do fmt, registrando um target chamado fmt::fmt com tudo que ele precisa — includes, flags de compilação, e a compilação da biblioteca em si. target_link_libraries(programa PRIVATE fmt::fmt) linka esse target ao programa. Vale notar que isso só funciona de forma tão direta porque o fmt já vem preparado para CMake; nem toda biblioteca tem essa cortesia, e aí a integração manual dos passos anteriores continua sendo necessária.

Por que isso não escala

Os dois casos acima já dão uma boa ideia do trabalho que existe por trás de "só usar uma biblioteca" em C++. E isso foi só com uma biblioteca de cada vez. Alguns problemas ficam evidentes assim que o projeto cresce um pouco:

  • Atualizar versão é manual. Se sair uma versão nova do nlohmann/json ou do fmt, é você quem precisa baixar o arquivo novo (ou atualizar o clone) e substituir o antigo — nada avisa, nada automatiza.
  • Recompilar em cada ambiente. O libfmt.a que compilamos acima só é válido para o compilador, a versão dele e o sistema operacional em que foi gerado. Em outra máquina, com outro compilador ou outra versão do GCC, é preciso recompilar tudo de novo.
  • Dependências transitivas ficam por sua conta. Se o fmt dependesse de outra biblioteca, seria preciso descobrir isso, baixar essa outra biblioteca também, e repetir o processo inteiro para ela — nada resolve essa árvore automaticamente.

Nenhum desses problemas é insolúvel um por um — mas juntos, em um projeto com várias dependências, viram uma quantidade de trabalho manual que não compensa. É exatamente para resolver isso que existem os gerenciadores de pacotes específicos de C++, que vamos ver a partir do próximo capítulo, começando pelo vcpkg.