CodeGym /Cursos /C++ SELF /Nullable API: pointer, std::optional e valores sentinel

Nullable API: pointer, std::optional e valores sentinel

C++ SELF
Nível 41, Lição 0
Disponível

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
T* / const T*
nullptr
acesso ao objeto (endereço)
if (p)
"encontrar objeto", "objeto pode estar ausente"
std::optional<T>
std::nullopt
valor-resultado
if (opt)
"obter valor", "resultado pode estar ausente"
sentinel (npos, -1, etc.) "valor especial" tipo normal (indice/codigo)
if (x == sentinel)
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).

1
Tarefa
C++ SELF, nível 41, lição 0
Bloqueado
Busca de frase no chat
Busca de frase no chat
1
Tarefa
C++ SELF, nível 41, lição 0
Bloqueado
Entrada no sistema
Entrada no sistema
1
Tarefa
C++ SELF, nível 41, lição 0
Bloqueado
Busca de produto no estoque
Busca de produto no estoque
1
Tarefa
C++ SELF, nível 41, lição 0
Bloqueado
Catálogo de filmes
Catálogo de filmes
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION