Ponto V!

Home C/C++ Conceitos Básicos Exception safety e gerência de recursos
Vinícius Godoy de Mendonça
Exception safety e gerência de recursosImprimir
Escrito por Vinícius Godoy de Mendonça

No último artigo sobre exceções falamos de uma série de boas práticas sobre como usar exceções. Entretanto, finalizei o artigo de maneira um tanto capciosa, ao deixar o leitor com uma pulga atrás da orelha: Se exceções podem ocorrer a qualquer momento, como garantir que meu código continuará consistente? Nesse artigo, exploraremos essa questão.

Observação: esse artigo fala de recursos de baixo nível tais como arquivos e memória (resources) e não imagens e sons (assets). Caso você queira gerenciar o segundo, leia esse excelente artigo do Bruno Sanches.

O problema

Considere o código abaixo:

void GameCanvas::changeBackground(std::stream& imgSrc)
{
    pthread_mutex_lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);  //E se isso lançar exceção?
    pthread_mutex_unlock(&mutex);
}

Vamos ver o que acontece com o estado da classe GameCanvas se uma exceção for disparada pelo construtor da classe Image, na linha 6?

  1. Um mutex foi adquirido, mas não será liberado;
  2. A imagem de fundo antiga foi apagada, mas não substituída;
  3. Adicionamos o contador em imageChanges, sem que a imagem tenha sido trocada;

Ou seja, estaremos em mãos com um objeto totalmente inconsistente!

Agora, lembre-se que exceções podem ocorrer em praticamente qualquer lugar. No lugar do construtor de image, poderia ser disparada no destrutor da linha 4, ou mesmo durante o unlock na última linha do método. Essa constatação levou a comunidade C++ a criticar-se sobre o assunto e mecanismos foram criados para evitar os problemas que isso pode causar. Esse assunto ficou conhecido como exception safety, sendo o artigo Here be Dragons, um dos artigos mais populares sobre o assunto.

Antes de continuar, entretanto, acho importante esclarecer que esse problema, sobretudo quando se trabalha em equipes, não está restrito as exceções. É claro, elas evidenciaram e o tornaram mais frequente, mas a situação pode ocorrer até onde exceções não existem, como a linguagem C, ou em linguagens gerenciadas, como o Java.

Por exemplo, vamos supor que sua equipe percebeu que, ao abrir o save do seu jogo, o usuário poderia escolher um arquivo qualquer, que não tem absolutamente nada a ver com o save, e isso geraria o crash da aplicação. Para resolver isso o time decidiu gravar nos primeiros bytes de todos os arquivos do jogo uma sequência numérica pré-definida (chamada de magic number). Essa sequência pode ser qualquer número não óbvio, como o 0xAF1AFACA. Então, nas rotinas de leitura, bastaria verificar se o arquivo começa com essa sequência, para evitar que o crash ocorresse ao ler de um arquivo inválido. A técnica é perfeitamente válida, e usada inclusive em formatos de arquivo comerciais.

Após isso, a equipe solicitou a um programador que alterasse as funções de leitura para fazer a verificação do magic number, e veja o que ele fez:

void readCharacter(char* filename) {
   FILE*pFile;
   pFile = fopen(filename, “r”);
   unsigned magic;
   fread(&magic, sizeof(unsigned), 1, pFile);
   if (magic != MAGIC_NUMBER) 
      return INVALID_FILE; 

    //Aqui vem o resto da leitura do arquivo

    fclose(pFile);
    return OK;
}

Veja que o código parece perfeitamente razoável, no entanto, o return dessa vez fez o papel da exceção e tornou-se o vilão da história. Naquele ponto, ao sair da função, pFile permanecerá aberto, causando assim, um estado inválido em nosso programa.

Então, se o problema não são as exceções, qual é o real problema? O real problema é que frequentemente solicitamos recursos ao sistema operacional e somos obrigados a devolvê-los em algum momento. Que recursos são esses? Praticamente qualquer coisa que deva ser “fechada”, “devolvida” ou “excluída”:

  • Memória no heap (após um new, é necessário um delete);
  • Arquivos, sockets, objetos de banco de dados (precisam de close);
  • Locks de threads (unlock);
  • Entre outros…

Resource acquisition is initialization (RAII)

Todo programador C++ sabe que variáveis criadas no stack (sem new) são automaticamente destruídas ao final da função, certo?

Baseado nessa observação, o próprio Stroustrup propôs a seguinte técnica:

  1. Crie uma classe que recebe o recurso a ser gerenciado em seu construtor;
  2. Faça com que essa classe libere o recurso no seu destrutor;
  3. Garanta que a aquisição do recurso será feita ao mesmo tempo que a inicialização dessa classe criada.

