Pular para conteúdo

3. Structs e enum class

Antes de tudo: o que é uma struct

Até aqui, uma "coisa" com vários atributos vira várias variáveis soltas, uma para cada atributo. Um aluno com nome e nota:

std::string nome1 = "Ana";
double nota1 = 8.5;

std::string nome2 = "Bruno";
double nota2 = 6.0;

Isso funciona para duas variáveis, mas não escala: uma função que recebe "um aluno" precisa receber nome e nota como dois parâmetros separados, nada no código deixa explícito que os dois valores andam juntos, e passar um aluno errado para o parâmetro errado (imprime(nota1, nome1)) é um erro fácil de cometer e que o compilador não pega.

Uma struct resolve isso: ela declara um tipo novo, que empacota vários campos com um nome só.

struct Aluno {
    std::string nome;
    double nota;
};

Repare no ; depois do } — é obrigatório em C++, e esquecê-lo é o erro mais comum de quem começa a usar struct (veja em Erros comuns, mais abaixo). Dentro das chaves ficam os campos, cada um com seu tipo, exatamente como uma declaração de variável comum.

Declarar a struct não cria nenhum aluno — é só a "planta" do tipo. Para ter um aluno de verdade, você cria uma variável do tipo Aluno, inicializando os campos entre chaves, na ordem em que foram declarados:

Aluno a{"Ana", 8.5};   // nome = "Ana", nota = 8.5
Aluno b{"Bruno", 6.0};
Aluno vazio{};          // string vazia, nota 0.0 — campos zerados

Aluno passa a se comportar como int ou std::string: um tipo que você pode usar para declarar quantas variáveis quiser, cada uma com sua própria cópia dos campos. Alterar b não afeta a.

Para ler ou alterar um campo, use . (ponto) entre a variável e o nome do campo:

std::cout << a.nome << " tirou " << a.nota << "\n";  // Ana tirou 8.5

a.nota = a.nota + 1.0;   // agora a.nota vale 9.5

E uma struct pode ser usada como parâmetro e como retorno de função, igual qualquer outro tipo. As mesmas regras de passagem do capítulo 2 valem aqui — e é exatamente para struct (e para tipos como std::string) que aquela tabela foi pensada:

bool tem_bolsa(const Aluno& a) { return a.nota >= 9.0; }  // le, sem copiar
void aprova(Aluno& a)          { a.nota = 10.0; }         // altera o do chamador

Aluno cria_aluno(const std::string& nome, double nota) {  // struct como retorno
    Aluno novo{nome, nota};
    return novo;
}

Guarde essas quatro ideias — tipo novo, {} para inicializar, . para acessar campo, struct como parâmetro/retorno — porque o resto deste capítulo (e boa parte do curso a partir daqui) é construído em cima delas.

struct com métodos: a ponte para orientação a objetos

Do jeito que foi vista até agora, uma struct é só um pacote de dados — nenhuma função "mora dentro" dela; para calcular algo sobre um Aluno, você escreve uma função separada que recebe o Aluno como parâmetro:

struct Ponto {
    double x;
    double y;
};

double distancia_origem(const Ponto& p) {
    return sqrt(p.x * p.x + p.y * p.y);
}

(É assim, aliás, que uma struct de C sempre funciona — em C não existe outra opção.)

Em C++, uma struct pode ter funções (chamadas métodos) dentro dela, com acesso direto aos campos, sem precisar receber a struct como parâmetro:

#include <cmath>

struct Ponto {
    double x;
    double y;

    double distancia_origem() const {
        return std::sqrt(x * x + y * y);
    }
};

Ponto p{3.0, 4.0};
double d = p.distancia_origem();  // 5.0

Note o const depois dos parênteses: ele diz "este método não altera os campos de p". É a mesma ideia de const do capítulo 1, aplicada a métodos — o compilador barra qualquer tentativa de atribuir a x ou y dentro de um método marcado const. Isso vira essencial na Unidade 5, quando struct evolui para class com encapsulamento — por enquanto, guarde a ideia: struct com método já é 80% de uma classe.

enum class: mais seguro que enum de C

