Postmortem: Erros comuns que já vi em C++ e COM

Há mais ou menos uma década, ou um pouco mais, cheguei a trabalhar com consultoria a respeito de otimização e caça de erros em códigos de “frameworks” para algumas empresas. Lembro-me de 3 projetos grandes que apresentavam os mesmos erros que, até onde sei, jamais foram solucionados.

Como sempre acontece, trata-se do uso indiscriminado de “facilidades”, sem o devido conhecimento de como elas funcionam. Coisa comum no desenvolvimento usando “orientação a objetos”, afinal, alguns “comportamentos” estão “encapsulados” dentro de um conjunto de classes e o programador, em teoria, não deveria nem querer saber como, só usá-los, certo? Bem… errado!

Abaixo, mostro alguns erros que já encontrei e é apenas uma lista pequena contida num texto gigantesto, desculpe…

1º: Objetos anônimos temporários

O primeiro problema comum de ser encontrado em códigos escritos em C++ é o desconhecimento sobre como uma linguagem de programação funciona… E não estou falando somente de suas regras sintáticas, mas da semântica da resolução de expressões e passagem de argumentos de funções. Vejamos um exemplo simples com objetos de alguma classe complexa qualquer, usando sobrecarga de operadores:

a = b + c;

Essa simples expressão é composta de dois operadores (‘=’ e ‘+’), onde o operador de adição sempre retornará um “objeto anônimo temporário”. Por quê? Ora, nem o objeto ‘b’, nem o objeto ‘c’ devem ser molestados e, o que queremos, é a adição desses dois como resultado. Temos que criar, on-the-fly, um terceiro objeto que represente essa “adição”… É comum que um operador desse seja definido de acordo com a assinatura abaixo:

MyClass MyClass::operator+(const Myclass& o);

O objeto retornado por esse operador será usado como argumento para o operador “=”, que o copiará para o interior do objeto ‘a’ e, só então, o objeto anônimo temporário deixará de existir.

Para objetos pequenos, cuja cópia é simples de ser feita, a perda de performance é quase inócua, mas para objetos complexos, que contém em seu interior, agregações, listas, árvores etc, a operação de cópia pode ser bem demorada, bem como as necessidades de recursos podem crescer um bocado. Suponha que o objeto ‘c’ tenha uns 2 MiB de dados agregados em seu interior e o objeto ‘b’ tenha 1 MiB… Suponha, agora, que no processo de “adição” esses dados sejam manipulados de forma tal a gerar o consumo de 3 MiB (que pode ser bem mais, dependendo da transformação necessária!)… Temos o consumo de 3 MiB adicionais apenas no objeto anônimo temporário, retornado pelo operador ‘+’!

Nesses casos, para evitar a criação de objetos temporários, seria interessante usarmos funções-membro especializadas ao invés de operadores. A expressão acima poderia ser reescrita como:

a.append(b, c);

Onde a função-membro append aceitaria duas referências para os argumentos ‘b’ e ‘c’. Podemos controlar a criação de objetos temporários, se houver necessidade de um.

Mais um exemplo: Suponha agora que a expressão seja bem mais complexa, como a = -(b + c) * (d >>= e);. Dependendo do que os operadores ‘-‘ (unário), ‘*’ e ‘>>=’ fazem, teremos uns 3 objetos anônimos temporários em potencial (um para t1=(b + c), outro para t2=t1*(d >>= e) e outro para t3=-t2). Cada um com seus próprios recursos… Isso sem contar que podemos ter outras expressões que usem os objetos originais e, mesmo com a otimização de common subexpression elimination, que não funciona muito bem para classes customizadas, já que a semântica dos operadores muda radicalmente, podemos ter n objetos temporários anônimos em uso num mesmo bloco.

A criação de objetos temporários acontece, também, com chamadas de funções, mas, neste caso, os objetos não são “anônimos”, mas uma cópia do original, se fizermos algo assim:

MyClass f(MyClass a, MyClass b) { ... }

