Classes e Objetos: Dando Forma aos Dados da Livraria

Classes e Objetos: Dando Forma aos Dados da Livraria

Classes e objetos em C#: campos, objetos como tipos de referência, construtores que fazem o objeto nascer válido, encadeamento com this, métodos de instância, ToString, membros estáticos e um Id compartilhado, e o catálogo da Livraria com um único dicionário de livros.
Linguagem C#

• • 14 min de leitura

Uma classe junta num só lugar os dados que pertencem juntos e as regras que os protegem, e com ela o livro deixa de ser uma string espalhada por dicionários.

A Fase 1 terminou com um incômodo explícito: em "Quando Algo Dá Errado", a Livraria funcionava, mas um livro não existia como coisa. Era uma chave de texto repetida em vários dicionários, e cada método recebia três ou quatro coleções para fazer um trabalho simples. Hoje abrimos a Fase 2, dedicada à orientação a objetos, com a ferramenta que resolve esse incômodo: a classe. Com ela, você ensina ao C# um tipo novo, Livro, com seus dados e seu comportamento juntos, e passa a trabalhar com livros em vez de strings soltas.

Classe e objeto

Uma classe é uma definição: ela descreve que dados um tipo tem e o que ele sabe fazer. Um objeto é uma instância concreta dessa definição. A classe Livro diz que todo livro tem título, autor, preço e estoque. Dom Casmurro, a R$ 59,90 com cinco exemplares, é um objeto.

Crie um arquivo Livro.cs ao lado do Program.cs:

class Livro
{
    public string Titulo = "";
    public string Autor = "";
    public decimal Preco;
    public int Estoque;
}

Os quatro itens dentro das chaves são campos: variáveis que pertencem a cada objeto. A palavra public diz que o código fora da classe pode acessá-los. Agora, no Program.cs:

Livro livro = new Livro();
livro.Titulo = "Dom Casmurro";
livro.Autor = "Machado de Assis";
livro.Preco = 59.90m;
livro.Estoque = 5;

Console.WriteLine($"{livro.Titulo}, de {livro.Autor}: {livro.Preco:C}");

O operador new cria o objeto, e o ponto (.) acessa seus membros. Pela primeira vez, as informações de um livro viajam juntas: passar livro para um método leva tudo de uma vez.

Uma nota de organização. Você pode declarar classes no próprio Program.cs, mas elas precisam vir depois das instruções de nível superior. A convenção, que vamos seguir, é um arquivo por classe, com o mesmo nome dela. O compilador junta todos os .cs do projeto automaticamente.

Objetos são tipos de referência

Em "Cada Valor Tem um Tipo" vimos a diferença entre tipos de valor e de referência usando arrays. Classes são tipos de referência. A variável não contém o objeto: contém uma referência para ele, que vive numa região da memória chamada heap.

Livro a = new Livro();
a.Titulo = "O Cortiço";
a.Preco = 42.50m;

Livro b = a;          // copia a REFERÊNCIA: a e b apontam para o mesmo objeto
b.Preco = 39.90m;

Console.WriteLine(a.Preco);   // 39,90

Existe um único livro, com duas variáveis apontando para ele. Isso tem uma consequência direta para métodos: quando você passa um objeto a um método, ele recebe uma cópia da referência e pode alterar o objeto original. É o mesmo comportamento que vimos com dicionários na aula passada.

Uma variável de tipo de referência também pode não apontar para objeto algum. Ela vale null, e acessar um membro através dela lança a famosa NullReferenceException. O compilador, com as verificações de nulidade ligadas no .csproj, já ajuda a evitar isso, e o sistema completo ganha uma aula na Fase 3.

E quem libera a memória do objeto? Ninguém precisa. Quando nenhuma variável aponta mais para um objeto, o coletor de lixo do .NET o recolhe automaticamente em algum momento. É o fim do malloc/free manual do C, com custos e sutilezas que vamos medir na Fase 4.

