Propriedades e Encapsulamento: Deixar Ler, Controlar a Escrita

Propriedades e Encapsulamento: Deixar Ler, Controlar a Escrita

Propriedades em C#: get e set no lugar de getters e setters, propriedades automáticas, a palavra-chave field do C# 14, private set e só leitura, readonly, propriedades calculadas, init e required, modificadores de acesso e um pedido que expõe os itens sem deixar alterá-los.
Linguagem C#

• • 15 min de leitura

Um objeto que valida tudo no construtor, mas deixa qualquer um alterar seus campos depois, está protegido pela porta da frente e aberto pela dos fundos.

Terminamos "Classes e Objetos" com uma classe Livro que nascia válida, mas cujos campos public permitiam escrever livro.Estoque = -50; de qualquer lugar do programa, contornando o construtor e o método Vender. O nome dessa proteção que falta é encapsulamento: o objeto decide o que o mundo pode ver e controla como seus dados mudam. Em muitas linguagens isso se faz com pares de métodos getPreco() e setPreco(). O C# tem um mecanismo próprio, integrado à linguagem e usado por toda a biblioteca padrão: as propriedades.

Do campo público ao par de métodos

A primeira ideia para proteger um campo é torná-lo private e expor métodos:

class Livro
{
    private decimal _preco;

    public decimal GetPreco() => _preco;

    public void SetPreco(decimal novoPreco)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(novoPreco);
        _preco = novoPreco;
    }
}

Funciona, e é assim que se faz em Java até hoje. Mas o código de quem usa fica verboso: livro.SetPreco(livro.GetPreco() * 1.1m). E a leitura perde a naturalidade: preço é um dado do livro, não uma ação. O C# resolve isso com uma sintaxe que parece um campo para quem usa e se comporta como método para quem implementa.

Propriedades

Uma propriedade tem um acessador get, executado na leitura, e um acessador set, executado na escrita:

class Livro
{
    private decimal _preco;

    public decimal Preco
    {
        get => _preco;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            _preco = value;
        }
    }
}

Dentro do set, a palavra value representa o valor que está sendo atribuído. Para quem usa a classe, a sintaxe é a de um campo:

livro.Preco = 64.90m;         // executa o set: validado
livro.Preco *= 1.10m;         // get, multiplica, set: validado
Console.WriteLine(livro.Preco);
livro.Preco = -1;             // ArgumentOutOfRangeException

Não existe mais caminho para um preço inválido: toda escrita passa pelo set. E, se amanhã a regra mudar (um preço máximo, um registro de histórico de preços), ela muda num só lugar, sem que nenhum código que usa livro.Preco precise ser tocado. É a grande vantagem da propriedade sobre o campo público: você pode começar simples e acrescentar lógica depois, sem quebrar ninguém.

Propriedades automáticas

Quando não há lógica nenhuma a acrescentar, escrever o campo privado e os dois acessadores é pura cerimônia. A propriedade automática deixa o compilador criar o campo por você:

public string Editora { get; set; } = "";

Isso equivale a um campo privado escondido, com get e set triviais. O = "" é um inicializador, o valor inicial. A convenção moderna em C# é nunca expor campos públicos: mesmo quando nada precisa ser validado, use uma propriedade automática. Ela custa o mesmo em desempenho (o JIT elimina a chamada) e preserva a liberdade de adicionar lógica no futuro.

A palavra-chave field (C# 14)

Existe um meio-termo muito comum: você quer uma propriedade automática, mas com uma validação no set. Até o C# 13 isso obrigava a voltar à forma completa, com campo privado declarado à mão. O C# 14 introduziu a palavra-chave field, que dá acesso ao campo que o compilador criou:

public decimal Preco
{
    get;
    set
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
        field = value;
    }
}

O get continua automático, e o set ganha a validação sem que você declare _preco. É o formato que vamos usar daqui em diante sempre que uma propriedade precisar de uma regra simples. Em código escrito para versões anteriores, você vai encontrar a forma com campo explícito, que é equivalente.

Controlando quem escreve: private set e propriedades só de leitura

Nem toda propriedade deve ser alterável de fora. O estoque é o caso perfeito: o mundo precisa ler quantos exemplares há, mas só deve mudar esse número por meio de Vender e Repor, que aplicam as regras.

public int Estoque { get; private set; }

O get é público e o set é privado. Dentro da classe, Estoque -= quantidade; funciona. Fora dela, livro.Estoque = 10; é erro de compilação (CS0272). A porta dos fundos da aula anterior está fechada.