Ao passar instâncias para a função f(), automaticamente o compilador gerará uma cópia das instâncias originais, porque ‘a’ e ‘b’ não podem modificá-las, sendo locais à função… Note que a função retorna um objeto anônimo da classe ‘obj’ também!

É claro que para solucionar esse tipo de coisa, pelo menos no que se refere aos argumentos, podemos usar referèncias:

MyClass f(const MyClass& a, const MyClas& b) { ... }

Aqui, o qualificador const garante que a instância referenciada não poderá ser modificada no interior da função… Isso é óbvio para um desenvolvedor experiente, mas, nessa época de .NET e Java, onde argumentos de funções de tipos complexos são, na verdade, referências, costuma-se esquecer do fato acima, causando grande pressão por uso de recursos…

2º: Tipos primitivos versus objetos “constantes”

Outra coisa que já observei é a tendência a usar classes que oferecem facilidades, ao invés de tipos primitivos, especialmente quando estamos lidando com constantes. Um exemplo clássico é a definição de “constantes” do tipo “string”… Nesses “frameworks” era comum ver algo assim:

const std::string ERROR1 = "Erro genérico";
const std::string ERROR2 = "Erro de qualquer bobagem";
const std::string ERROR3 = "Erro errado";
...
const std::string ERROR1023 = "Erro de um monte de erros";

O problema aqui é que o programador não está criando constantes. Está criando instâncias do objeto basic_string contendo contantes. Todos esses 1023 objetos terão que ser construídos, ou seja, código de construtores serão chamados. Só para ilustrar, eis o código em assembly gerado pelo GCC, para x86-64 (linux), do construtor da “constante” ERROR1, acima:

_GLOBAL__sub_I_test.cc:
  movabs rax, 7954877705826234949 ; A string de ERROR1.
  mov rdx,__dso_handle
  mov rsi,_ZL6ERROR1   ; A referência ao objeto ERROR1.
  mov [_ZL6ERROR1+16],rax
  mov rdi, _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEED1Ev
  mov eax, 28515
  mov qword [_ZL6ERROR1],_ZL6ERROR1+16
  mov dword [_ZL6ERROR1+24],1769122243
  mov [_ZL6ERROR1+28],ax
  mov qword [_ZL6ERROR1+8],14
  mov byte [_ZL6ERROR1+30],0
  jmp __cxa_atexit

É claro que a rotina também registra o destrutor (pulando para __cxa_atexit no fim das contas)… Mas, neste construtor, porque a string é pequena (14 bytes), ela cabe no registrador RAX e o valor atribuído no início da rotina é exatamente essa string parcial no formato little endian (os primeiros 8 bytes: 0x6E6567206F727245, formando “Erro gené”), a 5ª instrução, de cima para baixo completa a string (0x6972A9C3 ou “rico”), e a penúltima coloca o ‘\0’ final. Mas existem outras 8 instruções para esse simples construtor de apenas uma das “constantes” (dentre eles o ajuste do tamanho da string na ante-penúltima instrução). Para strings maiores que 16 bytes, contando o terminador ‘\0’, o construtor fica mais complicado… Assim, um código enorme será executado para inicializar os objetos.

Compare isso à simples declaração de arrays:

const char ERROR1[] = "Erro genérico";

O compilador só fará isso:

section .rodata

        global ERROR1
ERROR1: db 'Erro genérico',0

Nenhum construtor é criado.. Os dados simplesmanente são colocados no segmento de dados read-only e acessíveis por ponteiro. Existe outra vantagem nisso: O qualificador const, para tipos primitivos, tende a criar constantes de fato em C++. Por exemplo, se tivéssemos, em dois módulos separados:

// No módulo errors.cc
const char ERRO1[] = "Erro";

// No módulo xpto.cc
const char * const error = "Erro";

O linker tenderá a fazer os ponteiros ERROR1 e error apontarem para o mesmo lugar porque existirá apenas uma string “Erro” no código inteiro (otimização “merge duplicate strings“). E, como vimos no caso do uso de “objetos constantes”, eles são construídos e a string é copiada para o interior do objeto, triplicando o uso dos recursos (teriamos 3 strings “Erro” na memória se o objeto ERROR1 e ERROR2 foram inicializados com a string “Erro”).