Construtores: nascer válido

A versão acima tem um problema sério: new Livro() cria um livro sem título e com preço zero, um objeto inválido, e nada impede que ele circule pelo programa. O construtor resolve isso. É um método especial, com o mesmo nome da classe e sem tipo de retorno, que executa na criação e pode exigir os dados necessários:

class Livro
{
    public string Titulo;
    public string Autor;
    public decimal Preco;
    public int Estoque;

    public Livro(string titulo, string autor, decimal preco, int estoque = 0)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(titulo);
        ArgumentException.ThrowIfNullOrWhiteSpace(autor);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(preco);
        ArgumentOutOfRangeException.ThrowIfNegative(estoque);

        Titulo = titulo.Trim();
        Autor = autor.Trim();
        Preco = preco;
        Estoque = estoque;
    }
}

Agora é impossível criar um livro sem os dados obrigatórios, ou com dados inválidos:

var dom = new Livro("Dom Casmurro", "Machado de Assis", 59.90m, 5);
var semEstoque = new Livro("Vidas Secas", "Graciliano Ramos", 38.00m);   // estoque = 0
var invalido = new Livro("", "Alguém", 10m);   // ArgumentException na hora

As validações são as mesmas de "Quando Algo Dá Errado", agora no lugar certo: na porta de entrada do objeto. Um princípio que vamos repetir muitas vezes: um objeto deve nascer válido. Se o construtor garante isso, o resto do programa não precisa ficar desconfiando de cada livro que recebe.

Três observações sobre construtores. Quando você declara um construtor com parâmetros, o construtor vazio que o C# fornecia de graça deixa de existir, e new Livro() passa a não compilar. É exatamente o que queremos aqui. Quando o tipo é óbvio pelo lado esquerdo, dá para abreviar o new (C# 9): Livro dom = new("Dom Casmurro", "Machado de Assis", 59.90m);. E uma classe pode ter vários construtores (sobrecarga, como em "Métodos"), com um reaproveitando o outro por this(...):

public Livro(string titulo, decimal preco)
    : this(titulo, "Autor desconhecido", preco)   // delega ao construtor completo
{
}

Assim as validações ficam num lugar só.

Métodos de instância: o comportamento junto com os dados

Até aqui, todos os nossos métodos eram static: recebiam tudo por parâmetro. Um método de instância, declarado sem static dentro da classe, trabalha sobre o objeto em que foi chamado e enxerga seus campos diretamente:

class Livro
{
    // ... campos e construtor como antes ...

    public decimal ValorEmEstoque() => Preco * Estoque;

    public void Vender(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        if (quantidade > Estoque)
            throw new InvalidOperationException(
                $"há apenas {Estoque} exemplar(es) de \"{Titulo}\".");

        Estoque -= quantidade;
    }

    public void Repor(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Estoque += quantidade;
    }
}

Compare livro.Vender(2) com o Vender(estoque, titulo, quantidade) da aula anterior. Não há mais dicionário nem título para procurar: o livro sabe vender a si mesmo. A regra "não vender sem estoque" mora dentro da classe, e qualquer parte do programa que venda um livro passa obrigatoriamente por ela. Essa é a ideia central da orientação a objetos: dados e as regras que os protegem ficam juntos.

Dentro de um método de instância, a palavra this se refere ao próprio objeto. Ela é implícita (escrever Estoque é o mesmo que this.Estoque), mas às vezes aparece para desambiguar, por exemplo quando um parâmetro tem o mesmo nome de um campo.

Uma representação em texto com ToString

Tente imprimir um livro diretamente: Console.WriteLine(dom); mostra apenas Livro, o nome do tipo. Todo objeto em C# tem um método ToString(), e a versão padrão devolve o nome da classe. Podemos substituí-la:

public override string ToString() =>
    $"{Titulo} ({Autor}), {Preco:C}, {Estoque} em estoque";

