1. Contrato honesto
Quando você vê pela primeira vez um erro de compilação “uso de função deletada” ou “construtor de cópia não disponível”, pode parecer que o compilador está simplesmente sendo chato. Na verdade, o compilador nesse momento atua como um guardião rigoroso: ele não permite que você faça algo que muito provavelmente levaria a um bug. Um tipo non-copyable é um tipo para o qual a cópia é proibida. Não é proibido porque “o autor preguiçou”, mas porque a cópia quebra o sentido.
A ideia mais importante: proibir a cópia é parte da interface do tipo. É um elemento de design tão relevante quanto “a função tem dois parâmetros e não cinco”. Você decide antecipadamente: “este objeto não pode ser ‘xerocado’; deve ser tratado como uma única instância”.
Para evitar confusões terminológicas, vamos registrar uma pequena tabela. Ela não é sobre sintaxe, mas sobre significado.
| Situação no modelo | O que aconteceria com a cópia | Portanto o tipo… |
|---|---|---|
| Ownership único de um recurso (memória, descritor, "handle") | Dois donos passam a acreditar que o recurso é deles | …são non-copyable |
| Identidade única (um “cartão de acesso”, uma “sessão”) | Surgem “duas identidades idênticas” | …são non-copyable |
| Exclusividade tipo mutex (um “tranco”) | Surgem duas “cópias do tranco” que não sincronizam a mesma coisa | …são non-copyable |
Agora vamos analisar três cenários típicos. E sim, em todos os três casos a linguagem C++ nos ajuda porque o erro aparece na compilação, não “daqui a dois dias no servidor às 03:00”.
2. Cenário nº1: unique ownership — “deve haver um só dono”
Se você já segurou na mão a chave do apartamento, já entende unique ownership: você pode entregar a chave a outra pessoa, mas se simplesmente fizer uma cópia “do nada”, as regras do jogo mudam. Em programação é a mesma coisa: alguns objetos, por definição, devem ter exatamente um dono.
Em C++ o exemplo mais conhecido de ownership único é std::unique_ptr. Ele literalmente diz: “eu tenho ownership do objeto, não pode haver dois donos”. Por isso std::unique_ptr não é copiável — e se você colocá‑lo como campo de uma struct, a struct também se torna non-copyable automaticamente (é o Rule of Zero em ação: o comportamento do tipo decorre do comportamento dos campos).
Mini-exemplo: um dono de um inteiro na heap (sim, um inteiro é um recurso didático, mas o princípio fica claro).
#include <memory>
struct IntOwner {
std::unique_ptr<int> p;
};
int main() {
IntOwner a{std::make_unique<int>(42)};
// IntOwner b = a; // erro de compilação: cópia proibida
}
Aqui não importa que o recurso seja um int. O importante é que possuímos algo que não pode ser “multiplicado” com segurança por padrão. É isso que o non-copyable expressa.
Às vezes um iniciante tenta “contornar a proibição”, porque “precisa passar o objeto para uma função”. Aqui entra uma ideia madura: se o objeto é o dono, a função deve declarar claramente o que faz com ele. Se a função apenas lê — aceita const&. Se a função altera o dono — aceita &. Se a função tentar receber o dono por valor (o que exigiria uma cópia), o design da função não condiz com o modelo de ownership.
Uma pequena demonstração ao nível das assinaturas (sem truques):
void observe(const IntOwner& o) {
(void)o; // lemos, mas não copiamos
}
void modify(IntOwner& o) {
(void)o; // modificamos, mas não copiamos
}
int main() {
IntOwner a{std::make_unique<int>(10)};
observe(a); // OK
modify(a); // OK
}
É útil guardar uma regra simples: se o tipo é non-copyable, passe-o por referência (ou por ponteiro, se aceitar ausência de valor — mas isso é outro contrato de design).
3. Cenário nº2: tipos de arquivo e ownership de descritor
Arquivo é um excelente exemplo de recurso que vive “fora” do seu programa. Ao abrir um arquivo para escrita você recebe uma espécie de “handle” para o recurso do sistema. E aí vem a magia do SO: cursor de escrita, buffers, permissões, locks, erros… Não é algo que você queira copiar por engano e obter duas entidades que pensam que gerenciam a mesma coisa.
Na biblioteca padrão, streams de arquivos (std::ifstream, std::ofstream) não são copiáveis. Faz sentido: “copiar a stream” é semântica duvidosa. Portanto qualquer tipo que contenha uma stream de arquivo normalmente também se torna non-copyable automaticamente.
Vamos ligar isso ao nosso app didático. Imagine que escrevemos um programa de console simples chamado MiniNotes: ele armazena notas em memória (em um vector) e as imprime. Hoje vamos adicionar logging em arquivo: “add”, “list”, “remove”. O log é uma “caixa-preta”: deve haver um só e verdadeiro.
Façamos um logger simples que possui um std::ofstream:
#include <fstream>
#include <string>
struct FileLogger {
std::ofstream out;
explicit FileLogger(const std::string& path) : out(path) {}
void log(const std::string& msg) {
out << msg << '\n';
}
};
O código parece inocente, mas tem uma propriedade importante: FileLogger não será copiável, porque std::ofstream não é copiável. E isso é bom: não queremos que alguém crie outro logger “para a mesma handle” com estado indefinido.
Agora vejamos como usar isso no MiniNotes. Não vamos implementar toda a interface, apenas o ponto-chave: passamos o logger por referência.
#include <iostream>
#include <string>
#include <vector>
struct Note {
int id{};
std::string text;
};
void add_note(std::vector<Note>& notes, FileLogger& lg, const std::string& text) {
int new_id = static_cast<int>(notes.size()) + 1;
notes.push_back(Note{new_id, text});
lg.log("add id=" + std::to_string(new_id)); // gravado no arquivo
}
E um exemplo de uso:
#include <iostream>
#include <vector>
int main() {
FileLogger lg{"app.log"};
std::vector<Note> notes;
add_note(notes, lg, "Buy milk");
std::cout << notes.size() << '\n'; // 1
}
Observe o efeito arquitetural: o logger vira um recurso central que não copiamos nem espalhamos. Ou o mantemos como campo no “contexto da aplicação”, ou o passamos por referência onde necessário.
Se você pensa agora: “E se eu precisar de dois loggers?” — tudo bem. Mas dois loggers devem ser dois objetos distintos, criados conscientemente: por exemplo, um escreve em "app.log", outro em "error.log". Isso não é uma cópia, são dois recursos diferentes.
Às vezes é útil explicitar a proibição de cópia, mesmo que ela ocorra automaticamente por causa dos campos. Isso torna a mensagem do compilador mais clara e deixa o contrato do tipo óbvio:
#include <fstream>
#include <string>
struct FileLogger {
std::ofstream out;
explicit FileLogger(const std::string& path) : out(path) {}
FileLogger(const FileLogger&) = delete;
FileLogger& operator=(const FileLogger&) = delete;
};
Sim, é um pouco redundante, mas ajuda iniciantes: você literalmente vê “não pode copiar” escrito no código.
4. Cenário nº3: tipos semelhantes a mutex — “exclusividade não se copia”
A palavra “mutex” no título pode assustar se você ainda não estudou concorrência. E tudo bem: não vamos falar de threads e sincronização em profundidade. Precisamos apenas da imagem: há um objeto que garante exclusividade. Exclusividade significa que só um pode entrar “por dentro”.
Mesmo em um mundo single-threaded isso aparece com frequência. Por exemplo, você tem um “cartão de acesso” para a sala do servidor. Existe apenas um. Se você copiar, o sentido se perde: agora há dois cartões de acesso e alguém pode entrar onde não deveria. Em programação um “cartão” costuma ser um objeto que representa o direito de realizar uma ação.
Vamos modelar esse objeto com um tipo simples AccessCard. Ele mesmo é barato (apenas um int), mas por semântica não pode ser copiado, porque o ID deve ser único e exclusivo.
struct AccessCard {
int id{};
explicit AccessCard(int v) : id(v) {}
AccessCard(const AccessCard&) = delete;
AccessCard& operator=(const AccessCard&) = delete;
};
Agora façamos a “sala” que só pode ser acessada com o cartão. E deliberadamente tornamos o parâmetro por referência, para que ninguém passe uma cópia por engano.
#include <iostream>
void enter_server_room(const AccessCard& card) {
std::cout << "Entered with card #" << card.id << '\n'; // Entered with card #7
}
Exemplo de uso:
int main() {
AccessCard card{7};
enter_server_room(card);
// AccessCard copy = card; // erro: cópia proibida
}
A ideia não é que o int seja um tipo “ruim”. A ideia é que expressamos a regra de negócio pela linguagem: “o cartão não pode ser copiado”. E o compilador vira seu aliado: ele não permite que alguém escreva código que viole o modelo de segurança.
Se você olhar na biblioteca padrão por tipos parecidos com “trancas” ou “sincronizadores”, muito provavelmente também são non-copyable. Pela mesma razão: “copiar o tranco” não cria um segundo tranco válido; na maioria das vezes faz pouco sentido.
5. Non-copyable na aplicação: contexto, referências e limites de ownership
Quando um objeto non-copyable aparece no projeto, o iniciante faz duas perguntas típicas. Primeiro: “Como eu passo isso entre funções agora?” Segundo: “Onde eu devo armazenar isso?” Ambas se resolvem com uma ideia: o recurso non-copyable deve ter um dono claro, e o restante do código deve operar através de referências.
Voltemos ao MiniNotes e façamos um “contexto da aplicação” que guarda tanto os dados quanto os recursos. É conveniente: no main você cria tudo de uma vez e depois passa um único objeto “contexto” por referência.
#include <vector>
struct AppContext {
FileLogger logger;
std::vector<Note> notes;
explicit AppContext(const std::string& log_path)
: logger(log_path) {}
};
Agora as funções trabalham com AppContext&:
#include <string>
void add_note(AppContext& app, const std::string& text) {
int new_id = static_cast<int>(app.notes.size()) + 1;
app.notes.push_back(Note{new_id, text});
app.logger.log("add id=" + std::to_string(new_id));
}
E o main:
int main() {
AppContext app{"app.log"};
add_note(app, "Read C++ book");
}
O que essa organização fornece?
Ela torna objetos “recursivos” (logger, conexão, token exclusivo) parte de uma raiz de ownership explícita. Essa raiz é criada no main e vive até o fim do programa. Portanto referências para seus campos são seguras dentro de limites razoáveis de uso.
Mas é muito importante não “remediar” tipos non-copyable com gambiarras. Por exemplo, não pense: “Se não posso copiar, vou guardar um ponteiro cru e usá-lo em todo lugar”. Ponteiro copia endereço, sim — mas você não resolveu “quem é o dono” e “quando o objeto será destruído”. Isso frequentemente leva a dangling pointers. No nosso contexto atual, a abordagem mais amigável para iniciantes é usar referências e um dono claro.
6. Erros do compilador sobre copia deletada
Erros de compilação são como mensagens de um amigo honesto. Nem sempre são educados ou curtos, mas geralmente dizem a verdade. Quando um tipo é non-copyable, você normalmente verá uma de duas situações.
Primeira situação — você mesmo proibiu a cópia via = delete, e o compilador dirá que você está tentando usar uma função deletada. Aparecerá algo como (em essência, não texto exato): “use of deleted function AccessCard::AccessCard(const AccessCard&)”.
Segunda situação — você não proibiu a cópia manualmente, mas ela foi proibida “sozinha” porque um dos campos não é copiável (por exemplo, std::ofstream ou std::unique_ptr). Nesse caso a mensagem será parecida: “copy constructor is implicitly deleted”, e o compilador aponta qual campo causou o problema. Isso é muito útil: o compilador basicamente diz “veja, você está tentando copiar o dono do recurso”.
Quase sempre o algoritmo de conserto é o mesmo: localize onde você está passando/retornando o objeto por valor e troque por passagem por referência.
Por exemplo, não se deve escrever isto (porque o parâmetro por valor exige cópia):
void bad(FileLogger lg) {
(void)lg;
}
Já isto é adequado:
void good(FileLogger& lg) {
lg.log("hello");
}
Vale a pausa filosófica: tipos non-copyable forçam você a escrever assinaturas mais honestas. Se uma função pede “me dê o objeto por valor”, ela está quase prometendo que precisa de uma cópia independente. Se a cópia é impossível, a promessa estava errada.
7. Erros típicos
Erro nº1: proibem a cópia “pela metade” (apenas o construtor ou apenas a atribuição).
Às vezes escrevem T(const T&) = delete;, mas esquecem do operator=(const T&). Como resultado, um cenário de cópia fica proibido e o outro permanece formalmente possível (ou sua tentativa resulta em mensagem estranha). Para iniciantes o hábito mais seguro é proibir ambas as operações, para que o tipo seja consistentemente non-copyable.
Erro nº2: continuam aceitando tipos non-copyable por valor “por hábito”.
É muito fácil escrever void f(T t) automaticamente, porque assim fazíamos para int e std::string. Mas para tipos non-copyable isso não é apenas “caro”, é impossível. O passo correto é parar e escolher o contrato: const T& para leitura, T& para mutação. Isso torna o código compilável e mais claro.
Erro nº3: tentam “contornar” a proibição usando ponteiros crus em vez de um design normal de ownership.
Quando a cópia é proibida, surge a tentação de guardar T*, “porque ponteiro é copiável”. Sim, o endereço é copiado — mas você não resolveu “quem é o dono” e “quando o objeto será destruído”. Muitas vezes isso leva a situações onde o objeto foi destruído e ponteiros para ele ainda circulam. No contexto de hoje a estratégia mais segura é: um dono + referências.
Erro nº4: fazem o tipo non-copyable sem motivo e se surpreendem que “ficou inconveniente”.
Non-copyable é uma restrição forte e deve ser justificada pelo modelo. Se o objeto logicamente é um valor (como Point, User, Report de aulas anteriores), proibir a cópia só complica: você terá que usar referências por todo lado, pensar em tempo de vida e gastar atenção. Proíba cópia quando a cópia realmente quebra o sentido: ownership único, identidade única, exclusividade.
Erro nº5: confundem = delete (função deletada) com delete (liberação de memória).
Por causa da mesma palavra, às vezes iniciantes “colam” os conceitos. T(const T&) = delete; não libera memória nem “remove o objeto”; é apenas a proibição de chamar a função em tempo de compilação. Já delete p; é a operação de desalocar memória (e já discutimos por que gerenciar memória manualmente é perigoso). São dois mundos diferentes com a mesma placa.
GO TO FULL VERSION