Isso não parece ser um problema sério, mas considere que as classes definidas nesse framework eram usadas em objetos COM que, de acordo com o ambiente, podem ser carregados e descarregados da memória… Esse comportamento é comum, no que a Microsoft chama de “objetos interoperáveis” que é a mistura de COM (unmanaged code) com objetos .NET (managed code)

Ao usar constantes “reais”, definidos com tipos primitivos, o único problema é que para usá-las teremos que instanciar objetos temporários anônimos do tipo string, como em:

std::cerr << std::string(ERROR1) << '\n';

Mas, a criação deste objeto menos traumática. O compilador, provavelmente, criará uma única função com um construtor string::string(const char *); (ou um construtor de conversão). Diferente da criação de múltiplos objetos “constantes”, este objeto temporário é criado na hora de sua necessidade e destruído assim que sairmos do escopo do bloco onde ele existe.

3º: Conversão de tipos antes da hora

De maneira similar, outro fato relacionado às funções da Win32 API e objetos COM (OLE) é o uso de um tipo “especial” de string chamada BSTR. Em C e C++ uma string é definida como um array contendo os caracteres e terminada em ‘\0’. Note que não falei “array de chars” porque podemos ter arrays de wchar_t, onde cada item tem 16 bits de tamanho…

No caso de COM (OLE), algumas vezes é necessário usar uma estrutura diferente para strings, onde cada caracter é um wchar_t e o terminador é um ‘\0’, também com 16 bits de tamanho… Além disso, o primeiro wchar_t (ou unsigned short) do array contém o tamanho da string contida no array.

Tanto no Visual Studio quando no Borland C++ Builder (usado em dois dos projetos de que falei) contém classes especializadas para conter esse tipo de string e é comum a conversão do tipo de string ANSI (na nomenclatura do Windows, são strings de chars) ou Wide Strings (onde cada ítem é um wchar_t) para o tipo BSTR (ou _bstr_t). A conversão não pode ser mais simples, usando classes:

CBSTR bstr = str;

Dependendo do tipo de str (CString ou CStringW) a classe CBSTR poderá ter um construtor de conversão mais ou menos assim:

// Note: CString contém strings "ansi".
CBSTR::CBSTR(const CString& s)
{
  size_t i, length;
  char *p = s.c_str();

  length = strlen(p);
  this->str = new wchar_t[length+1];
  for (i = 1; i <= length; i++) this->str[i] = (wchar_t)*p++;
  this->str[i] = '\0';
  this->str[0] = (wchar_t)(length + 1); //?
}

A função acima, é claro, pode muito bem ser substituída por uma chamada a StrAllocString(), no caso de Wide Strings

O destrutor de CBSTR verifica se existe um ponteiro não nulo em this->str, efetua um delete [] this->str;, se for o caso, e zera this->length. Mas, o ponto aqui é que, cada conversão envolve a alocação de novo espaço e a cópia da string original, potencialmente duplicando o espaço originalmente usado, e triplicando o uso de recursos.

Assim, usar BSTRs antecipadamente é gasto de recursos. Deveriam manter as strings confinadas aos tipos genéricos (que ocupam 2 ou 3 bytes a menos, pelo menos, já que não contém o campo length) e só quando forem chamar uma função OLE, efetuassemos:

{ SomeAPIOLEFunction(CBSTR(str).bstr()); }

Onde o membro bstr() retorna o ponteiro para a string BSTR… Note o bloco… O objeto temporário seria destruído logo após o retorno da função…

O ponto aqui é que BSTRs são necessárias apenas para as chamadas dessas funções da Win32 API. Não há necessidade de mantê-las instanciadas mais do que o tempo necessário de seu uso. A mesma coisa acontece quando uma função da API devolver uma BSTR. Poderíamos fazer algo assim:

// Note: CStringW contém Wide string.
CStringW str;