Antes da sintaxe, o problema. Muita coisa que um programa representa só pode assumir um punhado de valores: um turno é manhã, tarde ou noite; um semáforo está verde, amarelo ou vermelho; um pedido está pendente, pago ou cancelado. A saída improvisada é guardar isso num int (0 é manhã, 1 é tarde...) ou numa std::string ("manha", "tarde"). As duas funcionam e as duas mentem:

int turno = 7;                  // 7 nao e turno nenhum, e ninguem reclama
std::string t = "Tarde";        // "Tarde", "tarde" ou "TARDE"? escolha errado e o if falha

O int aceita valores que não existem no seu domínio; a string aceita qualquer texto e ainda erra por maiúscula. Nos dois casos o compilador não tem como ajudar, porque para ele são só um número e um texto quaisquer.

Um enum resolve isso criando um tipo cujos únicos valores possíveis são os que você listou. Escrever algo fora da lista deixa de compilar — o erro sai na compilação, não na frente do usuário.

Em C, porém, um enum é basicamente um int disfarçado — dois enums diferentes podem "vazar" e se misturar sem erro:

enum Cor { VERMELHO, VERDE, AZUL };
enum Status { OK, ERRO };

enum Cor c = OK; // compila em C, e um bug logico

Pior: como os nomes ficam todos soltos no escopo de fora, dois enums não podem repetir um nome. Declarar enum Turno { Manha, Tarde } e enum Periodo { Manha, Madrugada } no mesmo programa dá erro de compilação:

error: 'Manha' conflicts with a previous declaration

enum class (C++11+) cria um tipo de verdade, isolado:

enum class Cor { Vermelho, Verde, Azul };
enum class Status { Ok, Erro };

Cor c = Cor::Vermelho;   // precisa qualificar com Cor::
// Cor c2 = Status::Ok;  // erro de compilacao: tipos incompativeis

Os nomes agora moram dentro do tipo — é isso que o Cor:: na frente significa, e é por isso que Turno::Manha e Periodo::Manha podem coexistir sem colidir.

Comparação funciona normalmente:

if (c == Cor::Verde) { /* ... */ }

Decidindo por caso: switch sobre um enum

Quando a ação muda conforme o valor, o switch é a forma natural — e é aqui que o enum paga o investimento:

std::string nome_cor(Cor c) {
    switch (c) {
        case Cor::Vermelho: return "vermelho";
        case Cor::Verde:    return "verde";
        case Cor::Azul:     return "azul";
    }
    return "desconhecida";
}

Dois detalhes que todo mundo tropeça na primeira vez:

O case precisa do qualificador. É case Cor::Verde:, não case Verde: — pela mesma razão de sempre, o nome mora dentro do tipo.

O return depois do switch parece inútil, e não é. Você cobriu as três cores, mas o compilador não considera isso uma garantia (nada impede que um static_cast mal-intencionado produza um Cor fora da lista). Sem aquela última linha, o g++ avisa:

warning: control reaches end of non-void function [-Wreturn-type]

Em troca, o compilador oferece algo que nem int nem std::string dariam: se você esquecer um caso, ele avisa, dizendo qual.

warning: enumeration value ‘Azul’ not handled in switch [-Wswitch]

Isso é o argumento mais prático a favor de enum: no dia em que você acrescentar Cor::Amarelo à lista, o compilador aponta cada switch do programa que precisa ser atualizado. Com int ou std::string, você descobriria testando — ou não descobriria.

Devolvendo um enum

Um enum não serve só de parâmetro: é um tipo como outro qualquer, e uma função pode devolvê-lo. Esse é o jeito de fazer uma classificação ficar explícita no tipo de retorno, em vez de devolver um int que quem chama precisa decifrar:

enum class Faixa { Baixa, Normal, Alta };

Faixa classifica(double temperatura) {
    if (temperatura < 18.0) return Faixa::Baixa;
    if (temperatura > 26.0) return Faixa::Alta;
    return Faixa::Normal;
}

Quem chama classifica(30.0) recebe um Faixa, não um número solto — e só pode compará-lo com os valores de Faixa.

Convertendo entre enum e int

O outro lado dessa segurança é que um enum class não vira int sozinho — nem para imprimir. Quando você realmente precisar do número por trás do valor, peça a conversão explicitamente com static_cast:

std::cout << static_cast<int>(Cor::Azul) << "\n";  // 2
// std::cout << Cor::Azul << "\n";                 // erro de compilacao

static_cast<T>(x) é o (T) x de C, com um nome que dá para procurar e que salta aos olhos de quem lê. A diferença é justamente essa: em C a conversão acontecia em silêncio, e aqui ela fica escrita no código, onde pode ser discutida.

Por padrão, os valores começam em 0 e seguem na ordem em que você declarou — por isso Cor::Azul é 2. Mas você pode escolher os números, o que é útil quando eles significam algo fora do programa:

enum class Codigo { Ok = 200, NaoEncontrado = 404, ErroInterno = 500 };

std::cout << static_cast<int>(Codigo::NaoEncontrado) << "\n";  // 404

O caminho de volta, de int para o enum, também existe — e exige cuidado, porque nada é validado:

Cor c = static_cast<Cor>(1);   // Cor::Verde
Cor ruim = static_cast<Cor>(7); // compila! e nao existe cor 7

O static_cast obedece sem perguntar, e ruim passa a carregar um valor que não corresponde a cor nenhuma — o switch da seção anterior cairia direto no return "desconhecida". Quando o número vem de fora do programa (de um arquivo, da entrada do usuário), conferir o intervalo antes de converter é responsabilidade de quem converte. Guarde essa questão: mais adiante neste capítulo aparece a forma honesta de uma função dizer "esse número não era uma cor nenhuma".

Prefira enum class a enum sempre que estiver escrevendo código novo em C++.

std::pair: agrupando dois valores sem criar uma struct

Às vezes você só precisa devolver dois valores de uma função, sem que valha a pena nomear uma struct nova:

#include <utility>

std::pair<int, int> divide_com_resto(int a, int b) {
    return {a / b, a % b};
}

auto resultado = divide_com_resto(17, 5);
std::cout << resultado.first << " " << resultado.second << "\n"; // 3 2

std::pair<A, B> acessa os campos por .first e .second. Você vai reencontrar pair na Unidade 2, porque é exatamente o que um std::map usa internamente para guardar chave e valor.

Quando a função pode não ter resposta

Nem toda pergunta tem resposta. "Em que posição está a letra z nesta palavra?" não tem resposta se a letra não estiver lá; "qual é o maior valor deste vector?" não tem resposta se o vector estiver vazio. Em C, isso se resolve de duas formas, e as duas continuam valendo em C++.

A primeira é devolver um valor que não pode ser confundido com uma resposta de verdade:

int busca_posicao(const std::string& texto, char alvo) {
    for (std::size_t i = 0; i < texto.size(); i++) {
        if (texto[i] == alvo) {
            return static_cast<int>(i);
        }
    }
    return -1;   // "nao achei"
}

Aqui o -1 funciona, e funciona por um motivo específico que vale entender: nenhuma posição válida é negativa, então -1 nunca se confunde com um índice real. É sorte do problema, não uma regra geral.

Porque basta a função devolver algo em que todo valor seja legítimo para a convenção ruir. Uma divisão protegida é o caso limite — -1 é um quociente perfeitamente possível:

double divide(double a, double b) {
    if (b == 0.0) return -1.0;  // "deu errado"... ou o resultado de -10.0 / 10.0
    return a / b;
}

Quem chama não tem como distinguir os dois casos. Aí entra a segunda forma, que é a que este curso adota daqui para frente: a função devolve bool dizendo se deu certo, e entrega o resultado por um parâmetro de referência — o mesmo mecanismo da Aula 02:

bool divide_seguro(double a, double b, double& resultado) {
    if (b == 0.0) {
        return false;          // nao mexe em "resultado"
    }
    resultado = a / b;
    return true;
}

E quem chama fica obrigado a olhar a resposta antes de usar o valor:

double r = 0.0;
if (divide_seguro(10.0, 2.0, r)) {
    std::cout << "deu " << r << "\n";   // deu 5
} else {
    std::cout << "divisao por zero\n";
}

Regra prática para escolher entre as duas: se existe um valor que a função jamais devolveria como resposta legítima, use-o (-1 para índice, 0 para uma contagem que nunca é zero); se todos os valores são possíveis, devolva bool e o resultado por referência.