Vamos a um exemplo prático.

class Lock
{
    private:
        pthread_mutex_t* mutex;
    public:
        Lock(pthread_mutex_t* m) : mutex(m) 
        { 
            pthread_mutex_lock(m); 
        }
        ~Lock() 
        { 
            pthread_mutex_unlock(m); 
        }
};

E agora, podemos alterar o código problemático do primeiro exemplo para:

void GameCanvas::changeBackground(std::stream& imgSrc)
{
    Lock lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);  //E se isso lançar exceção?
}

Note que a última linha sumiu! Afinal, como agora a nossa variável lock está no stack, ela será automaticamente destruída. E, ao fazer isso, ela chamará automaticamente o comando de unlock. O estado do lock está garantido, mesmo que ocorra uma exceção ou que um programador distraído coloque um return no meio do código.

Smart pointers

Que tal aplicarmos o mesmo conceito para a imagem? Nesse caso, o recurso sendo gerenciado é a memória, então, vamos criar uma classe que irá armazenar o ponteiro para a área gerenciada:

class AutoPtr<T> 
{
    private:
        T* ptr;
    public: 
        AutoPtr(T* _ptr) : ptr(_ptr) {}

        void reset(T* _ptr) 
        { 
            if (ptr) 
                delete ptr;
            ptr = _ptr;
        }

        inline T* get() { return ptr; }

        ~AutoPtr() { if (ptr) delete _ptr; }
};

E então, se Image agora fosse um AutoPtr e trocassemos o contador de lugar, o novo código ficaria assim:

void GameCanvas::changeBackground(std::stream& imgSrc)
{
    Lock lock(&mutex);    
    bgImage.reset(new Image(imgSrc));
    ++imageChanges;
}

Criamos a nossa própria versão de um ponteiro, mas com inteligência. Esse conceito ficou conhecido como smart pointer. Diversos smart pointers, antes pertencentes à boost, foram incluídos no C++11, como parte do novo padrão. Você pode ver uma descrição detalhada deles na série de artigos do Bruno Sanches, aqui mesmo no portal.

Logo os programadores perceberam que esse conceito poderia ser estendido para que os ponteiros também tivessem recursos como contagem de referências, adicionando ao C++ uma espécie de “coletor de lixo”. É o que ponteiros como o shared_ptr fornecem.

A versão do nosso método até agora parece perfeita, não? Mas existe uma falha sutil ainda. Você é capaz de identifica-la antes de ler ir para o próximo tópico?

Copy and swap

Se uma exceção for disparada no meio do construtor de image, como ficará o estado do stream recebido como parâmetro? Pode ser que metade da imagem tenha sido lida, portanto, os ponteiros do stream terão se deslocado. Isso significa que, embora nossas modificações tenham garantido o estado da classe GameCanvas, ainda teremos o stream em estado inválido após a execução do método! Se você não pegou esse erro, não se preocupe, muitos programadores experientes (inclusive eu), já foram vítimas dele.

Uma das soluções para esse problema é criar uma cópia do stream, trabalhar sobre a cópia, e só modifica-lo caso a leitura tenha sido bem sucedida:

void GameCanvas::changeBackground(std::stream& imgSrc) 
{
    Lock lock(&mutex);
    std::stream copy = imgSrc; 
    bgImage.reset(new Image(copy));
    ++imageChanges; 
    std::swap(imgsrc, copy);
}

Essa técnica só é eficiente caso os objetos tenham construtor de cópia e a função de swap implementada corretamente. Por isso, é uma das recomendações gerais em C++, que você sobrecarregue a função std::swap com eficiência para suas próprias classes.

A partir do C++11, com a move semantics, surge a possibilidade de otimização dessa técnica através do std::move. Por isso, considere sobrecarregar também essa função.

Poderíamos usar o copy&swap para dar as mesmas garantias de exception ao nosso método, no lugar dos smart pointers? Com certeza. Considere essa variação de changeBackground:

void GameCanvas::changeBackground(std::stream& imgSrc)
{
    GameCanvas copy = *this;
    pthread_mutex_lock(copy.mutex);
    delete copy.bgImage;
    ++(copy.imageChanges);

    std::stream streamCopy = imgSrc;
    copy.bgImage = new Image(streamCopy);  
    std::swap(imgSrc, streamCopy);

    pthread_mutex_unlock(copy.mutex);
    std::swap(copy, *this);

}

Note que criamos uma cópia de nosso próprio objeto. Em seguida, fizemos todas as operações, de forma despreocupada, como na primeira versão do método, só que sobre a cópia. Caso uma exceção ocorra, a cópia será simplesmente descartada e nosso objeto não mudará de estado! Caso contrário, substituímos a cópia por nosso próprio objeto, e o estado estará perfeito.

