RAII e ponteiros inteligentes em C ++

193

Na prática com C ++, o que é RAII , o que são indicadores inteligentes , como eles são implementados em um programa e quais são os benefícios de usar o RAII com indicadores inteligentes?

Rob Kam
fonte

Respostas:

317

Um exemplo simples (e talvez em excesso) de RAII é uma classe File. Sem RAII, o código pode ser algo como isto:

File file("/path/to/file");
// Do stuff with file
file.close();

Em outras palavras, devemos garantir que fechemos o arquivo assim que terminarmos. Isso tem duas desvantagens - primeiro, onde quer que usemos File, teremos que chamar File :: close () - se esquecermos de fazer isso, manteremos o arquivo por mais tempo do que precisamos. O segundo problema é o que acontece se uma exceção é lançada antes de fecharmos o arquivo?

Java resolve o segundo problema usando uma cláusula finally:

try {
    File file = new File("/path/to/file");
    // Do stuff with file
} finally {
    file.close();
}

ou desde o Java 7, uma instrução try-with-resource:

try (File file = new File("/path/to/file")) {
   // Do stuff with file
}

C ++ resolve os dois problemas usando RAII - ou seja, fechando o arquivo no destruidor de File. Desde que o objeto File seja destruído no momento certo (o que deveria ser de qualquer maneira), o fechamento do arquivo será resolvido por nós. Portanto, nosso código agora se parece com:

File file("/path/to/file");
// Do stuff with file
// No need to close it - destructor will do that for us

Isso não pode ser feito em Java, pois não há garantia de quando o objeto será destruído, portanto, não podemos garantir quando um recurso como arquivo será liberado.

Para ponteiros inteligentes - na maioria das vezes, apenas criamos objetos na pilha. Por exemplo (e roubar um exemplo de outra resposta):

void foo() {
    std::string str;
    // Do cool things to or using str
}

Isso funciona bem - mas e se quisermos retornar str? Poderíamos escrever isso:

std::string foo() {
    std::string str;
    // Do cool things to or using str
    return str;
}

Então, o que há de errado nisso? Bem, o tipo de retorno é std :: string - então isso significa que estamos retornando por valor. Isso significa que copiamos str e realmente retornamos a cópia. Isso pode ser caro e podemos evitar o custo de copiá-lo. Portanto, podemos ter a idéia de retornar por referência ou por ponteiro.

std::string* foo() {
    std::string str;
    // Do cool things to or using str
    return &str;
}

Infelizmente, esse código não funciona. Estamos retornando um ponteiro para str - mas str foi criado na pilha, portanto seremos excluídos quando sairmos de foo (). Em outras palavras, quando o chamador recebe o ponteiro, ele é inútil (e sem dúvida pior do que inútil, pois usá-lo pode causar todos os tipos de erros estranhos)

Então, qual é a solução? Poderíamos criar str no heap usando new - assim, quando foo () for concluído, o str não será destruído.

std::string* foo() {
    std::string* str = new std::string();
    // Do cool things to or using str
    return str;
}

Obviamente, essa solução também não é perfeita. O motivo é que criamos str, mas nunca o excluímos. Isso pode não ser um problema em um programa muito pequeno, mas, em geral, queremos ter certeza de que o excluiremos. Poderíamos dizer que o chamador deve excluir o objeto depois que ele terminar. A desvantagem é que o chamador precisa gerenciar a memória, o que aumenta a complexidade e pode causar erros, levando a um vazamento de memória, ou seja, não excluindo objetos, mesmo que não seja mais necessário.

É aqui que entram os ponteiros inteligentes. O exemplo a seguir usa shared_ptr - sugiro que você analise os diferentes tipos de ponteiros inteligentes para aprender o que realmente deseja usar.

shared_ptr<std::string> foo() {
    shared_ptr<std::string> str = new std::string();
    // Do cool things to or using str
    return str;
}

Agora, shared_ptr contará o número de referências a str. Por exemplo

shared_ptr<std::string> str = foo();
shared_ptr<std::string> str2 = str;

Agora, existem duas referências à mesma sequência. Quando não houver referências restantes para str, ele será excluído. Como tal, você não precisa mais se preocupar com a exclusão.

Edição rápida: como alguns dos comentários apontaram, este exemplo não é perfeito por (pelo menos!) Duas razões. Em primeiro lugar, devido à implementação de strings, copiar uma string tende a ser barato. Em segundo lugar, devido ao que é conhecido como otimização do valor de retorno nomeado, o retorno por valor pode não ser caro, uma vez que o compilador pode ser inteligente para acelerar as coisas.

Então, vamos tentar um exemplo diferente usando nossa classe File.

