1. Design nullable
Quase qualquer código "real" cedo ou tarde esbarra na situação "esse valor não existe". O usuário digitou um id inexistente, a busca não encontrou nada, a string acabou vazia, o parser não conseguiu ler um número. E é aí que o iniciante frequentemente liga o modo antigo: "vou retornar -1" ou "vou retornar uma string vazia e que tentem resolver".
O problema é que -1 não é ausência, é um valor normal do conjunto de int. E a string vazia também é um valor. Quando você mistura "sem resultado" e "resultado igual a zero/vazio", o código começa a viver uma dupla vida: por fora tudo parece ok, mas por dentro surgem um monte de convenções ocultas.
Hoje vamos analisar três abordagens usadas para expressar "pode estar ausente" e aprender a escolher pela semântica, não por humor.
Objeto ausente ou resultado ausente?
Antes de escolher T*, std::optional ou "valor mágico", você precisa aprender a fazer a pergunta certa. Ela é: "eu posso ter um objeto que não existe, ao qual quero dar acesso, ou posso não obter um valor-resultado?"
Se você procura um elemento em uma coleção e quer "dar acesso ao objeto encontrado" — isso se parece com a situação "o objeto pode estar ausente". Se não achar — o objeto simplesmente não existe.
Se você tenta fazer o parse de um número a partir de uma string, você não está procurando um "objeto", você está tentando obter um valor que pode não ser obtido. Aqui é muito mais natural o modelo "o valor-resultado pode estar ausente".
Essa distinção parece filosófica até o primeiro projeto grande, onde metade dos bugs é "misturamos valor com ausência".
2. Ponteiro (T* / const T*) + nullptr
Ponteiros são a primeira forma natural de expressar "pode não existir". Um ponteiro em C++ pode guardar o endereço de um objeto ou nullptr, que literalmente lê-se "não aponta para nada". E isso se encaixa bem no contrato: o objeto pode estar ausente.
Há um detalhe estilístico importante: um ponteiro como resultado normalmente é lido como "estou retornando acesso a um objeto já existente", e não "estou criando um novo valor". Ou seja, T* é mais como "referência que pode estar vazia".
Mini-exemplo: buscamos um livro por id e retornamos um ponteiro
Continuamos nosso projeto de console educativo (imagine um mini-catalogo "LibraryLite", onde guardamos livros em um std::vector). Livro é uma struct simples.
#include <string>
struct Book {
int id{};
std::string title;
};
Agora a função de busca:
#include <vector>
Book* FindBookById(std::vector<Book>& books, int id) {
for (Book& b : books) {
if (b.id == id) return &b;
}
return nullptr;
}
A assinatura aqui fala por si: "posso retornar o endereço do livro, ou posso retornar nullptr se não existir".
Uso:
#include <iostream>
#include <vector>
int main() {
std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (Book* b = FindBookById(books, 2)) {
std::cout << b->title << "\n"; // 1984
} else {
std::cout << "Not found\n";
}
}
Note: a checagem if (b) quase força você a tratar a ausência. Quase — porque nada impede escrever algo que dereference o ponteiro sem checar e ter problemas.
const T* como "achei, mas não pode ser modificado"
Se a função não deve alterar o objeto encontrado, isso é honestamente expresso via const:
#include <vector>
const Book* FindBookById(const std::vector<Book>& books, int id) {
for (const Book& b : books) {
if (b.id == id) return &b;
}
return nullptr;
}
Esse é o "contrato de acesso": "posso retornar acesso, mas apenas para leitura".
Quando o ponteiro é a melhor escolha
Ponteiro é uma ótima escolha quando você retorna acesso a um objeto que já existe em algum lugar, e "vazio" significa "o objeto não existe". Por isso ponteiros são comuns em funções "encontrar elemento", "achar configuração", "buscar entidade por chave".
3. std::optional<T>: o valor pode estar ausente
std::optional<T> é um tipo wrapper que guarda ou um valor T, ou o estado "sem valor". Não é mágica nem exceção: é apenas um modelo honesto "tem/nao tem resultado".
Conceitualmente optional é muito conveniente porque não finge que -1 é ausência. Ele força você a ver explicitamente a bifurcação: ou há um valor, ou não. Na biblioteca padrão existe inclusive std::nullopt relacionado a esse estado "vazio".
Mini-exemplo: parseamos um int de uma string sem exceções e retornamos optional
Suponha que o usuário digite o id como texto. Queremos parsear o número de forma segura. Em termos de semântica não estamos "procurando um objeto"; estamos tentando obter um valor (int) que pode falhar.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> ParseInt(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{}) return std::nullopt;
if (ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Aqui retornamos honestamente std::nullopt se o parse falhar. (Aliás, std::from_chars é bom porque não lança exceções; ele reporta erro via código. Isso combina perfeitamente com optional.)
Uso:
#include <iostream>
int main() {
if (auto id = ParseInt("42")) {
std::cout << "id=" << *id << "\n"; // id=42
} else {
std::cout << "bad id\n";
}
}
Repare no estilo: if (auto id = ParseInt(...)) parece "verificar que há valor". É quase como if (p) para ponteiros, só que a semântica é diferente: aqui não há endereço nem tempo de vida — é "valor como resultado".
Mini-exemplo: retornar o índice do livro encontrado como optional<size_t>
Às vezes você não precisa do objeto, precisa da posição. A posição é outro valor, e "sem posição" é bem expresso por optional.
#include <cstddef>
#include <optional>
#include <vector>
std::optional<std::size_t> FindBookIndexById(const std::vector<Book>& books, int id) {
for (std::size_t i = 0; i < books.size(); ++i) {
if (books[i].id == id) return i;
}
return std::nullopt;
}
Uso:
#include <iostream>
#include <vector>
int main() {
std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (auto pos = FindBookIndexById(books, 99)) {
std::cout << books[*pos].title << "\n";
} else {
std::cout << "No such book\n"; // No such book
}
}
Quando optional é a melhor escolha
optional é ideal quando "pode estar ausente" refere-se ao resultado de um cálculo, não ao objeto. Parse, cálculo, busca de índice, cálculo de média, obtenção de configurações por dados — tudo isso costuma ser melhor expresso com optional do que com ponteiro.
Além disso: optional sinaliza claramente "o autor da função espera que você trate isso". É uma pequena disciplina tipada.
4. Sentinel: "valor especial do mesmo tipo"
Sentinel é a abordagem "combinamos que um certo valor significa 'sem resultado'". O exemplo clássico em C++ é std::string::npos. O método find retorna uma posição (size_t), e se a substring não é encontrada — retorna npos.
Por que isso existe? Porque historicamente algumas APIs funcionam assim: retornam um tipo simples e embutem um valor especial nele. É barato, rápido e não requer wrapper adicional.
Mas há um preço: a ausência não aparece no tipo, ela está escondida numa convenção. E se você esquecer a checagem — o programa não cai imediatamente (o que às vezes seria até mais misericordioso), mas passa a fazer coisas estranhas.
Exemplo: usamos find e npos corretamente
#include <iostream>
#include <string>
int main() {
const std::string title = "The Lord of the Rings";
const std::size_t pos = title.find("Lord");
if (pos == std::string::npos) {
std::cout << "Not found\n";
} else {
std::cout << "Found at " << pos << "\n"; // Found at 4
}
}
Aqui está correto: verificamos o sentinel e só então usamos a posição.
Exemplo: "embrulhar" o sentinel numa API booleana compreensível
Se você não quer lembrar sempre de npos, pode criar uma função que retorne bool:
#include <string>
bool Contains(const std::string& text, const std::string& what) {
return text.find(what) != std::string::npos;
}
Agora o código chamador não precisa saber do sentinel — ele vê um bool honesto.
Quando o sentinel é uma escolha razoável
Sentinel funciona bem quando:
- O tipo tem uma constante oficialmente aceita (como std::string::npos), e não um "-1 por sorte".
- Esse valor não pode ser confundido com um resultado válido.
- Você consegue manter a verificação "perto" do uso (ou encapsulá-la numa função) para não perdê-la em refatoramentos.
5. Critérios de escolha: optional vs pointer vs sentinel
Gostaríamos de uma regra universal em uma linha, mas a vida não gosta de regras de uma linha. Então vamos a uma tabela prática: o que cada abordagem expressa, que checagens esperamos no código, e onde é mais fácil cometer erros.
| Abordagem | O que significa “vazio” | O que você realmente retorna | Checagem típica | Onde é mais conveniente |
|---|---|---|---|---|
|
|
acesso ao objeto (endereço) | |
"encontrar objeto", "objeto pode estar ausente" |
|
|
valor-resultado | |
"obter valor", "resultado pode estar ausente" |
| sentinel (npos, -1, etc.) | "valor especial" | tipo normal (indice/codigo) | |
APIs históricas, posições em strings, casos com constante aceita |
Se você retorna "acesso ao objeto", pense em ponteiro. Se retorna "valor como resultado de cálculo", pense em optional. Se retorna um tipo comum que tem um sentinel aceito, use-o, mas faça a checagem o mais próximo possível do uso ou encapsule-a numa função pequena.
Para fixar, aqui vai um pequeno fluxograma de decisão:
flowchart TD
A["Precisa expressar 'pode faltar'"] --> B{"Estamos retornando acesso a um objeto?"}
B -->|Sim| C["Retornar T* / const T* e nullptr"]
B -->|Nao| D{"Estamos retornando um valor-resultado?"}
D -->|Sim| E["Retornar std::optional<T>"]
D -->|Nao| F{"Existe um sentinel oficial (npos etc.)?"}
F -->|Sim| G["Retornar sentinel e checar perto do uso"]
F -->|Nao| H["Provavelmente preciso de optional ou repensar a API"]
(O diagrama não substitui raciocínio, mas salva algumas células nervosas — e células nervosas, como se sabe, não se comitam no Git.)
6. Mini-refatoramento prático “LibraryLite”: tornando a API honesta
Agora vamos juntar as três ideias num pequeno "pedaço de app" para ver a diferença não em exemplos abstratos, mas como se estivéssemos realmente escrevendo um programa.
Suponha que em main() processamos o comando do usuário "find <id>" e queremos: parsear o id, achar o livro, imprimir o resultado.
Parsing: optional<int>
#include <iostream>
#include <optional>
#include <string>
std::optional<int> ParseInt(std::string_view s);
int main() {
const std::string idText = "2";
const auto id = ParseInt(idText);
if (!id) {
std::cout << "Bad id\n";
return 0;
}
std::cout << "Parsed id=" << *id << "\n"; // Parsed id=2
}
Não retornamos "-1" porque esse valor pode ser um id válido (mesmo que estranho). Retornamos optional porque é o resultado de um cálculo.
Busca do objeto: const Book*
#include <iostream>
#include <vector>
const Book* FindBookById(const std::vector<Book>& books, int id);
int main() {
const std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (const Book* b = FindBookById(books, 2)) {
std::cout << b->title << "\n"; // 1984
} else {
std::cout << "Not found\n";
}
}
Retornamos ponteiro porque o "resultado" não é um novo valor, é acesso ao livro existente.
Busca "dentro da string": sentinel (npos)
Suponha que queremos realçar livros cujo título contenha a palavra "Ring":
#include <iostream>
#include <string>
bool Contains(const std::string& text, const std::string& what);
int main() {
const std::string title = "The Lord of the Rings";
if (Contains(title, "Ring")) {
std::cout << "Matched\n"; // Matched
}
}
Dentro de Contains está escondido npos, e o chamador não precisa lembrar que find tem sentinel. É um exemplo de pequeno design de API que simplifica o código em main().
7. Erros típicos
Erro #1: retornar "número mágico" (-1) em vez de std::optional.
No começo parece prático: "tenho um índice, que -1 signifique 'não encontrado'". Depois descobre-se que o índice é size_t e o -1 vira um número enorme. Ou que -1 é válido em outro contexto. std::optional torna a ausência explícita e elimina convenções escondidas.
Erro #2: usar T* como "nullable universal" até para valores.
Iniciantes às vezes começam a retornar ponteiro para tudo: "pode não haver resultado, vou retornar int*". Quase sempre é sinal ruim: mistura "valor-resultado" e "objeto em memória", e ainda acrescenta a pergunta "quem é dono desse int* e seu tempo de vida". Para valores é muito mais natural std::optional<int>.
Erro #3: sentinel verificado "algum tempo depois" (ou nunca).
Com npos e similares o perigo não é a abordagem em si, mas o esquecimento. Hoje você lembra de comparar com npos, amanhã refatorou e usou a posição como se sempre fosse válida. Se usar sentinel, faça a verificação imediatamente ou esconda-a em uma função que retorne bool ou optional.
Erro #4: retornar optional<T> e em seguida convertê-lo de volta para sentinel.
Às vezes aparece uma lógica híbrida estranha: a função retorna optional<size_t>, e o chamador faz "retorna valor ou -1". Assim você só transfere o problema e novamente esconde a ausência num "valor especial". Se escolheu optional — deixe a ausência no tipo e trate explicitamente a ramificação.
Erro #5: ignorar o significado "objeto vs resultado" e escolher o tipo por hábito.
O erro mais traiçoeiro é escolher T* só porque "sei usar nullptr", ou escolher optional só porque "está na moda". Na prática o critério é semântico: ponteiro para acesso a objeto, optional para valor-resultado, sentinel para interfaces históricas/baixo nível e casos com sentinel oficialmente definido (como npos).
GO TO FULL VERSION