paulo1205
(usa Ubuntu)
Enviado em 17/05/2020 - 04:37h
Caro Nick,
Eu imagino que você tenha seus motivos de querer acelerar a entrega de algum código que você queira fazer. Mas se o seu objetivo for o de aprender direito o material com o qual você está lidando, seria interessante você parar para estudar de modo mais sistemático. Codificar por cópia e colagem pode até produzir resultados que funcionem, mas eu diria — por experiência própria — que, como método de trabalho, é muito ineficiente e tende a ser frustrante.
Considere adquirir um bom livro texto. Eu indiquei um recentemente, mas que é meio caro (e com o dólar acima dos R$6,00, deve estar ficando ainda mais caro). Não tenho outras indicações a fazer porque não tenho mais usado livros introdutórios, mas pode ser que alguém mais as tenha, e você mesmo pode fazer uma pesquisa na Internet a esse respeito.
Se você pegar um livro bom sobre C++, uma das coisas que devem aparecer relativamente cedo na leitura é a técnica conhecida como RAII (de “
R esource A cquisition I s I nitialization”, ou “Aquisição de Recursos É Inicialização”).
Como você deve saber, cada classe do C++ possui um ou mais construtores e um destrutor. Quando um objeto de uma classe é criado, um dos construtores é invocado para colocar aquele objeto num estado que seja pronto para uso, o que frequentemente envolve reservar algum recurso (tal como memória, acesso a arquivo, abertura de canal de comunicação com a rede ou com outro processo etc.). Ao final da vida do objeto, o destrutor é invocado, como meio de devolver, de modo ordenado, recursos que tenham sido usados pelo objeto desde o momento de sua criação ou em algum outro momento do seu tempo de vida.
A ideia da técnica RAII é se valer do fato de que todo objeto começa com um construtor e termina com o destrutor para fazer com que todo recurso que precisa ser adquirido e depois liberado seja controlado por um objeto, de modo que a aquisição do recurso ocorra no momento de sua construção e a liberação necessariamente aconteça quando ele for destruído (ou desconstruído, que talvez seja uma expressão melhor para passar a ideia de que é um processo ordenado).
Mas por que isso?
Principalmente para lidar de forma adequada casos de erro. Até porque, entre outras coisas, oferece uma sintaxe mais simples.
Quando você faz gestão de recursos no estilo do C (que também é possível em C++, mas normalmente é evitada), geralmente a aquisição do recurso e sua liberação estão em partes completamente distintas do código, e isso gera um risco significativamente grande de que ocorram erros na elaboração dos fluxos de execução que podem levar a situações tais como esquecer de liberar o recurso ou tentar usá-lo depois de já ter sido liberado. Esse problema fica ainda mais grave se o código tiver de manipular vários recursos ao mesmo tempo (a manipulação simultânea de múltiplos recursos é, inclusive, a causa mais comum para justificar o uso de
goto em C, ou então os programas escritos em diagonal, que são piores ainda de ler).
Em C++, usando RAII, a forma de fazer é mais simples porque o escopo que contém o objeto controla seu tempo de vida e, por conseguinte, do recurso que ele controla. Se ocorrer algum erro com o objeto, ele vai disparar uma exceção. Se a exceção ocorrer durante a construção do objeto e tal objeto tiver vários componentes internos (os quais provavelmente também usam RAII), aqueles que já tiverem sido inicializados serão destruídos, e então a exceção será propagada de volta para quem tentou construí-lo. Se a exceção ocorrer após a construção, então a exceção será propagada para quem invocou a função que a provocou. Quando a exceção é recebida pelo chamador o fluxo de execução vai sair do escopo em que estava, de modo que o tempo de vida de todos os objetos que já tiverem sido construídos terá chegado ao fim, o que implica que seus destrutores serão chamados.
Uma comparação de código provavelmente ilustrará isso melhor. Os trechos abaixo mostram exemplos de uma função que depende de três recursos diferentes obtidos dinamicamente, para que você possa comparar os jeitos típicos de fazer em C e em C++ (incluindo exemplos de erros de implementação, para você ter uma ideia dos problemas). Em todos os casos, a função
f () retorna um valor verdadeiro em caso de sucesso ou falso em caso de falha ou erro. Se você descontar os comentários explicativos do meio dos códigos, verá que o código em C++ é mais simples, mais expressivo e mais fácil de manter corretamente.
// Exemplo 1, em C usando goto.
int f(const char *param1, const char *param2, const char *param3){
int ret_val=0; // Pressupõe falha.
T_REC1 *p_r1=aloca_rec1(param1);
if(!p_r1)
goto libera_p_r1;
T_REC2 *p_r2=aloca_rec2(param2);
if(!p_r2)
goto libera_p_r2;
T_REC3 *p_r3;
if(!p_r3)
goto libera_p_r3;
// Aqui, p_r1, p_r2 e p_r3 apontam para recursos válidos. Começa a computação que os usa.
// ...
// ...
// XXX: Neste ponto, digamos que ocorreu uma operação ilegal envolvendo um dos recursos. O programador
// XXX: que fez manutenção do código sabe que f() retorna 0 em caso de falha, e simplesmente fez isto aqui:
if(!combina(p_r1, p_r2))
return 0;
// XXX: Viu? Ele saiu da função sem liberar nenhum dos três recursos!
// Se a função de combinação acima tiver funcionado, o código continua abaixo. Suponha que não há outros erros.
// ...
// ...
// Chegou ao final da computação. Ajusta ret_val para indicar sucesso.
ret_val=1;
libera_p_r3:
libera_rec3(p_r3);
libera_p_r2:
libera_rec2(p_r2);
libera_p_r1:
libera_rec1(p_r1);
return ret_val;
}
// Exemplo 2, em C usando código diagonal (NÃO USE!).
int f(const char *param1, const char *param2, const char *param3){
int ret_val=0; // Pressupõe falha.
T_REC1 *p_r1=aloca_rec1(param1);
if(p_r1){
T_REC2 *p_r2=aloca_rec2(param2);
if(p_r2){
T_REC3 *p_r3;
if(p_r3){
// Aqui, p_r1, p_r2 e p_r3 apontam para recursos válidos. Começa a computação que os usa.
// ...
// ...
// XXX: Neste ponto, digamos que ocorreu uma operação ilegal envolvendo um dos recursos. O programador
// XXX: que fez manutenção do código sabe que f() retorna 0 em caso de falha, e simplesmente fez isto aqui:
if(!combina(p_r1, p_r2))
return 0;
// XXX: Viu? Ele saiu da função sem liberar nenhum dos três recursos!
// Se a função de combinação acima tiver funcionado, o código continua abaixo.
// Suponha que não há outros erros.
// ...
// ...
// Chegou ao final da computação. Ajusta ret_val para indicar sucesso.
ret_val=1;
libera_rec3(p_r3);
}
libera_rec2(p_r2);
}
libera_rec1(p_r1);
}
return ret_val;
}
// Exemplo 3, em C++ usando RAII, interceptando exceções e deixando que a função apenas sinalize
// sucesso ou erro através do valor de retorno. Poderia ser ainda mais simples se, em lugar de tratar
// exceções aqui eu as deixasse vazar para o chamador da função, mas aí o programa não seria equi-
// valente à versão em C.
// f() utiliza tipo de retorno bool, em vez de int, e parâmetros que são referências a std::string. que, por
// si só já é uma maneira de implementar strings que usa RAII.
bool f(const std::string ¶m1, const std::string ¶m2, const std::string ¶m3)
try {
// Como a função inteira depende de RAII, eu usei o tratamento de exceções no lugar do bloco da
// função. Se eu precisasse de algo mais específico, poderia ter usado um bloco de função comum,
// movendo o trecho sujeito a exceções para dentro dele.
T_REC1 r1(param1); // Note que não são ponteiros.
T_REC2 r2(param2);
T_REC3 r3(param3);
// Se ocorrer exceção durante a construção de algum dos objetos, apenas os que já tiverem
// sido construídos terão seus destrutores invocados.
// Aqui, r1, r2 e r3 contêm recursos válidos (não meramente apontam). Começa a computação que os usa.
// ...
// ...
// Neste ponto, digamos que ocorreu uma operação ilegal envolvendo um ou mais dos recursos em uma função
// que não dispara exceções. O programador que fez manutenção do código sabe que f() retorna false em caso
// de falha, e simplesmente fez isto aqui:
if(!combina(r1, r2))
return false;
// Não tem o menor problema! O return faz com que o fluxo sai do escopo, o que implica chamar os
// destrutores do objeto normalmente.
// Se a função de combinação acima tiver funcionado, o código continua abaixo. Suponha que não há outros erros
// o que todos os erros que houver sejam reportados por meio de exceções.
// ...
// ...
// Chegou ao final da computação. Simplesmente retorna true.
return true;
// Note que não preciso desalocar nada. O fim do escopo de existência dos objetos provoca a chamada de
// seus destrutores automaticamente.
}
// O tratamento de exceções está muito simples. Poderia ser mais complexo, dependendo da classe de erro.
catch(...){
return false;
}
Classes como
std::ifstream e
std::ofstream são classes para entrada e saída em arquivos que implementam a técnica de RAII. Compare as duas maneiras de gravar meu nome num arquivo: em C com <stdio.h> e em C++ com <fstream> (sem tratamento de erros).
#include <stdio.h>
int main(void){
FILE *fp=fopen("/tmp/nome.txt", "w"); // Lembrando que o modo "w" provoca truncamento do arquivo.
fputs("Paulo\n", fp);
fclose(fp);
}
#include <fstream>
int main(){
std::ofstream("/tmp/nome.txt", std::ios::trunc) << "Paulo\n";
}
Na versão em C++ eu nem precisei de uma variável para associar o objeto que representa o arquivo. Graças ao RAII, um objeto temporário é construído com um construtor que recebe o nome do arquivo e a instrução de criar/truncar o arquivo, é usado como operando esquerdo de
<< e, ao final da expressão, quando o temporário sai do seu contexto de existência, é automaticamente desconstruído, o que implica fechar o arquivo.
Quanto a razões para preferir
std::ofstream::open () sobre a abertura feita automaticamente pelo construtor e
std::ofstream::close () sobre o fechamento feito implicitamente pelo destrutor, há algumas razões que me ocorrem agora. Entre outros possíveis motivos (que não me ocorrem agora), eis alguns:
•
Para permitir o emprego de exceções para tratamento de erros. Apesar de ter a característica de RAII, as classes de
streams de arquivos não costumam usar exceções para sinalizar erros, até porque uma eventual “falha” em determinadas operações não caracteriza uma situação realmente excepcional (por exemplo: quando você chega ao final de um arquivo aberto para leitura, ou quanto você pede para abrir um arquivo sobre o qual não tem permissão de acesso). Mesmo assim, você pode pedir que o objeto use exceções para comunicar algumas situações de falha, mas só depois que o objeto já estiver construído. Assim sendo, se você quiser que a abertura do arquivo sinalize erro por meio de uma exceção, tem de criar o objeto sem associá-lo a arquivo nenhum, chamar a função que habilita o envio de exceções sobre esse objeto, e então abrir o arquivo explicitamente.
•
Para poder tratar erros na hora de fechar o arquivo. É possível que determinadas operações, sobretudo em arquivos abertos para escrita, só sinalizem erro na hora de fechar o arquivo (por exemplo: você mandou escrever dados no arquivo, mas o volume não foi suficiente para estourar o
cache do sistema operacional, de modo que o SO só tentou gravar efetivamente em disco quando você mandou fechar o arquivo, mas nessa hora faltou espaço livre ou deu algum erro de disco). O fechamento automático por meio do destrutor não vai reportar esse erro (mesmo que você esteja com exceções habilitadas, uma vez que os destrutores do C++ não podem deixar vazar exceções). Se você quiser garantia de integridade dos dados após o fechamento, tem de chamar
std::stream::close () explicitamente.
•
Porque nem sempre você tem o nome do arquivo na hora em que o objeto é criado. Um exemplo que me vem à mente é quando você tem um
array ou um
container da biblioteca padrão cujos elementos sejam
streams ou tipos compostos contendo
streams , que são preenchidos (abertos) ao longo da execução do programa. Nesses casos, os objetos correspondentes a cada elemento são criados, no momento da alocação do
array ou
container , com o construtor
default , que não associa nenhum arquivo a esse objeto, que são posteriormente associados a arquivos por meio de chamadas a função-membro
open ().
Qual forma você deve preferir no seu programa vai depender da característica do problema que você estiver tratando. Se você não pretende, por exemplo, tratar erros na hora de fechar o objeto do tipo
std::ofstream , não precisa chamar explicitamente
std::ofstream::close () sobre ele, mas pode perfeitamente deixar que o destrutor cuide de liberá-lo. Minha sugestão e que você prefira sempre a forma mais simples, desde que a tal forma seja suficiente para atender suas necessidades.
... Então Jesus afirmou de novo: “(...) eu vim para que tenham vida, e a tenham plenamente.” (João 10:7-10)