C++ Semântica de Movimento
Na aula Cópia e Movimento, você viu que copiar um objeto pode dar muito trabalho: um std::vector com um milhão de textos precisa reservar memória nova e copiar cada texto. Mas muitas vezes o original nem vai ser usado de novo: ele está prestes a morrer, ou você só quer entregá-lo para outro lugar. Para que copiar, então? O C++11 trouxe a resposta: mover. Em vez de duplicar, o objeto novo simplesmente pega para si o que era do antigo. Nesta aula você vai ver a diferença em números reais, aprender a usar std::move e descobrir que, na maior parte do tempo, o compilador já move por você.
- A diferença entre copiar e mover, com uma comparação do dia a dia.
- O que são lvalue e rvalue (o suficiente para usar), e o
&&. - O que
std::movefaz (e o que ele não faz). - Como escrever um construtor de movimento e ver, com prints, quando ele roda.
- Que o objeto "movido" fica vazio, mas válido.
- Como mover objetos para dentro de um
std::vector, e por que onoexceptimporta. - Quanto tempo o movimento economiza, e quando o compilador já move sozinho.
Esta aula usa Cópia e Movimento, Referências e std::vector. Os programas foram compilados com o g++ 15 na máquina da Ana, na pasta ~/cpp, com g++ -std=c++23 -Wall -Wextra arquivo.cpp -o programa.
A ideia: mudança de casa
A Ana vai se mudar. Ela tem duas opções:
- Copiar: comprar um sofá igual, uma geladeira igual, uma cama igual, para a casa nova. A casa velha continua mobiliada. Caro e demorado.
- Mover: pôr os móveis no caminhão e levar para a casa nova. A casa velha fica vazia, mas continua sendo uma casa: dá para mobiliá-la de novo ou vendê-la. Rápido e barato.
Se a Ana não vai mais morar na casa velha, mover é óbvio. Em C++, é igual: quando o objeto original não vai mais ser usado, mover os recursos dele (a memória do texto, os itens do vetor) é muito mais barato que copiá-los.
1. Cópia × movimento, com prints
A classe Mochila guarda o nome do dono e uma lista de itens. Vamos escrever o construtor de cópia e o de movimento com um std::cout em cada um:
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Mochila {
public:
Mochila(std::string dono) : dono_{dono} {}
void guardar(std::string item) { itens_.push_back(item); }
std::size_t quantidade() const { return itens_.size(); }
// construtor de cópia: duplica tudo
Mochila(const Mochila& outra) : dono_{outra.dono_}, itens_{outra.itens_} {
std::cout << " [cópia] duplicou " << itens_.size() << " itens\n";
}
// construtor de movimento: pega tudo da outra
Mochila(Mochila&& outra) noexcept
: dono_{std::move(outra.dono_)}, itens_{std::move(outra.itens_)} {
std::cout << " [movimento] levou " << itens_.size() << " itens\n";
}
private:
std::string dono_;
std::vector<std::string> itens_;
};
int main() {
Mochila a{"Ana"};
a.guardar("caderno");
a.guardar("estojo");
a.guardar("garrafa");
std::cout << "Mochila b = a;\n";
Mochila b = a;
std::cout << "Mochila c = std::move(a);\n";
Mochila c = std::move(a);
std::cout << "a: " << a.quantidade() << " itens\n";
std::cout << "b: " << b.quantidade() << " itens\n";
std::cout << "c: " << c.quantidade() << " itens\n";
}
- Mochila(Mochila&& outra)
- O construtor de movimento. O
&&(dois "e comercial") é uma referência a rvalue: ela só aceita objetos que podem ser "esvaziados" (seção 2). Repare que não temconst: o construtor vai mexer naoutra, tirando as coisas dela. - noexcept
- Uma promessa: "este construtor não lança exceções". Mover só troca ponteiros por dentro, então não tem como falhar. A seção 5 mostra por que essa palavra faz diferença.
- dono_{std::move(outra.dono_)}
- Move cada atributo: a string e o vetor também têm construtores de movimento, que fazem o trabalho de verdade.
- std::move(a)
- No
main: "pode mover dea, eu não preciso mais dele". Precisa de#include <utility>.
$ g++ -std=c++23 -Wall -Wextra mochila.cpp -o mochila $ ./mochila Mochila b = a; [cópia] duplicou 3 itens Mochila c = std::move(a); [movimento] levou 3 itens a: 0 itens « a mochila da Ana ficou vazia b: 3 itens « b é uma cópia c: 3 itens « c levou os itens originais
Sem std::move, o C++ copiou; com std::move, moveu. E a mochila a ficou com 0 itens: é a "casa vazia" depois da mudança.
Esta Mochila só tem os construtores de cópia e de movimento, porque o objetivo é ver quando cada um roda. Uma classe completa teria também as duas atribuições (operator= de cópia e de movimento), como manda a regra dos cinco. Na prática, pela regra do zero, você não escreveria nenhum: com atributos std::string e std::vector, o compilador gera os cinco, e o movimento automático faz exatamente o que o nosso faz (sem os prints).
Experimente: rode e confira a cópia e o movimento. Depois acrescente a.guardar("lanche"); depois do std::move(a) e mostre a quantidade de a de novo: a mochila vazia pode ser usada outra vez? Tente também Mochila d = std::move(c);.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Mochila {
public:
Mochila(std::string dono) : dono_{dono} {}
void guardar(std::string item) { itens_.push_back(item); }
std::size_t quantidade() const { return itens_.size(); }
Mochila(const Mochila& outra) : dono_{outra.dono_}, itens_{outra.itens_} {
std::cout << " [cópia] duplicou " << itens_.size() << " itens\n";
}
Mochila(Mochila&& outra) noexcept
: dono_{std::move(outra.dono_)}, itens_{std::move(outra.itens_)} {
std::cout << " [movimento] levou " << itens_.size() << " itens\n";
}
private:
std::string dono_;
std::vector<std::string> itens_;
};
int main() {
Mochila a{"Ana"};
a.guardar("caderno");
a.guardar("estojo");
Mochila b = a;
Mochila c = std::move(a);
std::cout << "a: " << a.quantidade() << ", b: " << b.quantidade()
<< ", c: " << c.quantidade() << '\n';
}
2. lvalue e rvalue (o suficiente para usar)
Como o C++ decide entre copiar e mover? Olhando para o tipo de valor que está do lado direito. São dois, com nomes que vêm de "lado esquerdo" (left) e "lado direito" (right) de uma atribuição:
| lvalue | rvalue | |
|---|---|---|
| O que é | Algo com nome e endereço, que continua existindo depois da linha | Um valor temporário, sem nome, que morre no fim da linha |
| Exemplos | nome, a, lista[0] | nome + " Souza", criar(), "Bia" virando std::string, 42 |
| Pode mover dele? | Não, a não ser que você peça (std::move) | Sim: ninguém mais vai usá-lo |
| Referência que aceita | T& e const T& | T&& e const T& |
Duas funções com o mesmo nome, uma recebendo const std::string& e outra std::string&&, mostram qual é qual:
#include <iostream>
#include <string>
#include <utility>
void receber(const std::string& s) {
std::cout << "lvalue (tem nome, vou copiar): " << s << '\n';
}
void receber(std::string&& s) {
std::cout << "rvalue (temporário, posso pegar): " << s << '\n';
}
std::string nome_completo() {
return "Ana Souza";
}
int main() {
std::string nome = "Ana";
receber(nome); // variável com nome
receber(nome + " Souza"); // resultado de uma conta
receber(nome_completo()); // retorno de função
receber("Bia"); // texto literal vira string temporária
receber(std::move(nome)); // "pode tratar como temporário"
}
$ g++ -std=c++23 -Wall -Wextra valores.cpp -o valores $ ./valores lvalue (tem nome, vou copiar): Ana rvalue (temporário, posso pegar): Ana Souza rvalue (temporário, posso pegar): Ana Souza rvalue (temporário, posso pegar): Bia rvalue (temporário, posso pegar): Ana « o std::move transformou nome em rvalue
O compilador escolheu a versão && para todos os temporários, sozinho. Só a variável nome foi tratada como lvalue, até o std::move(nome) da última linha.
std::move não move nada
O nome engana. std::move(x) não copia, não move e não apaga: ele só muda o rótulo de x de lvalue para rvalue, como quem cola um aviso "pode levar". Quem move de verdade é o construtor (ou a atribuição) de movimento que roda depois, por causa do rótulo. Se ninguém aproveitar o aviso (como na função receber, que só imprime), nome continua intacto.
3. O objeto movido: vazio, mas válido
O que sobra de um objeto depois que alguém se mudou dele? Veja com uma std::string:
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string texto = "Lista de compras: arroz, feijão, café";
std::string outro = std::move(texto);
std::cout << "outro: [" << outro << "]\n";
std::cout << "texto: [" << texto << "] tamanho " << texto.size() << '\n';
texto = "dá para usar de novo";
std::cout << "texto: [" << texto << "]\n";
}
$ g++ -std=c++23 -Wall -Wextra movida.cpp -o movida $ ./movida outro: [Lista de compras: arroz, feijão, café] texto: [] tamanho 0 « vazia, mas ainda é uma string que funciona texto: [dá para usar de novo]
A regra oficial do C++ para os tipos da biblioteca padrão é: o objeto movido fica num estado "válido, mas não especificado". Válido: você pode destruí-lo, atribuir um valor novo, perguntar o tamanho. Não especificado: o padrão não promete o que tem dentro. Na prática, std::string, std::vector e std::unique_ptr ficam vazios (o unique_ptr vira nullptr). A regra segura:
std::move(x), só faça duas coisas com x
- dar a ele um valor novo (
x = …;); ou - deixá-lo morrer no fim do escopo.
Ler o conteúdo de um objeto movido é um erro de lógica, mesmo quando "funciona".
Como "pegar para si" funciona por dentro
Uma std::string longa guarda o texto num pedaço de memória separado e tem só um ponteiro para ele. Mover é copiar o ponteiro e zerar o da origem, sem tocar no texto. Veja o mesmo truque escrito à mão, na classe Notas da aula anterior (com ponteiro cru, só para entender; no dia a dia, regra do zero):
// construtor de movimento: rouba o array e deixa a outra vazia
Notas(Notas&& outra) noexcept
: tamanho_{outra.tamanho_}, dados_{outra.dados_} {
outra.tamanho_ = 0;
outra.dados_ = nullptr; // a outra não vai apagar mais nada
}
// atribuição de movimento: devolve o antigo e rouba o novo
Notas& operator=(Notas&& outra) noexcept {
if (this != &outra) {
delete[] dados_;
tamanho_ = outra.tamanho_;
dados_ = outra.dados_;
outra.tamanho_ = 0;
outra.dados_ = nullptr;
}
return *this;
}
$ g++ -std=c++23 -Wall -Wextra -g -fsanitize=address roubar.cpp -o roubar $ ./roubar ana: 0, bia: 3 « Notas bia = std::move(ana); bia: 0, caio: 3 « caio = std::move(bia);
O outra.dados_ = nullptr; é o passo essencial: sem ele, as duas teriam o mesmo array, e o double free da aula anterior voltaria. Com ele, o destrutor da origem faz delete[] nullptr, que não faz nada. O AddressSanitizer não encontrou nenhum problema.
4. Por que mover deixa o programa mais rápido
Quanto custa copiar um vetor com um milhão de textos, comparado a movê-lo? O <chrono> (aula Tempo e Datas) mede:
#include <chrono>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
int main() {
using relogio = std::chrono::steady_clock;
std::vector<std::string> estoque(1'000'000, "produto da lojinha da Ana");
auto t0 = relogio::now();
std::vector<std::string> copia = estoque; // cópia
auto t1 = relogio::now();
std::vector<std::string> movido = std::move(estoque); // movimento
auto t2 = relogio::now();
auto us = [](auto d) {
return std::chrono::duration_cast<std::chrono::microseconds>(d).count();
};
std::cout << "cópia: " << us(t1 - t0) << " µs (" << copia.size() << " itens)\n";
std::cout << "movimento: " << us(t2 - t1) << " µs (" << movido.size() << " itens)\n";
}
$ g++ -std=c++23 -Wall -Wextra tempo.cpp -o tempo $ ./tempo cópia: 180694 µs (1000000 itens) « cerca de 0,18 segundo movimento: 0 µs (1000000 itens) « menos de um microssegundo
A cópia reservou memória para um milhão de textos e copiou cada um (cada texto tem 25 letras, guardadas num pedaço de memória próprio). O movimento trocou três ponteiros por dentro do vetor: o tempo nem chegou a 1 µs. Os números exatos mudam de computador para computador e de uma execução para outra, mas a diferença é sempre desse tamanho: a cópia cresce com o tamanho do vetor; o movimento custa sempre o mesmo, quase nada.
5. Mover para dentro de um vector
Guardar objetos num std::vector é onde o movimento mais aparece no dia a dia. A Mochila agora só tem o dono, para a saída ficar curta:
Mochila preparar(std::string dono) {
Mochila m{dono};
return m;
}
int main() {
std::vector<Mochila> armario;
armario.reserve(10);
Mochila ana{"Ana"};
std::cout << "push_back(ana):\n";
armario.push_back(ana);
std::cout << "push_back(std::move(ana)):\n";
armario.push_back(std::move(ana));
std::cout << "push_back(Mochila{\"Bia\"}):\n";
armario.push_back(Mochila{"Bia"});
std::cout << "emplace_back(\"Caio\"):\n";
armario.emplace_back("Caio");
std::cout << "Mochila d = preparar(\"Davi\"):\n";
Mochila d = preparar("Davi");
std::cout << "fim\n";
}
$ g++ -std=c++23 -Wall -Wextra vetor.cpp -o vetor
$ ./vetor
push_back(ana):
[cópia] Ana « ana tem nome: copiou
push_back(std::move(ana)):
[movimento] Ana « pedimos para mover
push_back(Mochila{"Bia"}):
[movimento] Bia « temporário: o compilador moveu sozinho
emplace_back("Caio"): « nada: construiu direto dentro do vetor
Mochila d = preparar("Davi"): « nada: elisão de cópia
fim
| Forma | O que acontece | Quando usar |
|---|---|---|
v.push_back(x) | Copia x | Você ainda vai usar x depois |
v.push_back(std::move(x)) | Move x (que fica vazio) | x não será mais usado |
v.push_back(Classe{…}) | Move o temporário | Raramente: prefira o emplace_back |
v.emplace_back(…) | Constrói direto no vetor, com os argumentos do construtor | Criar um objeto novo já dentro do vetor |
O noexcept faz diferença
Quando um vetor fica cheio, ele reserva um espaço maior e passa os itens antigos para lá (aula std::vector). Se algo der errado no meio dessa mudança, o vetor promete voltar ao estado anterior. Com cópias, ele consegue (os originais estão intactos); com movimentos que podem falhar, não. Por isso o vetor só move os itens antigos se o construtor de movimento prometer noexcept. Veja o mesmo programa com e sem a palavra:
int main() {
std::vector<Mochila> armario;
armario.emplace_back("Ana");
std::cout << "cabia " << armario.capacity() << "; colocando a Bia:\n";
armario.emplace_back("Bia");
}
$ g++ -std=c++23 -Wall -Wextra cresce.cpp -o cresce $ ./cresce « Mochila(Mochila&& outra) noexcept cabia 1; colocando a Bia: [movimento] Ana « a Ana foi movida para o espaço novo $ g++ -std=c++23 -Wall -Wextra semnoexcept.cpp -o semnoexcept $ ./semnoexcept « Mochila(Mochila&& outra), sem noexcept cabia 1; colocando a Bia: [cópia] Ana « sem a promessa, o vetor preferiu copiar
Nenhum aviso, nenhum erro: só um programa mais lento. Por isso, todo construtor e atribuição de movimento que você escrever deve ser noexcept. (Os que o compilador gera sozinho já são, quando os atributos permitem.)
6. Quando o compilador já move sozinho
Você quase nunca precisa escrever std::move. O compilador move (ou faz melhor ainda, nem copia nem move) nos casos mais comuns:
| Situação | O que o compilador faz |
|---|---|
return obj; de uma variável local | Constrói direto no destino (elisão de cópia). Se não puder, move. |
Um temporário: Classe x = criar();, v.push_back(Classe{…}) | Elisão ou movimento: temporários são rvalues. |
a = b + c; com strings | O resultado de b + c é temporário: é movido para a. |
| Variável com nome que você não vai mais usar | Nada: aqui é a sua vez de escrever std::move. |
Por isso, devolver objetos grandes por valor é a forma certa e barata em C++ moderno: std::vector<std::string> ler_arquivo();. E escrever return std::move(local); é pior do que não escrever, porque atrapalha a elisão. O g++ avisa:
std::string montar_lista() {
std::string lista = "arroz, feijão, café";
return std::move(lista);
}
$ g++ -std=c++23 -Wall -Wextra pessimo.cpp -o pessimo
pessimo.cpp: In function ‘std::string montar_lista()’:
pessimo.cpp:7:21: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
7 | return std::move(lista);
| ~~~~~~~~~^~~~~~~
pessimo.cpp:7:21: note: remove ‘std::move’ call
"Mover um objeto local num return impede a elisão de cópia". O nome do aviso diz tudo: pessimizing move, o movimento que "pessimiza" (o contrário de otimizar). A nota já dá o conserto: return lista;.
Quando dá errado
unique_ptr
auto caderno = std::make_unique<std::string>("caderno de receitas");
std::unique_ptr<std::string> emprestado = caderno;
$ g++ -std=c++23 -Wall -Wextra unico.cpp -o unico
unico.cpp: In function ‘int main()’:
unico.cpp:7:47: error: use of deleted function ‘std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = std::__cxx11::basic_string<char>; _Dp = std::default_delete<std::__cxx11::basic_string<char> >]’
7 | std::unique_ptr<std::string> emprestado = caderno;
| ^~~~~~~
In file included from /usr/include/c++/15/memory:80,
from unico.cpp:2:
/usr/include/c++/15/bits/unique_ptr.h:524:7: note: declared here
524 | unique_ptr(const unique_ptr&) = delete;
| ^~~~~~~~~~
unico.cpp:7:47: note: use ‘-fdiagnostics-all-candidates’ to display considered candidates
7 | std::unique_ptr<std::string> emprestado = caderno;
| ^~~~~~~
O que significa: o construtor de cópia do unique_ptr é = delete (a nota mostra a linha da biblioteca). Um unique_ptr é o único dono do objeto: copiar criaria dois donos. O std::__cxx11::basic_string<char> é só o nome completo de std::string. (Clang: call to deleted constructor of 'std::unique_ptr<std::string>'.)
Como consertar: se a intenção é transferir a posse, mova: std::unique_ptr<std::string> emprestado = std::move(caderno);. Depois disso, caderno vale nullptr. Se a intenção é só usar o objeto em outro lugar, passe uma referência (const std::string&).
std::move e ela estava vazia
O que significa: não há mensagem: o programa simplesmente mostra um texto vazio, um vetor com 0 itens ou quebra ao usar um unique_ptr que virou nullptr. O objeto foi movido (seção 3).
Como consertar: mova só no último uso. Se ainda precisar do valor, copie (sem std::move) ou guarde o que precisa antes.
std::move, mas copiou mesmo assim
const Mochila ana{"Ana"};
Mochila outra = std::move(ana);
$ g++ -std=c++23 -Wall -Wextra constmove.cpp -o constmove $ ./constmove [cópia] Ana
O que significa: ana é const, e mover significa mudar a origem (esvaziá-la). O construtor de movimento recebe Mochila&&, sem const, então não aceita; o compilador cai no de cópia, que aceita const Mochila&. Nenhum aviso. O mesmo acontece se a classe não tiver construtor de movimento (por exemplo, porque você escreveu só o de cópia).
Como consertar: não declare const o que você pretende mover; e confira se a classe tem movimento (regra do zero ou dos cinco).
O que significa: return std::move(variavel_local);, como na seção 6.
Como consertar: return variavel_local;. O compilador já move (ou faz melhor) sozinho.
Missão
Missão: a Mochila conta quantas cópias e quantos movimentos aconteceram. Do jeito que está, guardar a Ana e a Bia no armário faz 2 cópias. Como as duas variáveis não são usadas depois, mude o main para a saída ficar cópias: 0, movimentos: 2.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Mochila {
public:
inline static int copias = 0;
inline static int movimentos = 0;
Mochila(std::string dono) : dono_{dono} {}
Mochila(const Mochila& outra) : dono_{outra.dono_} { ++copias; }
Mochila(Mochila&& outra) noexcept : dono_{std::move(outra.dono_)} { ++movimentos; }
private:
std::string dono_;
};
int main() {
std::vector<Mochila> armario;
armario.reserve(3);
Mochila ana{"Ana"};
Mochila bia{"Bia"};
armario.push_back(ana);
armario.push_back(bia);
std::cout << "cópias: " << Mochila::copias << ", movimentos: " << Mochila::movimentos << '\n';
}
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Mochila {
public:
inline static int copias = 0;
inline static int movimentos = 0;
Mochila(std::string dono) : dono_{dono} {}
Mochila(const Mochila& outra) : dono_{outra.dono_} { ++copias; }
Mochila(Mochila&& outra) noexcept : dono_{std::move(outra.dono_)} { ++movimentos; }
private:
std::string dono_;
};
int main() {
std::vector<Mochila> armario;
armario.reserve(3);
Mochila ana{"Ana"};
Mochila bia{"Bia"};
armario.push_back(std::move(ana));
armario.push_back(std::move(bia));
std::cout << "cópias: " << Mochila::copias << ", movimentos: " << Mochila::movimentos << '\n';
}
A saída da solução no laboratório:
$ ./contador cópias: 0, movimentos: 2
Os contadores são membros static: pertencem à classe, não a cada mochila. Para ir além: e se, em vez de criar ana e bia, você usasse armario.emplace_back("Ana");? Quantos movimentos sobrariam?
Referência
| Código | O que faz |
|---|---|
std::move(x) | Marca x como "pode mover de mim" (rvalue). Não move nada sozinho. #include <utility> |
Classe(Classe&& outra) noexcept | Construtor de movimento: pega os recursos de outra |
Classe& operator=(Classe&& outra) noexcept | Atribuição de movimento |
T&& | Referência a rvalue: só aceita temporários (ou o que passou por std::move) |
v.push_back(std::move(x)) | Move x para dentro do vetor |
v.emplace_back(args…) | Constrói o objeto direto dentro do vetor |
return local; | Elisão de cópia ou movimento automático. Nunca return std::move(local); |
| Objeto movido | Válido, mas não especificado (na prática, vazio). Só atribua ou deixe morrer |
const + std::move | Vira cópia, sem aviso |
Exercícios
1. Entregar sem copiar
Complete para colocar a string nome no vetor lista sem copiar (você não vai mais usar nome):
2. A assinatura
Complete o construtor de movimento da classe Cesta:
3. O que sobrou
Depois de std::string b = std::move(a);, em que a valia "feijão", quanto vale a.size() na prática (g++)?
4. O que std::move(x) faz sozinho?
5. Qual destes é um rvalue?
6. Por que o construtor de movimento deve ser noexcept?
Resumo
- Mover = o objeto novo pega os recursos do antigo, que fica vazio. É muito mais barato que copiar (0 µs × 0,18 s no teste com um milhão de textos).
- lvalue tem nome e continua existindo; rvalue é temporário. Temporários são movidos automaticamente.
std::move(x)só marcaxcomo "pode mover"; o construtor de movimentoClasse(Classe&&) noexceptfaz o trabalho.- O objeto movido fica válido, mas sem conteúdo garantido: atribua um valor novo ou deixe-o morrer.
- Em vetores:
push_back(std::move(x))ouemplace_back(…). Movimento semnoexceptfaz o vetor copiar ao crescer. - O compilador já move (ou elimina a cópia) em
return local;e com temporários. Não escrevareturn std::move(local);. std::movenum objetoconstvira cópia.unique_ptrsó pode ser movido, nunca copiado.