Essa técnica, entretanto, exige uma série de cuidados:

  • Cópias devem ser, necessariamente, eficientes;
  • Exige uma função de swap eficiente;
  • Nem o construtor de cópia e nem o método swap podem disparar exceções.

Se quiser conhecer meios de usar mais esse recurso com efetividade, leia sobre o idioma pimpl e ponteiros opacos. É importante ressaltar que a técnica não descarta o uso dos smart pointers, uma vez que eles podem ser usados para fazer uma gerência mais abrangente de recursos. Mas é excelente tê-la à mão, pois pode ser uma mão na roda em muitas situações.

Garantias em caso de exceção

Quando escrevemos software, uma das partes que muitas pessoas negligenciam é a documentação. No caso de exceções, devemos não só documentar que tipo de exceções um método pode disparar, como também, o que garantias esse método dá em caso de exceções.

Três termos foram criados para especificar que tipo de garantia o método dará após uma exceção ocorrer:

Garantia básica:

  • Promete que se alguma coisa acontecer no programa, ele permanecerá num estado válido.
  • Nenhum objeto ou estrutura de dados se corrompe e os objetos ficam num estado válido;
  • Entretanto, o estado pode não ser previsível. O programa pode ter que ler isso através de uma função.

Garantia forte

  • Promete que se uma exceção acontecer, o estado do programa permanece inalterado.
  • Esse tipo de garantia assegura uma execução atômica, no sentido de que se a função falhar, será como se nunca tivesse sido invocada;
  • Utilizar funções desse tipo é muito mais fácil do que funções com a garantia básica já que, ao final, seu programa só poderá ter 2 estados: o desejado, ou o anterior a chamada.

Garantia nothrow:

  • Indica que a função nunca lança exceção, de nenhum tipo.
  • É o tipo mais fácil de todos de se trabalhar, porém, o mais difícil de se programar (nem sempre sequer é possível);
  • No C++11, a palavra chave nothrow é usada para métodos com essa garantia. Eles passam a ser sujeitos a otimizações específicas de compilação.

Para você refletir: Considere que os métodos doSomething1() e doSomething2() dão garantia forte. Podemos dizer que a função abaixo dá essa garantia?

void SomeClass::doComplexStuff() 
{
   doSomething1();
   doSomething2();
}

A resposta é não. Se houver uma exceção em doSomething2(), o estado de SomeClass já pode ter sido alterado por doSomething1(). O método doComplexStuff() daria então garantia básica? Apenas se doSomething1() realmente puder ser chamado isoladamente. Nesse caso, o estado de nosso objeto se alteraria para outro estado válido, porém, diferente do original.

Considerações finais

Isso cobre o básico sobre gerência de recursos em C++. Outras linguagens apresentam técnicas diferentes para a mesma gerência.

  • Java e C# apresentam a palavra chave finally, que pode ser colocada num bloco try catch. Tudo que é colocado no finally será executado ao final do método, independente de haver uma exceção disparada ou não. Lá o ponto ideal para finalizar recursos (para mais informações, leia o artigo Good Housekeeping Practices, do Brian Goetz).
  • O C# introduziu a palavra chave using, que fecha automaticamente recursos que implementem a interface IDisposable. Na versão 7, o Java copiou a ideia introduzindo o try with resources, capaz de fechar objetos que implementem a interface AutoCloseable;
  • Em ambas as linguagens, você pode implementar o método finalize() para que o garbage collector feche o recurso quando rodar. Isso pode demorar demais, mas não deixa de ser um último recurso.

Bem, isso finaliza minha série sobre exceções em C++. Espero que tenham gostado! Aguardo seus comentários, críticas e até sugestões de próximos artigos!


Comentários (2)
  • Anônimo
    avatar

    Quanto mais aprendo sobre programação, c++ em específico, eu percebo o qual complexo e difícil é escrever um bom código. Valeu pelas dicas Viny, excelente artigo. :cheer:

  • Vinícius Godoy de Mendonça
    avatar

    Valeu!

Escrever um comentário
Your Contact Details:
Gravatar enabled
Comentário:
[b] [i] [u] [url] [quote] [code] [img]   
:angry::0:confused::cheer:B):evil::silly::dry::lol::kiss::D:pinch::(:shock:
:X:side::):P:unsure::woohoo::huh::whistle:;):S:!::?::idea::arrow:
Security
Por favor coloque o código anti-spam que você lê na imagem.
LAST_UPDATED2  

Busca

Linguagens

Twitter