Windows com C++

Adicionar verificação de tipo do tempo de compilação para Printf

Kenny Kerr

Kenny KerrExplorei algumas técnicas para tornar printf mais conveniente para usar com o C++ moderno em minha coluna de março de 2015 (msdn.microsoft.com/magazine/dn913181). Mostrei como transformar argumentos usando um modelo variadic para preencher a lacuna entre a classe de cadeia de caracteres oficial do C++ e a função printf antiquada. Por que perder tempo com isso? Bem, o printf é muito rápido e uma solução para saída formatada que pode tirar proveito de que ao mesmo tempo que permite aos desenvolvedores escreverem de forma mais segura, o código de nível mais alto é certamente desejável. O resultado foi um modelo Impressão de função variadic que poderia levar a um programa simples como este:

int main()
{
  std::string const hello = "Hello";
  std::wstring const world = L"World";
  Print("%d %s %ls\n", 123, hello, world);
}

E expandir efetivamente a função printf interna da seguinte maneira:

printf("%d %s %ls\n", Argument(123), Argument(hello), Argument(world));

O modelo de função de argumento também seria compilar além e deixar as funções do acessador necessárias:

printf("%d %s %ls\n", 123, hello.c_str(), world.c_str());

Embora essa seja uma ponte conveniente entre o C++ moderno e a função printf tradicional, ela não faz nada para resolver as dificuldades de escrever código correto usando a função printf. printf ainda é printf e estou contando com o compilador não totalmente onisciente e a biblioteca para detectar qualquer inconsistência entre especificadores de conversão e os argumentos reais fornecidos pelo autor da chamada.

Certamente o C++ moderno pode fazer melhor. Muitos desenvolvedores tentaram fazer melhor. O problema é que desenvolvedores diferentes têm requisitos ou prioridades diferentes. Alguns ficam felizes em obter um pequeno impacto no desempenho e simplesmente contar com <iostream> para verificação de tipo e extensibilidade. Outros têm desenvolvido bibliotecas elaboradas que fornecem recursos interessantes que exigem estruturas adicionais e alocações para controlar o estado de formatação. Pessoalmente, não estou satisfeito com as soluções que apresentam o desempenho e sobrecarga do tempo de execução para o que deve ser uma operação fundamental e rápida. Se for pequeno e rápido em C, ele deve ser pequeno, rápido e fácil de usar corretamente em C++. “Mais lento” não deve entrar nessa equação.

O que deve ser feito então? Bem, um pouco de abstração funciona, apenas enquanto as abstrações podem ser compiladas imediatamente para deixar algo muito semelhante a uma instrução printf manuscrita. A chave é perceber que especificadores de conversão como %d e %s são apenas espaços reservados para os argumentos ou valores que seguem. O problema é que esses espaços reservados fazem suposições sobre os tipos de argumentos correspondentes sem que haja uma maneira de saber se esses tipos estão corretos. Em vez de tentar adicionar a verificação do tipo de tempo de execução que confirmará essas suposições, vamos jogar fora essa informação falsa e em vez disso, deixar que o compilador deduza os tipos dos argumentos como eles são solucionados naturalmente. Portanto, em vez de escrever:

printf("%s %d\n", "Hello", 2015);

Em vez disso, deve restringir a cadeia de caracteres de formato para os caracteres reais para a saída e quaisquer espaços reservados para expandir. Eu ainda posso usar o mesmo metacaractere como um espaço reservado:

Write("% %\n", "Hello", 2015);

Não há realmente nenhuma informação do tipo a menos nesta versão do que no exemplo printf anterior. A única razão da necessidade do printf para incorporar essas informações de tipo adicionais, foi porque a linguagem de programação C não tinha modelos variadic. O último também é muito mais limpo. Com certeza, ficaria feliz se eu não precisar manter pesquisando vários especificadores de formato de printf para certificar de que a que peguei está certa.

Também não desejo limitar a saída apenas ao console. E quanto a formatação em uma cadeia de caracteres? E sobre algum outro destino? Um dos desafios de se trabalhar com printf é que embora ofereça suporte a saída para vários destinos, ele faz isso por meio de funções distintas que não são sobrecargas e são difíceis de usar genericamente. Para ser justo, a linguagem de programação C não oferece suporte a programação genérica ou sobrecargas. Ainda assim, não vamos repetir a história. Gostaria de imprimir no console de forma tão fácil e genérica como posso imprimir uma cadeia de caracteres ou um arquivo. O que tenho em mente está ilustrado na Figura 1.

Figura 1 Saída genérica com dedução de tipo