Agora Console.WriteLine(dom) e $"{dom}" mostram algo útil. A palavra override indica que estamos substituindo um comportamento herdado. Ela só vai fazer sentido completo quando estudarmos herança, daqui a duas aulas. Por ora, é a receita para personalizar a exibição de um objeto.

Membros estáticos: o que pertence à classe, e não ao objeto

Agora o static que usamos desde "Métodos" ganha seu significado completo. Um membro estático pertence à classe, e não a cada objeto. Você já usa vários: Math.PI, int.Parse e Console.WriteLine são chamados pelo nome do tipo, sem new. Um uso clássico é gerar identificadores únicos:

class Livro
{
    private static int _proximoId = 1;   // um só contador, compartilhado por todos os livros

    public int Id;
    // ... demais campos ...

    public Livro(string titulo, string autor, decimal preco, int estoque = 0)
    {
        // ... validações ...
        Id = _proximoId++;
        // ... atribuições ...
    }
}

Cada livro tem seu próprio Id, mas existe um único _proximoId, compartilhado por todos. O modificador private diz que só o código da própria classe pode acessá-lo. O prefixo _ é a convenção para campos privados. Métodos estáticos, por sua vez, não têm this: não estão ligados a nenhum objeto e só podem usar o que recebem por parâmetro ou outros membros estáticos.

Juntando tudo: o catálogo com objetos

Com a classe completa, reunindo o que vimos (arquivo Livro.cs):

class Livro
{
    private static int _proximoId = 1;

    public int Id;
    public string Titulo;
    public string Autor;
    public decimal Preco;
    public int Estoque;

    public Livro(string titulo, string autor, decimal preco, int estoque = 0)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(titulo);
        ArgumentException.ThrowIfNullOrWhiteSpace(autor);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(preco);
        ArgumentOutOfRangeException.ThrowIfNegative(estoque);

        Id = _proximoId++;
        Titulo = titulo.Trim();
        Autor = autor.Trim();
        Preco = preco;
        Estoque = estoque;
    }

    public decimal ValorEmEstoque() => Preco * Estoque;

    public void Vender(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        if (quantidade > Estoque)
            throw new InvalidOperationException(
                $"há apenas {Estoque} exemplar(es) de \"{Titulo}\".");
        Estoque -= quantidade;
    }

    public void Repor(int quantidade)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Estoque += quantidade;
    }

    public override string ToString() =>
        $"#{Id} {Titulo} ({Autor}), {Preco:C}, {Estoque} em estoque";
}

E o programa principal, que agora trabalha com um único dicionário de livros:

using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("pt-BR");

var catalogo = new Dictionary<string, Livro>(StringComparer.OrdinalIgnoreCase);
Adicionar(catalogo, new Livro("Dom Casmurro", "Machado de Assis", 59.90m, 5));
Adicionar(catalogo, new Livro("O Cortiço", "Aluísio Azevedo", 42.50m, 2));
Adicionar(catalogo, new Livro("Vidas Secas", "Graciliano Ramos", 38.00m));

foreach (Livro livro in catalogo.Values)
    Console.WriteLine(livro);

try
{
    catalogo["dom casmurro"].Vender(2);
    catalogo["O Cortiço"].Vender(3);    // falha: só há 2
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Venda recusada: {ex.Message}");
}

decimal patrimonio = 0;
foreach (Livro livro in catalogo.Values)
    patrimonio += livro.ValorEmEstoque();
Console.WriteLine($"Valor total em estoque: {patrimonio:C}");

static void Adicionar(Dictionary<string, Livro> catalogo, Livro livro) =>
    catalogo[livro.Titulo] = livro;
#1 Dom Casmurro (Machado de Assis), R$ 59,90, 5 em estoque
#2 O Cortiço (Aluísio Azevedo), R$ 42,50, 2 em estoque
#3 Vidas Secas (Graciliano Ramos), R$ 38,00, 0 em estoque
Venda recusada: há apenas 2 exemplar(es) de "O Cortiço".
Valor total em estoque: R$ 264,70

