1. Por que surge a escolha
Quando você começa a escrever operator== e <=>, aparece uma sensação agradável: “meu tipo é quase como um int”. Mas rapidamente a realidade aparece: o mesmo objeto pode ser comparado de formas diferentes em tarefas distintas. Em um lugar nos importa o id, em outro — “primeiro prioridade, depois título”, em outro — “primeiro os concluídos no fim da lista”. E é aí que é importante não transformar os operadores em uma "canivete suíço que também cozinha sopa".
Operadores sobrecarregados são o padrão para o tipo. Se você adicionou a < b, você meio que diz: “na maioria dos casos, quando as pessoas veem Task e querem ordenar, elas esperam exatamente essa ordem”. Um comparador (frequentemente uma lambda) é uma "regra local": agora, neste concreto std::sort, estamos ordenando assim porque a tarefa exige.
Para não discutir isso no vácuo, continuamos com nosso aplicativo de console didático: um mini‑rastreador de tarefas (lista TODO condicional), onde guardamos tarefas em std::vector e sabemos imprimi‑las e ordená‑las.
2. Mini‑modelo: TaskId, Priority, Task
Antes de discutir "operadores vs comparadores", precisamos de um objeto que possa ser comparado. Faremos um pequeno modelo de tarefa de modo que seja claro para iniciantes e, ao mesmo tempo, suficientemente "rico" para surgir diferentes critérios de ordenação e busca. Importante: o modelo não precisa ser perfeito — precisa ser didático e previsível, para que as consequências das suas decisões fiquem visíveis.
Começamos com um pequeno TaskId (para não confundir "número em si" e "identificador de tarefa") e com a prioridade:
#include <compare>
class TaskId {
public:
explicit TaskId(int v) : v_(v) {}
int value() const { return v_; }
auto operator<=>(const TaskId&) const = default; // ordenação por número
bool operator==(const TaskId&) const = default;
private:
int v_{};
};
enum class Priority {
Low = 0,
Normal = 1,
High = 2
};
Agora a própria tarefa. Suponha que ela tenha id, title, priority e a flag done. Já sabemos imprimir para stream e comparar a partir do material de palestras anteriores.
#include <string>
#include <utility>
struct Task {
TaskId id;
std::string title;
Priority priority{Priority::Normal};
bool done{false};
bool operator==(const Task&) const = default;
};
Note: aqui operator== = default significa “iguais se todos os campos forem iguais”. Isso nem sempre é o sentido correto de igualdade, mas é um ponto de partida muito conveniente para a conversa. Em alguns parágrafos veremos quando isso vira erro de design.
Vamos também fornecer a saída para stream, para que haja algo a imprimir nos exemplos de ordenação:
#include <iostream>
std::ostream& operator<<(std::ostream& os, const Task& t) {
os << "Task{id=" << t.id.value()
<< ", title=\\"" << t.title << "\\""
<< ", done=" << (t.done ? "true" : "false")
<< "}";
return os;
}
3. Quando a comparação deve ser parte do tipo
Quando você define o operador dentro do tipo (ou próximo a ele como função livre), você não está só adicionando açúcar sintático. Você publica uma regra que outras partes do programa vão usar: algoritmos, contêineres, suas futuras funções. É como nomear um campo price e esperar que todos entendam "preço em dólares", e depois, de repente, armazenar lá "centavos". Formalmente é possível. A felicidade é pouca.
O critério mais honesto: o operador é apropriado quando o tipo tem um sentido natural, óbvio para a maioria dos leitores, de comparação. Para TaskId isso é fácil: dois TaskId são iguais se o número é igual. Para Task já não é tão óbvio: a tarefa pode ser "a mesma" pelo id, mesmo que o title tenha mudado (renomeio). Ou o contrário: você pode ter um sistema sem id, e então "igualdade por todos os campos" é a norma.
Uma regra prática que salva nervos: o operador == deve responder à pergunta "é o mesmo objeto no sentido do meu domínio?". Se sim — ótimo. Se a resposta depende da situação, então provavelmente é cedo demais para definir == como "o único sentido de igualdade", ou ele deveria ser baseado em uma chave mais estável.
Por exemplo, se id é identidade, então igualdade "por todos os campos" pode estar errada. Melhor assim:
struct Task {
TaskId id;
std::string title;
Priority priority{Priority::Normal};
bool done{false};
bool operator==(const Task& other) const {
return id == other.id; // identidade por id
}
};
Esse operator== faz com que a tarefa seja "a mesma" mesmo que você altere o título ou a prioridade. E isso frequentemente reflete a realidade: tarefa é uma entidade, e os campos são seu estado.
4. Ordem padrão: útil, mas perigosa
Ordem (<, >, <=>) é uma estrada ainda mais escorregadia do que igualdade. Igualdade costuma ser mais fácil de explicar. Já ordem frequentemente começa a "fingir ser natural", quando na verdade ela é apenas conveniente para o programador.
Se você adiciona <=> para Task, você define uma ordenação padrão. E aí é preciso perguntar honestamente: "existe uma única ordem natural para a tarefa que seja esperada em todos os lugares?". Se a resposta for sim, por exemplo "em todo lugar ordenamos por id", então pode-se:
#include <compare>
struct Task {
TaskId id;
std::string title;
Priority priority{Priority::Normal};
bool done{false};
auto operator<=>(const Task& other) const {
return id <=> other.id; // ordenação por identificador
}
bool operator==(const Task& other) const {
return id == other.id; // e igualdade coerente com a ordem
}
};
Aqui o ponto importante: fazemos == coerente com a ordem, para evitar "dissonância cognitiva". Se a == b, espera‑se normalmente que a < b e b < a sejam falsos.
Mas se você não tem certeza sobre uma ordem "natural" — melhor não adicioná‑la no tipo. E isso não é fraqueza. É cautela. Um tipo sem < não fica pior como entidade; ele simplesmente diz honestamente: "a ordenação depende da tarefa, especifique via comparador".
5. Comparador: ordenação para uma tarefa específica
Quando ordenamos um std::vector<Task>, quase sempre ordenamos para a tela, para um relatório, para escolha do usuário, e não porque as tarefas têm uma "ordem natural" filosófica. Portanto, std::sort com um comparador é uma prática absolutamente normal e madura.
Imagine que nossa aplicação tem o comando "mostrar tarefas: primeiro não concluídas, depois concluídas; dentro disso — por prioridade; e só então — por id". Isso não é "ordem da entidade tarefa", é "ordem de apresentação".
Assim fica com um comparador lambda:
#include <algorithm>
#include <vector>
int priority_rank(Priority p) {
return static_cast<int>(p);
}
void sort_for_ui(std::vector<Task>& tasks) {
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) {
if (a.done != b.done) return a.done < b.done; // false antes de true
if (a.priority != b.priority) {
return priority_rank(a.priority) > priority_rank(b.priority); // High maior
}
return a.id < b.id;
});
}
Note o estilo: articulamos explicitamente "se diferem por isso — decidimos por isso, senão vamos adiante". Esse código é lido como regras de ordenação, não como enigma. Sim, são mais linhas do que = default, mas o sentido está no lugar onde você o usa.
E o mais importante: você não teve que mudar o significado de Task em todo o programa. Você simplesmente ordenou para a tela desejada.
6. Busca: operadores e predicados
A palavra "busca" frequentemente é confundida com "ordenação". Na cabeça didática isso costuma ser o mesmo: "eu busco — logo comparo". Mas no STL a busca frequentemente é "encontrar um elemento que satisfaça uma condição". Essa condição é uma função comum bool(const T&), e frequentemente escrita como lambda. Aqui não precisamos de ordenação, e muitas vezes nem de operator==.
Começando pelo clássico std::find. Ele procura "elemento exatamente igual". Logo, precisa de operator==:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks = {
{TaskId{1}, "Buy milk", Priority::Normal, false},
{TaskId{2}, "Fix bug", Priority::High, false}
};
auto it = std::find(tasks.begin(), tasks.end(),
Task{TaskId{2}, "", Priority::Low, true});
std::cout << (it != tasks.end() ? it->title : "not found") << '\n';
// Fix bug
}
Esse exemplo parece estranho (criamos uma "tarefa modelo" com título vazio), mas demonstra bem que std::find compara via ==. Se seu == compara apenas id, isso funciona. Se seu == compara todos os campos, então buscar "por id" não funciona — você terá que criar uma cópia exata da tarefa ou mudar de abordagem.
E é aqui que std::find_if entra: busca por condição. Ele não precisa de operator==, precisa de um predicado.
#include <algorithm>
Task* find_by_id(std::vector<Task>& tasks, TaskId id) {
auto it = std::find_if(tasks.begin(), tasks.end(),
[&](const Task& t) { return t.id == id; });
return (it == tasks.end()) ? nullptr : &*it;
}
Esse estilo muitas vezes é superior mesmo quando existe ==, porque a condição de busca fica visível no código: estamos procurando por id, ponto. Nada de adivinhação "o que compara dentro de operator==?".
7. Nuances úteis
Cheat sheet: o que colocar onde
Quando a cabeça ferve, é útil ter um mapa simples de decisões à vista. Não há "resposta única", mas há heurísticas que evitam APIs estranhas e operadores bizarros.
| Situação | Melhor escolha | Por quê |
|---|---|---|
| O tipo tem um único sentido óbvio de "igualdade" (ex.: TaskId) | operator== dentro do tipo | Faz parte da entidade e é esperado em todos os lugares |
| O tipo tem uma única "ordem natural" óbvia (raro, mas existe) | operator<=> ou operator< dentro do tipo | Conveniente para std::sort por padrão e para contêineres que precisam de ordem |
| O tipo tem muitas ordenações plausíveis (por nome, por data, por prioridade) | Comparador em std::sort | A regra de ordenação é local e explícita, o tipo não é "regravatado" |
| Busca por condição complexa (substring, flag, intervalo) | std::find_if com lambda | É mais simples expressar a condição assim do que tentar encaixar no operator== |
| Busca por chave (ex.: id -> Task) e operação frequente | Índice separado (ex.: std::unordered_map<TaskId, ...>) | A busca torna-se direta, em vez de "varrer todo o vector" |
Note: a última linha não é diretamente sobre operadores, mas é muito prática. Às vezes discutimos "como comparar" quando a resposta correta é "vamos armazenar os dados de forma a não buscar por varredura".
Comparador e comparação correta
É muito fácil escrever um comparador que pareça plausível, mas viole as expectativas de std::sort. Não vamos entrar fundo na teoria, mas a intuição básica é importante: o comparador deve definir uma ordem sensata sem contradições. Se o comparador em um caso diz "a é menor que b", e em outra verificação se comporta de forma diferente (ou depende de um estado externo mutável), a ordenação pode produzir resultados estranhos.
Por exemplo, é uma má ideia um comparador que use aleatoriedade ou um contador global de chamadas. Parece piada, mas às vezes as pessoas fazem algo parecido acidentalmente ao capturar por referência em uma lambda uma variável que muda durante a ordenação.
Bons comparadores têm uma característica: são "puros". Recebem dois objetos e retornam true/false, e essa decisão depende só desses objetos (e, no máximo, de regras constantes, como uma tabela de prioridades).
8. Várias ordenações sem quebrar o modelo
Situação típica em projeto real: o usuário quer um seletor de ordenação. Hoje "por prioridade", amanhã "por título". Aqui é especialmente importante não tentar "regravar operator<=> dinamicamente" (o que, felizmente, é impossível) e não fazer o operador depender de uma variável global currentSortMode. Parece ideia, mas cheira a bug.
Façamos certo: função separada que recebe o modo e chama std::sort com o comparador apropriado. Que o modo seja por ora um simples enum class.
#include <algorithm>
enum class SortMode {
ById,
ByTitle,
ByPriority
};
void sort_tasks(std::vector<Task>& tasks, SortMode mode) {
if (mode == SortMode::ById) {
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) { return a.id < b.id; });
return;
}
if (mode == SortMode::ByTitle) {
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) { return a.title < b.title; });
return;
}
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) {
return static_cast<int>(a.priority) > static_cast<int>(b.priority);
});
}
Note o "truque técnico": não complicamos com templates avançados ou tópicos futuros, apenas escolhemos o comparador de forma explícita. Sim, há if. E isso é ok. Às vezes o design mais legível é o que não tenta ser "esperto demais".
9. Erros típicos
Erro nº1: tentar colocar todas as variações de ordenação em operator<=>.
Iniciantes pensam: "posso definir uma ordem, então será conveniente em todo lugar". Depois descobrem que existe uma ordem conveniente, mas cinco cenários de ordenação. Como resultado, operator<=> vira controverso ou nocivo, porque impõe ordem onde não se deseja.
Erro nº2: usar operator== = default sem refletir sobre o sentido de identidade.
= default compara todos os campos, e isso costuma surpreender. Se a tarefa é "a mesma" pelo id, mas você mudou o title, então subitamente a == b será falso, e std::find deixará de encontrar "a mesma" tarefa. Antes de defaultar ==, verbalize: "o que significa 'iguais' para meu tipo?".
Erro nº3: usar std::find quando precisa de busca por condição.
Se precisa encontrar por substring no título ou por status done, não torture operator== nem crie um "objeto modelo" para comparação. std::find_if com lambda normalmente é mais simples, claro e honesto — a condição de busca fica explícita no ponto de chamada.
Erro nº4: comparador depende de estado externo mutável.
A ordenação chama o comparador muitas vezes e em ordem não óbvia. Se seu comparador lê uma variável que muda em paralelo (ou muda dentro do próprio comparador), você terá uma comparação "flutuante". Resultado — ordem estranha ou, no pior caso, comportamento difícil de explicar.
Erro nº5: misturar "igualdade" e "semelhança".
Às vezes se tenta sobrecarregar operator== para que tarefas sejam consideradas iguais se tiverem títulos parecidos ou prioridade idêntica. Mas isso não é igualdade, é "semelhança". Para semelhança é melhor uma função separada ou um predicado em find_if; caso contrário você quebra as expectativas sobre == em todo o programa.
GO TO FULL VERSION