Digamos que queremos usar um arquivo como um log. Isso significa que queremos abrir nosso arquivo no modo somente acréscimo:

File file("/path/to/file", File::append);
// The exact semantics of this aren't really important,
// just that we've got a file to be used as a log

Agora, vamos definir nosso arquivo como o log de alguns outros objetos:

void setLog(const Foo & foo, const Bar & bar) {
    File file("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Infelizmente, este exemplo termina horrivelmente - o arquivo será fechado assim que esse método terminar, o que significa que foo e bar agora têm um arquivo de log inválido. Nós poderíamos construir o arquivo na pilha e passar um ponteiro para o arquivo foo e bar:

void setLog(const Foo & foo, const Bar & bar) {
    File* file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Mas quem é responsável pela exclusão do arquivo? Se nenhum dos arquivos for excluído, haverá um vazamento de memória e recurso. Não sabemos se foo ou bar terminará primeiro com o arquivo; portanto, não podemos esperar que eles sejam excluídos. Por exemplo, se foo excluir o arquivo antes que a barra termine, agora a barra possui um ponteiro inválido.

Então, como você deve ter adivinhado, poderíamos usar indicadores inteligentes para nos ajudar.

void setLog(const Foo & foo, const Bar & bar) {
    shared_ptr<File> file = new File("/path/to/file", File::append);
    foo.setLogFile(file);
    bar.setLogFile(file);
}

Agora, ninguém precisa se preocupar em excluir um arquivo - depois que o foo e a barra terminarem e não tiver mais referências ao arquivo (provavelmente devido à destruição do foo e da barra), o arquivo será excluído automaticamente.

Michael Williamson
fonte
7
Deve-se notar que muitas implementações de strings são implementadas em termos de um ponteiro contado de referência. Essas semânticas de copiar na gravação tornam o retorno de uma string por valor realmente barato.
7
Mesmo para os que não são, muitos compiladores implementam a otimização de NRV, que cuidaria da sobrecarga. Em geral, acho o shared_ptr raramente útil - basta ficar com o RAII e evitar a propriedade compartilhada.
Nemanja Trifunovic
27
retornar uma string não é realmente um bom motivo para usar ponteiros inteligentes. a otimização do valor de retorno pode otimizar facilmente o retorno, e a semântica c ++ 1x move eliminará completamente uma cópia (quando usada corretamente). Mostre algum exemplo do mundo real (por exemplo, quando nós compartilhamos o mesmo recurso) em vez :)
Johannes Schaub - litb
1
Acho que a sua conclusão desde o início sobre por que o Java não pode fazer isso carece de clareza. A maneira mais fácil de descrever essa limitação em Java ou C # é porque não há como alocar na pilha. O C # permite a alocação de pilha por meio de uma palavra-chave especial, no entanto, você perde a segurança do tipo.
ApplePieIsGood
4
@ Nemanja Trifunovic: Por RAII, nesse contexto, você quer dizer devolver cópias / criar objetos na pilha? Isso não funciona se você devolver / aceitar objetos de tipos que podem ser subclassificados. Então você tem que usar um ponteiro para evitar fatiar o objeto, e eu argumentaria que um ponteiro inteligente é melhor do que um ponteiro bruto nesses casos.
precisa saber é o seguinte
141

RAII Este é um nome estranho para um conceito simples, mas impressionante. Melhor é o nome Gerenciamento de recursos vinculados ao escopo (SBRM). A idéia é que, com frequência, você aloca recursos no início de um bloco e precisa liberá-lo na saída de um bloco. A saída do bloco pode ocorrer pelo controle de fluxo normal, saltando dele e até mesmo por uma exceção. Para cobrir todos esses casos, o código se torna mais complicado e redundante.

Apenas um exemplo fazendo isso sem o SBRM:

void o_really() {
     resource * r = allocate_resource();
     try {
         // something, which could throw. ...
     } catch(...) {
         deallocate_resource(r);
         throw;
     }
     if(...) { return; } // oops, forgot to deallocate
     deallocate_resource(r);
}

Como você vê, existem muitas maneiras pelas quais podemos nos envolver. A idéia é que encapsulemos o gerenciamento de recursos em uma classe. A inicialização do seu objeto adquire o recurso ("Aquisição de recursos é inicialização"). No momento em que saímos do bloco (escopo do bloco), o recurso é liberado novamente.

struct resource_holder {
    resource_holder() {
        r = allocate_resource();
    }
    ~resource_holder() {
        deallocate_resource(r);
    }
    resource * r;
};

void o_really() {
     resource_holder r;
     // something, which could throw. ...
     if(...) { return; }
}

Isso é bom se você tiver classes próprias que não sejam apenas para alocar / desalocar recursos. A alocação seria apenas uma preocupação adicional para realizar seu trabalho. Porém, assim que você deseja alocar / desalocar recursos, o exposto acima se torna impraticável. Você precisa escrever uma classe de empacotamento para todo tipo de recurso que adquirir. Para facilitar, ponteiros inteligentes permitem automatizar esse processo:

shared_ptr<Entry> create_entry(Parameters p) {
    shared_ptr<Entry> e(Entry::createEntry(p), &Entry::freeEntry);
    return e;
}

Normalmente, ponteiros inteligentes são invólucros finos em torno de novos / excluídos que são chamados deletequando o recurso que eles possuem sai do escopo. Alguns indicadores inteligentes, como shared_ptr, permitem que você diga o chamado deleter, que é usado em vez de delete. Isso permite, por exemplo, gerenciar identificadores de janela, recursos de expressão regular e outras coisas arbitrárias, desde que você informe o shared_ptr sobre o deleter correto.

Existem diferentes indicadores inteligentes para diferentes propósitos:

unique_ptr

é um ponteiro inteligente que possui um objeto exclusivamente. Não está no impulso, mas provavelmente aparecerá no próximo padrão C ++. É não copiável, mas suporta transferência de propriedade . Algum código de exemplo (próximo C ++):

Código:

unique_ptr<plot_src> p(new plot_src); // now, p owns
unique_ptr<plot_src> u(move(p)); // now, u owns, p owns nothing.
unique_ptr<plot_src> v(u); // error, trying to copy u

vector<unique_ptr<plot_src>> pv; 
pv.emplace_back(new plot_src); 
pv.emplace_back(new plot_src);

Diferente do auto_ptr, o unique_ptr pode ser colocado em um contêiner, porque os contêineres poderão conter tipos não copiáveis ​​(mas móveis), como fluxos e unique_ptr também.

scoped_ptr

é um ponteiro inteligente de impulso que não é copiável nem móvel. É a coisa perfeita a ser usada quando você deseja garantir que os ponteiros sejam excluídos ao sair do escopo.

Código:

void do_something() {
    scoped_ptr<pipe> sp(new pipe);
    // do something here...
} // when going out of scope, sp will delete the pointer automatically. 

shared_ptr

é para propriedade compartilhada. Portanto, é copiável e móvel. Várias instâncias de ponteiro inteligente podem possuir o mesmo recurso. Assim que o último ponteiro inteligente que possui o recurso ficar fora do escopo, o recurso será liberado. Alguns exemplos do mundo real de um dos meus projetos:

Código:

shared_ptr<plot_src> p(new plot_src(&fx));
plot1->add(p)->setColor("#00FF00");
plot2->add(p)->setColor("#FF0000");
// if p now goes out of scope, the src won't be freed, as both plot1 and 
// plot2 both still have references. 

Como você vê, a fonte de plotagem (função fx) é compartilhada, mas cada uma possui uma entrada separada, na qual definimos a cor. Há uma classe weak_ptr que é usada quando o código precisa se referir ao recurso pertencente a um ponteiro inteligente, mas não precisa ser o proprietário do recurso. Em vez de passar um ponteiro bruto, você deve criar um fraca_ptr. Ele emitirá uma exceção quando perceber que você tenta acessar o recurso por um caminho de acesso weak_ptr, mesmo que não haja mais shared_ptr que possua o recurso.

Johannes Schaub - litb
fonte
Até onde eu sei, objetos não copiáveis ​​não são bons para serem usados ​​em contêineres stl, pois eles dependem da semântica de valores - o que acontece se você deseja classificar esse contêiner? tipo faz elementos de cópia ...
fmuecke
Os contêineres C ++ 0x serão alterados para que respeitem os tipos somente de movimentação unique_ptr, e sorttambém serão alterados da mesma forma.
Johannes Schaub - litb 21/10/09
Você se lembra de onde ouviu o termo SBRM pela primeira vez? James está tentando descobrir.
GManNickG
quais cabeçalhos ou bibliotecas devo incluir para usá-los? mais alguma leitura sobre isso?
AtoMerz 13/05
Um conselho aqui: se não houver uma resposta a uma pergunta C ++ por @ litb, é a resposta certa (não importa os votos ou a resposta marcada como "correta") ...
FNL
32

A premissa e as razões são simples, em conceito.

RAII é o paradigma de design para garantir que as variáveis ​​manipulem toda a inicialização necessária em seus construtores e toda a limpeza necessária em seus destruidores. Isso reduz toda inicialização e limpeza em uma única etapa.

O C ++ não requer RAII, mas é cada vez mais aceito que o uso de métodos RAII produzirá código mais robusto.

A razão pela qual a RAII é útil no C ++ é que o C ++ gerencia intrinsecamente a criação e a destruição de variáveis ​​à medida que elas entram e saem do escopo, seja por meio do fluxo normal de código ou pelo desenrolamento da pilha acionado por uma exceção. Isso é um brinde em C ++.

Ao vincular toda a inicialização e limpeza a esses mecanismos, você garante que o C ++ cuidará desse trabalho também.

Falar sobre RAII em C ++ geralmente leva à discussão de ponteiros inteligentes, porque os ponteiros são particularmente frágeis quando se trata de limpeza. Ao gerenciar a memória alocada a heap adquirida da malloc ou nova, geralmente é responsabilidade do programador liberar ou excluir essa memória antes que o ponteiro seja destruído. Ponteiros inteligentes usarão a filosofia RAII para garantir que os objetos alocados pelo heap sejam destruídos sempre que a variável do ponteiro for destruída.

Drew Dormann
fonte
Além disso - os indicadores são a aplicação mais comum do RAII - você provavelmente alocará milhares de vezes mais indicadores do que qualquer outro recurso.
Eclipse
8

Ponteiro inteligente é uma variação do RAII. RAII significa aquisição de recursos é inicialização. O ponteiro inteligente adquire um recurso (memória) antes do uso e o joga fora automaticamente em um destruidor. Duas coisas acontecem:

  1. Alocamos memória antes de usá-la, sempre, mesmo quando não queremos - é difícil fazer outra maneira com um ponteiro inteligente. Se isso não estivesse acontecendo, você tentaria acessar a memória NULL, resultando em uma falha (muito dolorosa).
  2. Liberamos memória mesmo quando há um erro. Não resta memória pendurada.

Por exemplo, outro exemplo é o soquete de rede RAII. Nesse caso:

  1. Abrimos o soquete de rede antes de usá-lo, sempre, mesmo quando não sentimos - é difícil fazê-lo de outra maneira com o RAII. Se você tentar fazer isso sem o RAII, poderá abrir um soquete vazio, por exemplo, conexão do MSN. Em seguida, mensagens como "vamos fazê-lo hoje à noite" podem não ser transferidas, os usuários não vão transar e você pode correr o risco de ser demitido.
  2. Fechamos o soquete de rede mesmo quando há um erro. Nenhum soquete fica pendurado, pois isso pode impedir que a mensagem de resposta "com certeza esteja no fundo" retorne ao remetente.

Agora, como você pode ver, o RAII é uma ferramenta muito útil na maioria dos casos, pois ajuda as pessoas a transar.

As fontes C ++ de ponteiros inteligentes estão em milhões na rede, incluindo respostas acima de mim.

amadurecer
fonte
2

O Boost possui vários deles, incluindo os do Boost.Interprocess para memória compartilhada. Ele simplifica bastante o gerenciamento de memória, especialmente em situações que causam dor de cabeça, como quando você tem 5 processos compartilhando a mesma estrutura de dados: quando todo mundo termina com um pedaço de memória, você quer que ele seja liberado automaticamente e não precisa ficar parado tentando descobrir quem deve ser responsável por chamar deleteum pedaço de memória, para que não ocorra um vazamento de memória ou um ponteiro que seja liberado por engano duas vezes e possa corromper toda a pilha.

Jason S
fonte
0
void foo ()
{
   barra std :: string;
   //
   // mais código aqui
   //
}

Não importa o que aconteça, a barra será excluída corretamente assim que o escopo da função foo () for deixado para trás.

As implementações internamente std :: string geralmente usam ponteiros contados por referência. Portanto, a cadeia interna só precisa ser copiada quando uma das cópias das cadeias foi alterada. Portanto, um ponteiro inteligente contado como referência torna possível copiar apenas algo quando necessário.

Além disso, a contagem de referência interna torna possível que a memória seja excluída corretamente quando a cópia da sequência interna não for mais necessária.


fonte
1
void f () {Obj x; } Obj x é excluído por meio da criação / destruição de quadros de pilha (desenrolamento) ... não está relacionado à contagem de ref.
Hernán
A contagem de referência é um recurso da implementação interna da sequência. RAII é o conceito por trás da exclusão do objeto quando o objeto sai do escopo. A pergunta era sobre RAII e também indicadores inteligentes.
1
"Não importa o que aconteça" - o que acontece se uma exceção for lançada antes que a função retorne?
titaniumdecoy
Qual função é retornada? Se uma exceção for lançada em foo, a barra será excluída. O construtor padrão da barra que lança uma exceção seria um evento extraordinário.