{
  wchar_t *p;
  
  SomeAPIOLEFuncionGetsBSTR((BSTR *)p);
  str = CBSTR((BSTR *)p); // supondo que CBSTR aceite um ponteiro void...
                          // supondo que CStringW tenha um construtor de
                          //   conversão para BSTR *.
  // A expressão acima poderia ser substituída por:
  //
  //   str = (BSTR *)p;
  //
  // Se CStringW tiver alguma conversão de ponteiros desse tipo.

  // Pode ser necessário chamar SysFreeString((BSTR *)p) aqui!
}

Do mesmo jeito, CBSTR “morrerá” assim que o bloco for encerrado… Apenas por um breve momento teremos muito recurso em uso, mas strings deste tipo não são tão grandes assim (64 KiB, no máximo) e logo o final dos blocos as destruiriam…

4º: Agregações com containers errados

É comum, nessas classes complexas de frameworks, que o desenvolvedor queira manter listas, filas, pilhas e outras estruturas. O que é comum encontrar é o uso de containers errados para a finalidade que o objeto se dispõe a resolver. Por exemplo, já vi muita classe agregando vector<T> ou list<T> para conter uma lista de objetos ordenados. É clarqo que dá para fazer isso e a STL disponibliza a função sort() no header algorithm. Só que existem containers que são feitos para manterem itens ordenados à medida que os inserimos… É o exemplo de set e map (e seus irmãos que aceitam várias chaves idênticas, multiset e multimap). Eles usam uma red black tree para implementar esse comportamento e, portanto, têm tempo de pesquisa na ordem de \log n. Sendo bem mais rápidos do que os containers mais “fáceis” de usar.

5º: O preconceito contra ponteiros

Eis um dos motivos da “fuga” da programação procedural para a “orientada a objetos”. Tem um monte de gente que tem medo de ponteiros! Embora a construção e destruição “automática” de objetos seja bem interessante, a alocação e dealocação dinâmica de blocos de dados é bem mais rápida e gera código mais eficiente. Isso pode ser visto nos dois fragmentos de código abaixo:

// Usando um array de objetos como uma lista.
// O último item é NULL (para emular o end()
// do iterator, abaixo).
obj *list, *p;

for (p = list; p; p++)
  p->doSomething();
-----%<-----%<-----
// Usando um container vector<>:
std::vector<obj> list;
std::vector<obj>::iterator i;

for (i = list.begin(); i != list.end(); i++)
  i->doSomething();

O segundo código parece ser mais limpo e simples que o primeiro, mas ele esconde um monte de detalhes de implementação. Para objetos simples os dois códigos são quase que exatamente os mesmos (quase! o primeiro é mais eficiente), mas se seu objeto tiver funções virtuais, herança múltipla (comum no caso de COM), classes base virtuais, … o container vector pode gerar código mais complicado (lembre-se que ele é um template). E, como demonstrei neste artigo, um container não se comporta exatamente como se espera, às vezes.

Além disso, graças ao conceito de “referência”, a turma do C++ prefere usá-las, ao invés de ponteiros só porque a “notação” é mais simples, do ponto de vista da linguagem. Mas, note que lidar com ponteiros é coisa que o processador faz com facilidade. Ao adicionar comportamentos à classes, a abstração permite ao programador mais liberdade, com o custo de uma pequena, mas significativa, ineficiência.

6º: O uso cego da MFC ou da VCL não é a melhor maneira de implementar um objeto COM

Ambas a Microsoft Foundation Classes (no Visual Studio) e a Visual Components Library (no Borland C++ Builder ou Delphi) contém templates prontinhos para usar herança na implementação de classes baseadas nas interfaces IUnknown ou IDispatch, que são as mais usadas nesse tipo de codificação. Esses templates, geralmente, são construídos através de wizzards que constroem classes com nossas funções membro hardcoded na classe, mais ou menos assim:

class IMyClass : public IUnknown {
public:
  virtual void doSomething(void);
};