Compare com a versão de dois dicionários paralelos de "Guardando Muitos Valores". Agora existe uma única fonte da verdade para cada livro. É impossível ter um preço sem estoque. A regra de venda está num lugar só. E catalogo.Values percorre os próprios livros, com tudo que eles sabem fazer. (O valor em estoque confere: Dom Casmurro ficou com 3 exemplares após a venda, 3 × 59,90 = 179,70; O Cortiço, 2 × 42,50 = 85,00; soma: 264,70.)

Mas ainda há uma porta aberta, e ela é grave. Os campos são public, então nada impede que qualquer parte do programa escreva livro.Estoque = -50; ou livro.Preco = 0;, contornando por completo o construtor e o método Vender, que tanto trabalho tiveram para proteger essas regras. Validar na entrada não basta se a porta dos fundos fica escancarada. Fechar essa porta, deixando o mundo ler os dados mas controlando como eles mudam, é o trabalho do encapsulamento. No C# ele tem uma ferramenta própria, elegante e onipresente: as propriedades.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie uma classe Autor com nome e ano de nascimento, um construtor que valide os dois (nome não vazio, ano entre 1000 e o ano atual) e um método Idade(int anoAtual).

Ver resposta

✓ Resposta:

class Autor
{
    public string Nome;
    public int AnoNascimento;

    public Autor(string nome, int anoNascimento)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(nome);
        ArgumentOutOfRangeException.ThrowIfLessThan(anoNascimento, 1000);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(anoNascimento, DateTime.Now.Year);

        Nome = nome.Trim();
        AnoNascimento = anoNascimento;
    }

    public int Idade(int anoAtual) => anoAtual - AnoNascimento;

    public override string ToString() => $"{Nome} ({AnoNascimento})";
}

ThrowIfLessThan e ThrowIfGreaterThan são outros atalhos da mesma família do ThrowIfNegativeOrZero. DateTime.Now.Year é o ano atual do relógio do sistema, uma propriedade estática que você vai encontrar muito. Receber anoAtual como parâmetro em Idade, em vez de ler o relógio lá dentro, é uma escolha deliberada: o método fica determinístico, com o mesmo resultado para os mesmos dados, e fácil de testar. Por exemplo, new Autor("Machado de Assis", 1839).Idade(1908) dá 69, a idade com que ele morreu.

Exercício 2

Sem rodar, preveja a saída:

var original = new Livro("Vidas Secas", "Graciliano Ramos", 38.00m, 4);
var outro = original;
outro.Vender(1);
AplicarPromocao(original);
Console.WriteLine($"{original.Estoque} {original.Preco}");

static void AplicarPromocao(Livro livro) => livro.Preco = 30.00m;
Ver resposta

✓ Resposta: Imprime 3 30,00. outro = original copia a referência, então outro.Vender(1) altera o único livro existente, que passa de 4 para 3 exemplares. AplicarPromocao recebe uma cópia da referência, que aponta para o mesmo objeto, e altera o preço do original. Em todo o trecho há um objeto Livro e três caminhos para chegar até ele: original, outro e o parâmetro livro.

Exercício 3

Crie dois objetos Livro com exatamente os mesmos dados e compare-os com ==. Qual é o resultado? Por quê? Como você verificaria se representam o mesmo livro?

Ver resposta

✓ Resposta: O resultado é False. Para classes, == compara referências por padrão: pergunta se as duas variáveis apontam para o mesmo objeto, e não se os objetos têm o mesmo conteúdo. São dois objetos distintos no heap, que por acaso guardam os mesmos valores. Para comparar conteúdo, seria preciso comparar campo a campo (a.Titulo == b.Titulo && a.Autor == b.Autor && ...) ou, num sistema real, comparar um identificador que represente a identidade do livro, como o ISBN. Repare que nem o nosso Id serve: cada new gera um Id diferente. A pergunta "quando dois objetos são iguais?" tem respostas diferentes para tipos diferentes, e o C# oferece um tipo feito especialmente para igualdade por conteúdo, o record, que estudaremos ainda nesta fase.