Algumas propriedades não devem mudar nunca depois da criação, como o identificador. Para elas, basta omitir o set:

public int Id { get; }

Uma propriedade só com get pode receber valor apenas no construtor ou no inicializador. Depois, fica congelada. Para os campos, o equivalente é o modificador readonly:

private readonly List<ItemPedido> _itens = [];

readonly garante que o campo sempre aponte para a mesma lista. Atenção: a lista continua podendo receber itens. O que não pode é trocar _itens por outra lista. readonly protege a referência, não o conteúdo do objeto referenciado. É uma distinção sutil e importante, e ela reaparece no fim desta aula.

Propriedades calculadas

Uma propriedade não precisa guardar nada. Ela pode calcular seu valor a partir de outras:

public bool Disponivel => Estoque > 0;
public decimal ValorEmEstoque => Preco * Estoque;

Para quem usa, livro.Disponivel parece um dado. Na aula anterior, ValorEmEstoque() era um método. Quando usar cada forma? As diretrizes do .NET dão um critério claro: use propriedade quando a operação é barata, não tem efeitos colaterais e devolve sempre o mesmo resultado para o mesmo estado do objeto. Use método quando ela faz trabalho significativo (acessa disco, rede ou banco), altera algo ou pode devolver valores diferentes a cada chamada. Quem lê pedido.Total espera algo instantâneo. Quem lê pedido.CalcularFreteNosCorreios() sabe que pode demorar.

init, inicializadores de objeto e required

Quando uma classe tem muitas propriedades, um construtor com dez parâmetros fica difícil de ler. O C# oferece uma alternativa, o inicializador de objeto, que atribui propriedades logo após a criação:

class Cliente
{
    public string Nome { get; set; } = "";
    public string Email { get; set; } = "";
    public string? Telefone { get; set; }
}

var cliente = new Cliente
{
    Nome = "Ana Souza",
    Email = "ana@exemplo.com",
};

Cada linha nomeia a propriedade, o que é legível. Mas há dois problemas. Com set público, as propriedades podem mudar a qualquer momento depois. E nada obriga a informar Nome e Email: um new Cliente() vazio compila.

O acessador init (C# 9) resolve o primeiro: a propriedade pode ser atribuída no inicializador, mas nunca depois. O modificador required (C# 11) resolve o segundo: o compilador exige que o inicializador atribua a propriedade.

class Cliente
{
    public required string Nome { get; init; }
    public required string Email { get; init; }
    public string? Telefone { get; init; }
}

var ana = new Cliente { Nome = "Ana Souza", Email = "ana@exemplo.com" };   // ok
var semEmail = new Cliente { Nome = "Bruno" };   // erro CS9035: Email é obrigatório
ana.Nome = "Outra";                              // erro CS8852: só na inicialização

Telefone não é required: é opcional, e o ? indica que pode ficar nulo. Um aviso honesto: required garante que a propriedade foi atribuída, mas não que o valor faz sentido. Nome = "" passa. Se precisar validar, combine required com um init que use field:

public required string Email
{
    get;
    init
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(value);
        if (!value.Contains('@'))
            throw new ArgumentException("E-mail inválido.", nameof(value));
        field = value.Trim();
    }
}

Construtor ou required com init? Os dois são válidos, e a comunidade usa ambos. Construtores brilham quando a validação envolve várias propriedades juntas ("a data de fim não pode ser anterior à de início"), porque todas chegam de uma vez. Inicializadores com required brilham em objetos com muitos dados e poucas regras cruzadas, típicos de configurações e dados vindos de formulários ou de APIs. Neste curso vamos usar construtores nas entidades com regras de negócio, como Livro e Pedido, e required/init em objetos de dados, como Cliente.

Modificadores de acesso

Já usamos public e private. O quadro completo, por enquanto:

  • public: acessível de qualquer lugar.
  • private: só dentro da própria classe. É o padrão para membros quando nada é escrito.
  • protected: dentro da classe e das classes derivadas dela. Faz sentido na próxima aula, sobre herança.
  • internal: dentro do mesmo projeto (assembly). É o padrão para classes declaradas sem modificador, e ganha importância quando dividirmos a Livraria em vários projetos.

A regra de ouro do encapsulamento é começar fechado e abrir só o necessário. Um membro private pode ser tornado public amanhã sem quebrar nada. O caminho inverso quebra todo mundo que já o usava.

Juntando tudo: Livro e Pedido encapsulados

A classe Livro reescrita com propriedades. Tomei uma decisão de modelagem: título e autor não mudam depois do cadastro. Se mudassem, seria outro livro. Por isso são propriedades só de leitura, validadas no construtor.

class Livro
{
    private static int _proximoId = 1;

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

        Id = _proximoId++;
        Titulo = titulo.Trim();
        Autor = autor.Trim();
        Preco = preco;          // passa pela validação do set
        Estoque = estoque;
    }

    public int Id { get; }
    public string Titulo { get; }
    public string Autor { get; }

    public decimal Preco
    {
        get;
        set
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            field = value;
        }
    }

    public int Estoque { get; private set; }

    public bool Disponivel => Estoque > 0;
    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";
}

Repare que o construtor atribui Preco = preco; pela propriedade, e não pelo campo. Assim, a validação do preço vive num único lugar, o set, e vale tanto na criação quanto nas alterações posteriores.

Agora ItemPedido e Pedido:

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

    public Livro Livro { get; }
    public int Quantidade { get; }
    public decimal Subtotal => Livro.Preco * Quantidade;
}

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

    public IReadOnlyList<ItemPedido> Itens => _itens.AsReadOnly();

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

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

    public decimal Subtotal
    {
        get
        {
            decimal soma = 0;
            foreach (var item in _itens)
                soma += item.Subtotal;
            return soma;
        }
    }

    public decimal PercentualDesconto => QuantidadeTotal switch
    {
        >= 10 => 0.15m,
        >= 3  => 0.10m,
        _     => 0m
    };

    public decimal Total => Subtotal * (1 - PercentualDesconto);
}

O ponto mais delicado está em Itens. O pedido precisa deixar o mundo ver seus itens, para exibir um resumo por exemplo, sem deixar que alguém os altere por fora de Adicionar. Se expuséssemos public List<ItemPedido> Itens, qualquer um poderia chamar pedido.Itens.Clear(), e o readonly do campo não impediria, porque ele protege a referência e não o conteúdo, como vimos. IReadOnlyList<ItemPedido> é um tipo que só oferece leitura: Count, acesso por índice e foreach. E AsReadOnly() embrulha a lista num objeto que realmente não permite alteração, nem por truques de conversão (o exercício 5 mostra por que esse cuidado importa). O I no início do nome indica uma interface, conceito que ganha aula própria logo adiante. Por ora, leia como "algo que se comporta como uma lista somente leitura".

Com isso, cada objeto da Livraria protege os próprios dados: o livro não aceita preço inválido nem estoque manipulado, o item não aceita quantidade zero e o pedido não aceita itens que não passaram pela porta da frente. Esse modelo, porém, conhece um único tipo de produto. A Livraria quer começar a vender e-books, que têm título, autor e preço como qualquer livro, mas não têm estoque físico nem peso para calcular frete. Copiar a classe Livro e apagar o que não serve seria duplicar código e regras. O que precisamos é dizer ao C# que um e-book é um tipo de produto, que herda o que é comum e especializa o que é diferente. Isso é herança.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Reescreva a classe Autor (do exercício 1 de "Classes e Objetos") usando propriedades: Nome e AnoNascimento só de leitura, validados no construtor, e uma propriedade calculada Seculo que devolva o século de nascimento (1839 → 19).

Ver resposta

✓ Resposta:

class Autor
{
    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 string Nome { get; }
    public int AnoNascimento { get; }
    public int Seculo => (AnoNascimento - 1) / 100 + 1;
}

A fórmula do século usa a divisão inteira que vimos em "Operadores, Texto e Conversa com o Usuário". O - 1 trata o caso dos anos redondos: o ano 1900 pertence ao século 19, e não ao 20, porque o século 20 vai de 1901 a 2000. Sem o ajuste, 1900 / 100 + 1 daria 20. Para 1839: (1838 / 100) + 1 = 18 + 1 = 19.

Exercício 2

Com a classe Livro desta aula, explique por que cada linha abaixo compila ou não compila:

var livro = new Livro("Dom Casmurro", "Machado de Assis", 59.90m, 5);
livro.Preco = 49.90m;
livro.Estoque = 10;
livro.Titulo = "Outro";
Console.WriteLine(livro.Disponivel);
livro.Disponivel = false;
Ver resposta