Onde tudo o que você tem que fazer é criar o código de doSomething(). No entanto, especialmente com a interface IDispatch, graças ao conceito de late binding, esse tipo de artifício pode tornar seu código bem complicado.

Não parece, mas é bem mais fácil usarmos “composição” para criarmos essas classes, mais ou menos assim (este é apenas um código de exemplo não testado… Não me lembro se a MFC ou a VCL implementam as funções virtuais de IUnknown – estou assumindo que sim!):

class IMyClass : public IUnknown {
private:
  MyClassInternal *myobjptr;
public:
  IMyClass() : myobjptr(NULL) {}

  // métodos virtuais sobrecarregados de IUnknown.
  ULONG Release(void);
  HRESULT QueryInterface(REFIID riid, void **pObj);

  virtual void doSomething(void);
};

ULONG IMyClass::Release(void)
{
  ULONG ref;

  ref = IUnknonwn::Release();

  if (!ref && myobjptr)
  {  
    delete myobjptr;
    myobjptr = NULL;
  }

  // Quando retorna 0 a COM Library
  // faz um "garbage collection" e livra-se
  // da instância.
  return ref;
}

HRESULT IMyClass::QueryInterface(REFIID riid, void **pObj)
{
  // Se a interface que o usuário quer é
  // a de nosso objeto...
  if (riid == IID_MyClass)
  {
    // Cria o objeto interno
    if (!myobjptr)
    {
      myobjptr = new MyClassInternal;

      // Se não conseguiu alocar
      // objeto interno, retorna erro.
      if (!myobjptr)
      {
        // QueryInterface() exige isso,
        // em caso de erro.
        *pObj = NULL;

        // Retorna erro (E_NOINTERFACE é o ideal?)
        return E_NOINTERFACE;
      }
    }

    // Devolve o objeto, adiciona 1 à referência
    // e retorna S_OK.
    *pObj = this;
    AddRef();
    return S_OK;
  }

  return IUnknown::QueryInterface(riid, pObj);
}

// Wrapper.
void IMyClass::doSomething(void)
{ myobjptr->doSomething(); }

Na construção do objeto, via QueryInterface devemos instanciar o objeto da classe MyClassInternal, caso o GUID correto seja informado. Isso implica em realizar uma chamada a IUnknown::AddRef() para reference counting, que, de outra forma, seria feita pela função-membro base virtual, sobrecarregada.

As vantagens estão no fato que as funções-membro de MyClassInternal podem ser codificadas sem que tenhamos que nos preocupar com as regras impostas pela COM (OLE), a não ser no caso de multithreading… Ainda, no caso da sobrecarga de IUnknown::AddRef(), que criará a instância na sua primeira chamada, devemos também sobrecarregar IUnknown::Release() que se livrará de nosso objeto “interno” quando o contador chegar a zero, garantindo que não teremos memory leakage. A outra vantagem é que a classe interna pode ser desenvolvida de forma independente do contexto de um objeto COM. Ela pode até mesmo ser testada separadamente.

A desvantagem óbvia é que toda chamada é feita indiretamente e à partir de uma função virtual (implicando em tripla indireção). No entanto, não há motivos para que a função membro da classe interna também seja virtual…

7º: O seu objeto pode não estar no seu computador!

Quanto lidamos com COM ou OLE isso é importante: Os seus objetos podem estar em qualquer lugar onde haja maneira de comunicação… Por exemplo, o seu programa, que é um cliente de um objeto, pede ao Windows que instancie o objeto cuja identificação é IID_MyClass (um GUID). Graças às configurações no “arquivo de registro”, o sistema sabe que este objeto pode estar num servidor do outro lado do mundo, dai ele envia uma mensagem (marshalling) encapsulando tanto o tipo de objeto desejado quanto os métodos sendo chamados… Do outro lado, a OLE Library recebe a mensagem, a decodifica (unmarshalling), instancia o objeto desejado, chama a função membro indicada e monta uma mensagem de resposta (marshalling, de novo). A OLE Library, do seu lado, recebe a mensagem, decodifica (unmarshalling, de novo) e faz a função retornar o valor desejado.