Exercício 4

Adicione à classe Livro um segundo construtor que receba só título e preço, usando "Autor desconhecido" e estoque zero, sem repetir as validações.

Ver resposta

✓ Resposta:

public Livro(string titulo, decimal preco)
    : this(titulo, "Autor desconhecido", preco)
{
}

O : this(...) executa o construtor completo antes do corpo deste, que aqui é vazio. O parâmetro opcional estoque do construtor completo assume o padrão 0. Todas as validações e o incremento do Id acontecem uma única vez, no construtor principal. Se amanhã surgir uma regra nova, como um preço máximo, ela é escrita em um só lugar e vale para todas as formas de criar um livro. Duplicar as validações em cada construtor seria convidar a esquecer uma delas.

Exercício 5

Crie as classes ItemPedido (um livro e uma quantidade, com método Subtotal()) e Pedido (uma lista de itens, com os métodos Adicionar(Livro, int) e Total()). O total deve aplicar o desconto por volume das aulas anteriores (10% a partir de 3 exemplares somando todos os itens, 15% a partir de 10).

Ver resposta

✓ Resposta:

class ItemPedido
{
    public Livro Livro;
    public int Quantidade;

    public ItemPedido(Livro livro, int quantidade)
    {
        ArgumentNullException.ThrowIfNull(livro);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantidade);
        Livro = livro;
        Quantidade = quantidade;
    }

    public decimal Subtotal() => Livro.Preco * Quantidade;
}

class Pedido
{
    private List<ItemPedido> _itens = [];

    public void Adicionar(Livro livro, int quantidade) =>
        _itens.Add(new ItemPedido(livro, quantidade));

    public int QuantidadeTotal()
    {
        int soma = 0;
        foreach (ItemPedido item in _itens)
            soma += item.Quantidade;
        return soma;
    }

    public decimal Total()
    {
        decimal subtotal = 0;
        foreach (ItemPedido item in _itens)
            subtotal += item.Subtotal();

        decimal percentual = QuantidadeTotal() switch
        {
            >= 10 => 0.15m,
            >= 3  => 0.10m,
            _     => 0m
        };
        return subtotal * (1 - percentual);
    }
}

Uso: var p = new Pedido(); p.Adicionar(dom, 2); p.Adicionar(vidas, 1); Console.WriteLine(p.Total());, que calcula (2 × 59,90 + 38,00) × 0,90 = 142,02 e imprime 142,0200: o decimal guarda as casas da multiplicação. Com {p.Total():C}, sai R$ 142,02. Repare em três coisas. Objetos podem conter outros objetos: um item tem um livro, e um pedido tem uma lista de itens. A lista é private, então ninguém de fora consegue esvaziá-la ou inserir itens sem passar por Adicionar, que valida tudo pelo construtor de ItemPedido. É um primeiro gosto do encapsulamento que a próxima aula generaliza. E Pedido não precisa de construtor: sem parâmetros, o construtor vazio padrão basta, e o campo _itens já nasce com uma lista vazia.

Comentários

Mais em Linguagem C#

EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta
EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta

Como enxergar e corrigir o SQL que o EF Core gera: ToQueryString e TagWith, o…

Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso
Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso

O capstone: a Biblioteca em Apuros envia partidas que a API valida com as…

Um Repositório, Três Bancos: Injeção de Dependência e a Livraria Independente de Infraestrutura
Um Repositório, Três Bancos: Injeção de Dependência e a Livraria Independente de Infraestrutura

Como deixar o domínio sem saber em que banco mora: contratos de repositório no…