Write(printf, "Hello %\n", 2015);
FILE * file = // Open file
Write(file, "Hello %\n", 2015);
std::string text;
Write(text, "Hello %\n", 2015);

De uma perspectiva de design ou de implementação, deve haver um modelo de função de gravação que atua como um driver e qualquer número de destinos ou adaptadores de destino associados genericamente com base no destino identificado pelo autor da chamada. Um desenvolvedor poderá facilmente adicionar destinos adicionais conforme necessário. Então, como isso funciona? Uma opção é tornar o destino um parâmetro de modelo. Algo parecido com isto:

template <typename Target, typename ... Args>
void Write(Target & target,
  char const * const format, Args const & ... args)
{
  target(format, args ...);
}
int main()
{
  Write(printf, "Hello %d\n", 2015);
}

Isso funciona para um ponto. Posso escrever outros destinos estejam de acordo com a assinatura esperada do printf e ela deve funcionar bem. Poderia escrever um objeto de função que está em conformidade e anexa a saída a uma cadeia de caracteres:

struct StringAdapter
{
  std::string & Target;
  template <typename ... Args>
  void operator()(char const * const format, Args const & ... args)
  {
    // Append text
  }
};

Em seguida, posso usar enums com o mesmo modelo de função de gravação:

std::string target;
Write(StringAdapter{ target }, "Hello %d", 2015);
assert(target == "Hello 2015");

A princípio, isso pode parecer muito elegante e até mesmo flexível. Posso escrever todos os tipos de funções de adaptador ou objetos de função. Mas, na prática, isto rapidamente pode se tornar entediante. Seria muito mais desejável simplesmente passar a cadeia de caracteres de destino diretamente e fazer com que o modelo da função de gravação cuide de adaptá-la com base em seu tipo. Então, teremos o modelo de função de gravação correspondente ao destino solicitado com um par de funções sobrecarregadas para acrescentar ou formatar a saída. Uma pequena manobra indireta de tempo de compilação rende bastante. Ao perceber que grande parte da saída que precisa ser gravada será simplesmente texto sem nenhum espaço reservado, adicionarei não um, mas um par de sobrecargas. O primeiro simplesmente acrescentará texto. Eis uma função para acrescentar cadeias de caracteres:

void Append(std::string & target,
  char const * const value, size_t const size)
{
  target.append(value, size);
}

Em seguida, posso dar uma sobrecarga de acréscimo para printf:

template <typename P>
void Append(P target, char const * const value, size_t const size)
{
  target("%.*s", size, value);
}

Poderia ter evitado o modelo usando um ponteiro de função correspondente a assinatura printf, mas isso é um pouco mais flexível porque outras funções podem perfeitamente ser associadas a essa mesma implementação e o compilador não é impedido de qualquer forma pelo caminho indireto do ponteiro. Também posso fornecer uma sobrecarga para o arquivo ou fluxo de saída:

void Append(FILE * target,
  char const * const value, size_t const size)
{
  fprintf(target, "%.*s", size, value);
}

Obviamente, a saída formatada ainda é essencial. Aqui está uma função AppendFormat para cadeias de caracteres:

template <typename ... Args>
void AppendFormat(std::string & target,
  char const * const format, Args ... args)
{
  int const back = target.size();
  int const size = snprintf(nullptr, 0, format, args ...);
  target.resize(back + size);
  snprintf(&target[back], size + 1, format, args ...);
}

Primeiro, ela determina a quantidade de espaço adicional é necessária antes de redimensionar o destino e formatar o texto diretamente na cadeia de caracteres. É tentador tentar e evitar chamar snprintf duas vezes, verificando se há espaço suficiente no buffer. O motivo que costumo sempre chamar snprintf duas vezes é que o teste informal indicou que chamá-lo duas vezes é geralmente mais barato do que o redimensionamento de capacidade. Embora uma alocação não é obrigatória, esses caracteres extras são zerados, o que tende a ser mais caro. Isso é, no entanto, muito subjetivo, depende de padrões de dados e a frequência com que a cadeia de caracteres de destino é reutilizada. E aqui está uma para printf:

template <typename P, typename ... Args>
void AppendFormat(P target, char const * const format, Args ... args)
{
  target(format, args ...);
}

Uma sobrecarga para saída de arquivo é da mesma forma simples:

template <typename ... Args>
void AppendFormat(FILE * target,
  char const * const format, Args ... args)
{
  fprintf(target, format, args ...);
}

Agora tenho um dos blocos de construção no local para a função do driver de gravação. O outro bloco de construção necessário é uma maneira genérica de lidar com formatação de argumento. Embora a técnica ilustrada na minha coluna de março de 2015 fosse simples e elegante, não tinha a capacidade de lidar com quaisquer valores que não foram mapeados diretamente para os tipos suportados pelo printf. Ela também não podia lidar com a expansão de argumento ou valores de argumento complexos, como tipos definidos pelo usuário. Mais uma vez, um conjunto de funções sobrecarregadas pode resolver o problema de forma bem elegante. Vamos supor que a função do driver de gravação passará cada argumento para uma função WriteArgument. Aqui está uma para cadeias de caracteres:

template <typename Target>
void WriteArgument(Target & target, std::string const & value)
{
  Append(target, value.c_str(), value.size());
}

As várias funções WriteArgument sempre aceitarão dois argumentos. O primeiro representa o destino genérico enquanto o segundo é o argumento específico para gravação. Aqui, estou contando com a existência de uma função Acrescentar para coincidir com o destino e cuidar de acrescentar o valor até o final do destino. A função WriteArgument não precisa saber qual é esse destino realmente. Eu poderia evitar perfeitamente as funções de adaptador de destino, mas que resultaria em um crescimento quadrático em sobrecargas do WriteArgument. Aqui está outra função WriteArgument para argumentos inteiros:

template <typename Target>
void WriteArgument(Target & target, int const value)
{
  AppendFormat(target, "%d", value);
}

Nesse caso, a função WriteArgument espera uma função AppendFormat para coincidir com o destino. Assim como acontece com as sobrecargas Append e AppendFormat, escrever funções WriteArgument adicionais é simples. A vantagem dessa abordagem é que os adaptadores de argumento não precisam retornar algum valor na pilha para a função printf, como na versão de março de 2015. Em vez disso, sobrecargas de WriteArgument realmente investigam a saída de modo que o destino é gravado imediatamente. Isso significa que tipos complexos podem ser usados como argumentos e armazenamento temporário ainda pode ser considerado para formatar sua representação de texto. Aqui está uma sobrecarga de WriteArgument para GUIDs:

template <typename Target>
void WriteArgument(Target & target, GUID const & value)
{
  wchar_t buffer[39];
  StringFromGUID2(value, buffer, _countof(buffer));
  AppendFormat(target, "%.*ls", 36, buffer + 1);
}

Posso até mesmo substituir a função Windows StringFromGUID2 e formatá-la diretamente, talvez para melhorar o desempenho ou adicionar portabilidade, mas isso mostra claramente a força e a flexibilidade dessa abordagem. Tipos definidos pelo usuário podem ser facilmente suportados com a adição de uma sobrecarga de WriteArgument. Vou chamá-los de sobrecargas aqui, mas a rigor eles não precisam ser. A biblioteca de saída certamente pode fornecer um conjunto de sobrecargas para destinos e argumentos comuns, mas a função do driver de gravação não deve presumir que as funções de adaptador são sobrecargas e, em vez disso, devem ser tratadas como funções de início e fim não membro definidas pela biblioteca C++ padrão. As funções de início e fim não membro são extensíveis e adaptáveis a todos os tipos de contêineres padrão e não padrão precisamente porque não precisam residir no namespace padrão, mas devem ser locais para o namespace do tipo que está sendo correspondido. Da mesma forma, essas funções de adaptador de destino e o argumento podem residir em outros namespaces para dar suporte aos destinos e argumentos definidos pelo usuário do desenvolvedor. Então, como a função do driver de gravação se parece? Para começar, há apenas uma função de gravação:

template <typename Target, unsigned Count, typename ... Args>
void Write(Target & target,
  char const (&format)[Count], Args const & ... args)
{
  assert(Internal::CountPlaceholders(format) == sizeof ... (args));
  Internal::Write(target, format, Count - 1, args ...);
}

A primeira coisa que preciso fazer é determinar se o número de espaços reservados na cadeia de caracteres de formato é igual ao número de argumentos no pacote de parâmetros variadic. Aqui, estou usando uma asserção de tempo de execução, mas isso realmente deve ser um static_assert que verifica a cadeia de caracteres de formato em tempo de compilação. Infelizmente, o Visual C++ ainda não chegou lá. Ainda assim, posso escrever o código para que, quando o compilador fica em dia, o código pode ser facilmente atualizado para verificar a cadeia de caracteres de formato em tempo de compilação. Assim, a função CountPlaceholders interna deve ser um constexpr:

constexpr unsigned CountPlaceholders(char const * const format)
{
  return (*format == '%') +
    (*format == '\0' ? 0 : CountPlaceholders(format + 1));
}

Quando o Visual C++ alcança a conformidade completa com o C++14, pelo menos em relação ao constexpr, você poderá simplesmente substituir a declaração assert dentro da função de gravação com static_assert. Em seguida, a função de gravação sobrecarregada interna distribui a saída de argumento específico no tempo de compilação. Aqui, posso confiar no compilador para gerar e chamar as sobrecargas necessárias da função de gravação interna para satisfazer o pacote de parâmetros variadic expandido:

template <typename Target, typename First, typename ... Rest>
void Write(Target & target, char const * const value,
  size_t const size, First const & first, Rest const & ... rest)
{
  // Magic goes here
}

Falarei sobre essa mágica em instantes. Por fim, o compilador será executado sem argumentos e uma sobrecarga não variadic será necessária para concluir a operação:

template <typename Target>
void Write(Target & target, char const * const value, size_t const size)
{
  Append(target, value, size);
}

Ambas as funções de gravação internas aceitam um valor, junto com o tamanho do valor. O modelo de função de gravação variadic deve considerar ainda que há pelo menos um espaço reservado para o valor. A função de gravação variadic não precisa fazer nenhuma suposição e simplesmente pode usar a função Append genérica para gravar qualquer parte à direita da cadeia de caracteres de formato. Antes que a função de gravação variadic possa escrever seus argumentos, ela deve primeiro gravar todos os caracteres à esquerda e, claro, encontrar o primeiro espaço reservado ou metacaractere:

size_t placeholder = 0;
while (value[placeholder] != '%')
{
  ++placeholder;
}

Somente então ela pode gravar os caracteres à esquerda:

assert(value[placeholder] == '%');
Append(target, value, placeholder);

O primeiro argumento pode ser gravado e o processo se repete até que nenhum argumento adicional e espaços reservados são deixados:

WriteArgument(target, first);
Write(target, value + placeholder + 1, size - placeholder - 1, rest ...);

Agora posso oferecer suporte a saída genérica na Figura 1. Posso até mesmo converter um GUID em uma cadeia de caracteres simplesmente:

std::string text;
Write(text, "{%}", __uuidof(IUnknown));
assert(text == "{00000000-0000-0000-C000-000000000046}");

E sobre algo um pouco mais interessante? Que tal visualizando um vetor:

std::vector<int> const numbers{ 1, 2, 3, 4, 5, 6 };
std::string text;
Write(text, "{ % }", numbers);
assert(text == "{ 1, 2, 3, 4, 5, 6 }");

Para isso, eu simplesmente preciso escrever um modelo de função WriteArgument que aceita um vetor como um argumento, como mostrado na Figura 2.

Figura 2 Visualizando um vetor

template <typename Target, typename Value>
void WriteArgument(Target & target, std::vector<Value> const & values)
{
  for (size_t i = 0; i != values.size(); ++i)
  {
    if (i != 0)
    {
      WriteArgument(target, ", ");
    }
    WriteArgument(target, values[i]);
  }
}

Observe como não forço o tipo dos elementos no vetor. Isso significa que agora posso usar a mesma implementação para visualizar um vetor de cadeias de caracteres:

std::vector<std::string> const names{ "Jim", "Jane", "June" };
std::string text;
Write(text, "{ % }", names);
assert(text == "{ Jim, Jane, June }");

É claro que levanta a questão: E se eu quiser expandir ainda mais um espaço reservado? Com certeza, posso escrever um WriteArgument para um contêiner, mas ela não fornece flexibilidade no ajuste da saída. Imagine, preciso definir uma paleta para um aplicativo de esquema de cores e tenho cores primárias e secundárias:

std::vector<std::string> const primary = { "Red", "Green", "Blue" };
std::vector<std::string> const secondary = { "Cyan", "Yellow" };

A função de gravação formatará com boa vontade isso para mim:

Write(printf,
  "<Colors>%%</Colors>",
  primary,
  secondary);

A saída, no entanto, não é exatamente o que eu quero:

<Colors>Red, Green, BlueCyan, Yellow</Colors>

Isso está obviamente errado. Em vez disso, gostaria de marcar as cores que eu sei que são primárias e secundárias. Provavelmente assim:

<Colors>
  <Primary>Red</Primary>
  <Primary>Green</Primary>
  <Primary>Blue</Primary>
  <Secondary>Cyan</Secondary>
  <Secondary>Yellow</Secondary>
</Colors>