C++ tem uma terceira forma, e ela não é assunto deste curso

A biblioteca padrão oferece std::optional<T>, um tipo que representa "ou tem um T, ou não tem nada" sem precisar de valor mágico nem de parâmetro de saída. É mais elegante que as duas formas acima, e é o que se usa em C++ moderno — mas envolve um conceito novo (um tipo que embrulha outro tipo) que não compensa carregar agora, no primeiro contato com struct. Ele está descrito no apêndice de próximos passos, e é um bom primeiro assunto para depois do curso.

Juntando tudo: um exemplo completo

#include <iostream>
#include <string>

enum class Categoria { Eletronico, Alimento, Vestuario };

struct Produto {
    std::string nome;
    double preco;
    Categoria categoria;

    bool esta_em_promocao() const {
        return preco < 10.0;
    }
};

bool busca_produto(const std::string& nome, Produto& achado) {
    if (nome == "caneta") {
        achado = Produto{"caneta", 2.50, Categoria::Vestuario};
        return true;
    }
    return false;
}

int main() {
    Produto resultado{"", 0.0, Categoria::Eletronico};
    if (busca_produto("caneta", resultado)) {
        std::cout << resultado.nome << " custa " << resultado.preco << "\n";
        if (resultado.esta_em_promocao()) {
            std::cout << "esta em promocao!\n";
        }
    }
}

Repare que o parâmetro de saída é uma struct inteira, e isso não muda nada: referência funciona com qualquer tipo, inclusive os que você mesmo criou. E repare no if: o valor de resultado só é lido depois de a função confirmar que há o que ler.

Erros comuns

"error: expected ‘;’ after struct definition" — falta o ; depois do } que fecha a struct. Todo struct { ... } termina em ;, igual qualquer outra declaração — é fácil esquecer porque { ... } de função ou de if não pede isso.

Trocar a ordem dos valores na inicialização — {} segue a ordem dos campos na struct, não os nomes. Com struct Ponto { double x; double y; };, escrever Ponto p{4.0, 3.0} querendo dizer "x=3, y=4" compila sem erro nenhum — os dois campos são double, então qualquer ordem é aceita, e o valor errado só aparece quando o programa roda. Confira a ordem dos campos na declaração da struct sempre que inicializar.

"error: base operand of ‘->’ has non-pointer type ‘Aluno’" — -> é para ponteiro (Aluno* p; p->nome). Uma variável comum do tipo Aluno usa ., não ->.

A função devolveu false e o programa usou o valor mesmo assim — não é erro de compilação nenhum: o parâmetro de saída fica com o que já estava nele, que pode ser lixo. Por isso a chamada vai dentro de um if, e o valor só é lido no ramo verdadeiro.

"error: ‘VERMELHO’ was not declared in this scope" ao usar enum class — com enum class, os valores não "vazam" para o escopo externo como no enum de C. Use Cor::Vermelho, não Vermelho sozinho.

Esquecer const no método e o compilador reclamar em outro lugar — se você tentar chamar um método não-const numa struct que chegou como const Produto&, o compilador barra. A regra prática: todo método que só lê os campos deveria ser const.

Resumo

  • struct declara um tipo novo que empacota vários campos com um nome só; Nome{...} inicializa os campos na ordem em que foram declarados, e . lê ou altera cada um.
  • struct em C++ pode ter métodos — funções que enxergam os campos diretamente, sem receber a struct como parâmetro.
  • Método const promete não alterar os campos; o compilador garante isso.
  • enum class cria um tipo isolado e seguro, ao contrário do enum "int disfarçado" de C: os nomes moram dentro do tipo e não colidem.
  • switch sobre um enum é a forma natural de decidir por caso, e o compilador avisa quando você esquece um dos valores — o que int e std::string nunca fariam.
  • Um enum serve de parâmetro e de retorno; a conversão para int e de volta exige static_cast, e a volta não valida nada.
  • std::pair agrupa dois valores sem precisar nomear uma struct.
  • Quando a função pode não ter resposta: use um valor impossível (-1 para índice) se ele existir; senão devolva bool e o resultado por parâmetro de referência.
  • A chamada de uma função assim vai dentro de um if — o parâmetro de saída só tem valor confiável quando a função devolveu true.