✓ Resposta: livro.Preco = 49.90m; compila: Preco tem set público, que valida o valor. livro.Estoque = 10; não compila (CS0272), porque o set é private e só a classe altera o estoque, via Vender e Repor. livro.Titulo = "Outro"; não compila (CS0200), porque Titulo só tem get e só pode receber valor no construtor. Console.WriteLine(livro.Disponivel); compila: leitura de uma propriedade calculada. livro.Disponivel = false; não compila (CS0200), porque uma propriedade calculada não tem set, e nem faria sentido: a disponibilidade é consequência do estoque. Todas as proteções são verificadas pelo compilador, antes de o programa rodar, e nenhuma depende de disciplina de quem usa a classe.

Exercício 3

Adicione à classe Livro uma propriedade PercentualPromocao (entre 0 e 0,5) usando field, e uma propriedade calculada PrecoFinal que aplique a promoção.

Ver resposta

✓ Resposta:

public decimal PercentualPromocao
{
    get;
    set
    {
        ArgumentOutOfRangeException.ThrowIfNegative(value);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 0.5m);
        field = value;
    }
}

public decimal PrecoFinal => Preco * (1 - PercentualPromocao);

O campo implícito de um decimal começa em 0, então todo livro nasce sem promoção, sem precisar de inicializador. PrecoFinal é calculada, e não armazenada. Se fosse um campo atualizado manualmente, bastaria alguém mudar o Preco e esquecer de recalcular para os dois ficarem inconsistentes. Calcular na leitura elimina essa classe de erro: o valor derivado nunca fica desatualizado.

Exercício 4

Crie a classe Cliente com Nome e Email obrigatórios (required e init) e Telefone opcional. Mostre uma criação válida, uma que não compila por falta de campo obrigatório e uma alteração posterior que não compila.

Ver resposta

✓ Resposta:

class Cliente
{
    public required string Nome { get; init; }
    public required string Email { get; init; }
    public string? Telefone { get; init; }
}

var ok = new Cliente { Nome = "Ana", Email = "ana@exemplo.com" };
var incompleto = new Cliente { Nome = "Bruno" };   // CS9035: 'Email' é obrigatório
ok.Email = "novo@exemplo.com";                     // CS8852: só pode ser atribuída na inicialização

required move para a compilação um erro que, sem ele, só apareceria em execução (um cliente sem e-mail circulando pelo sistema). init torna o objeto imutável depois de criado. E por que imutável? Se o cliente muda de e-mail, o sistema deveria registrar essa mudança por uma operação explícita (AlterarEmail, com validação e talvez com o envio de uma confirmação), e não permitir uma atribuição anônima de qualquer lugar. A imutabilidade força as mudanças a passarem por caminhos com nome.

Exercício 5

Suponha que Pedido.Itens fosse declarada assim: public IReadOnlyList<ItemPedido> Itens => _itens; (sem AsReadOnly()). Mostre como um código mal-intencionado ainda conseguiria esvaziar o pedido e explique por que AsReadOnly() impede isso.

Ver resposta

✓ Resposta:

var lista = (List<ItemPedido>)pedido.Itens;   // conversão explícita
lista.Clear();                                 // o pedido original foi esvaziado!

Sem AsReadOnly(), a propriedade devolve a própria List<ItemPedido> interna. O tipo declarado IReadOnlyList só esconde os métodos de alteração, mas o objeto por trás continua sendo uma lista comum, e uma conversão explícita a recupera. Com AsReadOnly(), a propriedade devolve um objeto diferente, um ReadOnlyCollection<ItemPedido> que embrulha a lista e recusa qualquer alteração: quem chegar aos métodos Add ou Clear pela interface IList<ItemPedido> recebe uma NotSupportedException. A conversão para List<ItemPedido> falha com InvalidCastException, porque aquele objeto nunca foi uma lista. A lição vai além do exemplo: um tipo declarado comunica intenção, mas proteção real exige que o objeto por trás também não permita a operação.

Comentários

Mais em Linguagem C#

Godot com C#: nós, cenas e sinais
Godot com C#: nós, cenas e sinais

A Biblioteca em Apuros migra para o Godot com C#: tudo é um nó, nós formam…

Do Código-Fonte ao Processo: Instalando o .NET e Entendendo o que Acontece ao Rodar
Do Código-Fonte ao Processo: Instalando o .NET e Entendendo o que Acontece ao Rodar

Instalar o .NET 10 e entender o caminho do código até o processador: SDK e…

O caixa da Livraria em Avalonia: MVVM, navegação, leitor de código de barras e vendas offline
O caixa da Livraria em Avalonia: MVVM, navegação, leitor de código de barras e vendas offline

O caixa da loja física em Avalonia, para Windows e Linux: MVVM com…