Vamos adicionar mais uma função WriteArgument que pode fornecer esse nível de extensibilidade:

template <typename Target, typename Arg>
void WriteArgument(Target & target, Arg const & value)
{
  value(target);
}

Observe que a operação parece estar invertida radicalmente. Em vez de passar o valor para o destino, o destino está sendo passado para o valor. Dessa forma, posso fornecer uma função associada como um argumento em vez de apenas um valor. Posso anexar um comportamento definido pelo usuário e não simplesmente um valor definido pelo usuário. Eis uma função WriteColors que faz o que eu quero:

void WriteColors(int (*target)(char const *, ...),
  std::vector<std::string> const & colors, std::string const & tag)
{
  for (std::string const & color : colors)
  {
    Write(target, "<%>%</%>", tag, color, tag);
  }
}

Observe que isso não é um modelo de função e tive principalmente de codificá-la para um único destino. Isso é uma personalização de destino específico, mas mostra que é possível até mesmo quando você precisa sair a dedução de tipo genérico fornecida diretamente pela função de driver de gravação. Mas como ela pode ser incorporada em uma operação maior de gravação? Bem, você pode ser tentado por um momento a escrever isso:

Write(printf,
  "<Colors>\n%%</Colors>\n",
  WriteColors(printf, primary, "Primary"),
  WriteColors(printf, secondary, "Secondary"));

Coloque de lado por um momento o fato de que isso não será compilado, ele realmente não oferecem a sequência certa de eventos, mesmo assim. Se isso funcionar, as cores seriam impressas antes da abertura da marca de <Cores>. Sem dúvida, eles devem ser chamados como se fossem argumentos na ordem em que aparecem. E isso é o que o novo modelo de função WriteArgument permite. Só preciso associar as invocações do WriteColors que podem ser chamadas em um estágio posterior. Para tornar essa função ainda mais simples para alguém usando a função do driver de gravação, posso oferecer um wrapper de ligação útil:

template <typename F, typename ... Args>
auto Bind(F call, Args && ... args)
{
  return std::bind(call, std::placeholders::_1,
    std::forward<Args>(args) ...);
}

Esse modelo de função de associação simplesmente garante que um espaço reservado é reservado para o destino final para o qual ele será gravado. Posso, em seguida, formatar corretamente minha paleta de cores da seguinte maneira:

Write(printf,
  "<Colors>%%</Colors>\n",
  Bind(WriteColors, std::ref(primary), "Primary"),
  Bind(WriteColors, std::ref(secondary), "Secondary"));

E tenho marcada saída esperada. As funções de referência auxiliar não são estritamente necessárias, mas evite fazer uma cópia dos contêineres para os wrappers de chamada.

Não convencidos? As possibilidades são infinitas. Você pode manipular argumentos da cadeia de caracteres com eficiência para caracteres largos e normais:

template <typename Target, unsigned Count>
void WriteArgument(Target & target, char const (&value)[Count])
{
  Append(target, value, Count - 1);
}
template <typename Target, unsigned Count>
void WriteArgument(Target & target, wchar_t const (&value)[Count])
{
  AppendFormat(target, "%.*ls", Count - 1, value);
}

Dessa forma, posso facilmente e com segurança gravar a saída usando diferentes conjuntos de caracteres:

Write(printf, "% %", "Hello", L"World");

E se você não precisar gravar a saída de forma específica ou inicial, mas em vez disso, precisar apenas calcular a quantidade de espaço necessária? Sem problemas, posso simplesmente criar um novo destino que resume isso da seguinte maneira:

void Append(size_t & target, char const *, size_t const size)
{
  target += size;
}
template <typename ... Args>
void AppendFormat(size_t & target,
  char const * const format, Args ... args)
{
  target += snprintf(nullptr, 0, format, args ...);
}

Agora eu posso calcular o tamanho necessário simplesmente:

size_t count = 0;
Write(count, "Hello %", 2015);
assert(count == strlen("Hello 2015"));

Acho que é seguro dizer que essa solução finalmente soluciona a falha de tipo inerente ao uso printf diretamente, preservando a maioria dos benefícios de desempenho. O C++ moderno é mais do que capaz de atender às necessidades dos desenvolvedores que buscam um ambiente produtivo com tipo confiável de verificação, mantendo o desempenho para o qual o C e o C++ são normalmente conhecidos.


Kenny Kerr é programador de computador, assim como autor da Pluralsight e Microsoft MVP que mora no Canadá. Ele mantém um blog em kennykerr.ca e pode ser seguido no Twitter em twitter.com/kennykerr.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: James McNellis