Este caso de instanciamento fora do seu computador, chamamos de instancia Out-of-Process e o objeto cliente contém um “pseudo” objeto com as mesmas características do objeto original, mas sem a sua implementação. Trata-se de um “proxy” (ou um “procurador”, que age em benefício do cliente). Do lado do objeto real temos um “stub” (um “toco” ou “ponta”, em tradução livre), que receberá a mensagem e lidará com o objeto como se fosse o próprio cliente.

No caso do instanciamento ser feito na mesma máquina e na mesma thread, chamamos de In-Process, onde o par proxy/stub não é necessário:

Proxy/Stub

A falha em entender esse simples conceito causa grandes problemas no uso de COM…

8º: Modelo de threading errado

Nesses projetos que lidei não encontrei um objeto COM sequer que implementasse multithreading. Todos usavam o conceito de STA (Single Threaded Appartment). Isso porque este é o modelo mais simples, que deixa a COM Library lidar com a sincronização entre chamadas de várias threads para o mesma função-membro, bloqueando todas, exceto uma. De fato, apenas uma thread pode usar um objeto STA, se multiplas threads quiserem usar um objeto, cada uma delas terá que instanciar o seu…

O conceito de “Apartamento” é o mesmo do da vida normal: Existem apartamentos onde vivem pessoas sozinhas e outros onde vivem uma família com várias pessoas. No caso, não estamos falando de pessoas, mas threads.

É fácil desenvolver objetos COM em apartamentos de solteiros (STA), mas a desvantagem é que, num ambiente WEB, por exemplo, apenas um cliente terá acesso ao objeto por vez e, mesmo que vários clientes tenham seus próprios objetos, o consumo de recursos será enorme. Ou seja, seus objetos serão o “gargalo” de performance de todo o seu sistema.

Usar um modelo de threading diferente, como MTA (Multithreading Appartment) ou NTA (Neutral Threading Appartment) não é tarefa para o fraco de coração, especialmente porque a documentação detalhada sobre o assundo não é facilmente compreensível nas páginas da MSDN (ou seja, como diabos COM realmente funciona? De qualquer maneira, você pode ler muito aqui).

A escolha de um modelo de threading é essencial para que seus objetos possam ser usados com a máxima performance possível, de acordo com o ambiente. No caso de MTAs, o sincronismo entre threads deve ser feito pelo próprio objeto (usando critical sections, por exemplo), diferente das STAs, onde a COM Library usa um loop de mensagens para sincronia… Isso implica que o termo “Apartamento” é apenas um método de “marshaling” diferente, ou seja, de comunicação entre objetos (o cliente e o servidor, ou seja, a COM Library), o que torna todo o conceito ainda mais complicado… Por que essa comunicação? É que o objeto pode existir tanto no contexto do seu processo, quanto em algum outro processo ou até mesmo em um outro computador. COM pressupõe o uso de RPC (Remote Procedure Call), onde o objeto em uso pode estar, teóricamente, em qualquer lugar.

Não vou mostrar um objeto MTA e muito menos um NTA. A “neutralidade” foi uma modelo implementado no Windows 2000 para tornar MTAs ainda mais rápidos… Deixo esses detalhes para seus estudos ou, quem sabe, um dia volto a falar neles…

9º: Para complicar as coisas: COM+

Felizmente nunca peguei um projeto sério que usasse COM+… COM e OLE estão presentes no Windows desde a versão 3.1, para MS-DOS. COM+ é uma extensão do COM onde “transações” são incorporadas à complexidade do modelo. A ideia é criar objetos que permitam automatizar commits e rollbacks, do mesmo jeito que ocorre com bancos de dados. Garantindo que certas operações sejam atômicas.

Mas, sinceramente, COM e OLE já é um assunto complicado demais e eu quis ressaltar, ai em cima, que nos projetos que vi os desenvolvedores não faziam ideia do que fosse isso… Aliás, conheço poucos que fazem ideia, ainda hoje.

Anúncios