Projeto Conta Bancária (classes)

Uma conta bancária é o exemplo clássico de orientação a objetos, e por um bom motivo: ela tem dados (o titular, o saldo) e regras (não dá para sacar mais do que tem, não dá para depositar um valor negativo). Neste projeto, a Ana constrói as contas da lojinha em quatro passos: primeiro uma classe ingênua, que deixa o saldo ficar negativo; depois uma classe que protege as próprias regras; depois o extrato e a transferência; e, no fim, uma conta poupança que herda tudo da conta comum.

Você vai praticar

O que vamos construir

No fim, a Ana tem uma conta corrente e uma poupança. Ela deposita R$ 1.500 na corrente, passa R$ 1.000 para a poupança, e a poupança rende 0,5% no mês:

$ dotnet run
Conta corrente  Ana       R$ 500,00
Poupança        Ana     R$ 1.005,00

Extrato de Ana
  Transferência de Ana        R$ 1.000,00
  Rendimento de 0,5%              R$ 5,00
  Saldo                       R$ 1.005,00
  1. Uma classe Conta (e o problema dos campos públicos).
  2. Encapsular: a conta protege o saldo.
  3. Extrato e transferência.
  4. A poupança herda da conta.

Antes de começar

Você precisa ter visto Classes e Objetos, Propriedades, Construtores, Encapsulamento e throw; para a versão 4, Herança e virtual e override.

Cada classe mora no seu arquivo, como em todo projeto C# de verdade: Conta.cs, depois Movimentacao.cs e ContaPoupanca.cs. Por isso é um projeto:

$ cd projetos
$ dotnet new console -o Banco
O modelo "Aplicativo do Console" foi criado com êxito.

Processando ações pós-criação...
Restaurando /home/ana/projetos/Banco/Banco.csproj:
  Determinando os projetos a serem restaurados...
  /home/ana/projetos/Banco/Banco.csproj restaurado (em 104 ms).
A restauração foi bem-sucedida.

$ cd Banco

O dotnet run compila todos os .cs da pasta juntos, então o Program.cs enxerga a classe Conta sem nenhum using. Nas caixas Experimente, cada arquivo é uma aba.


Versão 1 · Uma classe Conta

Uma classe é a "planta" de um tipo de objeto: diz que dados ele guarda e o que ele sabe fazer. A conta guarda o titular e o saldo, e sabe depositar e sacar.

class Conta
{
    public string Titular = "";
    public decimal Saldo;

    public void Depositar(decimal valor)
    {
        Saldo += valor;
    }

    public void Sacar(decimal valor)
    {
        Saldo -= valor;
    }
}
Conta conta = new Conta();
conta.Titular = "Ana";
conta.Depositar(100m);
conta.Sacar(30m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

conta.Sacar(500m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

conta.Saldo = 1_000_000m;
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");
class Conta
Cria o tipo Conta. Por convenção, cada classe fica num arquivo com o mesmo nome.
public string Titular = "";
Um campo: uma variável que cada conta tem. public quer dizer que qualquer parte do programa pode ler e mudar. O = "" evita o aviso de referência nula.
public decimal Saldo;
Dinheiro sempre em decimal. Um campo numérico começa em 0.
public void Depositar(decimal valor)
Um método: algo que a conta sabe fazer. Dentro dele, Saldo é o saldo desta conta (veja this).
Conta conta = new Conta();
O new cria um objeto a partir da planta: uma conta de verdade, com o seu próprio saldo.
conta.Depositar(100m);
Chama o método naquele objeto. O m faz do 100 um decimal.
{conta.Saldo:C}
Mostra como dinheiro, no formato da máquina: R$ 70,00 (Formatar Números).
$ dotnet run
Ana: R$ 70,00
Ana: -R$ 430,00
Ana: R$ 1.000.000,00

A primeira linha está certa: 100 − 30 = 70. As outras duas mostram o problema desta versão. A conta aceitou um saque de R$ 500 com R$ 70 de saldo e ficou negativa. E, pior, a última linha do Program.cs mudou o saldo direto, para um milhão, sem depósito nenhum. A classe tem os dados, mas não tem como proteger as regras: qualquer um mexe em qualquer coisa. A versão 2 conserta isso.

Missão (versão 1): cada new Conta() é uma conta independente. No fim do Program.cs, crie a conta do Bruno, deposite 50m e mostre Bruno: R$ 50,00. A conta da Ana não deve mudar.

Conta conta = new Conta();
conta.Titular = "Ana";
conta.Depositar(100m);
conta.Sacar(30m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

conta.Sacar(500m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

conta.Saldo = 1_000_000m;
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");
class Conta
{
    public string Titular = "";
    public decimal Saldo;

    public void Depositar(decimal valor)
    {
        Saldo += valor;
    }

    public void Sacar(decimal valor)
    {
        Saldo -= valor;
    }
}
Conta conta = new Conta();
conta.Titular = "Ana";
conta.Depositar(100m);
conta.Sacar(30m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

conta.Sacar(500m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

conta.Saldo = 1_000_000m;
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

Conta bruno = new Conta();
bruno.Titular = "Bruno";
bruno.Depositar(50m);
Console.WriteLine($"{bruno.Titular}: {bruno.Saldo:C}");

Versão 2 · Encapsular: a conta protege o saldo

A ideia do encapsulamento é a do caixa do banco: você não abre o cofre, você pede ao caixa, e ele confere se pode. Três mudanças:

  • O saldo vira uma propriedade que todos podem ler, mas só a própria classe pode mudar (private set).
  • Um construtor obriga a dizer o titular na hora de criar a conta.
  • Depositar e Sacar conferem o valor e, se ele for inválido, lançam uma exceção (throw) em vez de fazer a conta errada.
class Conta
{
    public string Titular { get; }
    public decimal Saldo { get; private set; }

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor)
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
    }

    public void Sacar(decimal valor)
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
    }
}
Conta conta = new Conta("Ana");
conta.Depositar(100m);
conta.Sacar(30m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

try
{
    conta.Sacar(500m);
}
catch (InvalidOperationException erro)
{
    Console.WriteLine($"Não deu: {erro.Message}");
}

try
{
    conta.Depositar(-20m);
}
catch (ArgumentException erro)
{
    Console.WriteLine($"Não deu: {erro.Message}");
}

Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");
public string Titular { get; }
Uma propriedade só de leitura: recebe valor no construtor e nunca mais muda.
public decimal Saldo { get; private set; }
Todo mundo lê (get público); só o código dentro da classe muda (private set). Assim, o único jeito de mexer no saldo é por Depositar e Sacar, que conferem as regras.
public Conta(string titular)
O construtor: roda no new. Agora new Conta() sem titular nem compila; é preciso new Conta("Ana").
string.IsNullOrWhiteSpace(titular)
"É nulo, vazio ou só espaços?". Um titular assim é recusado.
throw new ArgumentException("…");
Lança uma exceção: a operação para ali, nada muda, e quem chamou fica sabendo. ArgumentException quer dizer "o argumento que você passou é inválido".
throw new InvalidOperationException(…)
"A operação não pode ser feita agora": o valor é bom, mas o saldo não dá.
catch (InvalidOperationException erro)
No Program.cs, quem usa a conta decide o que fazer com a recusa. erro.Message é o texto que a conta escreveu no throw.
$ dotnet run
Ana: R$ 70,00
Não deu: Saldo insuficiente: o saldo é R$ 70,00 e o saque, R$ 500,00.
Não deu: O depósito tem de ser maior que zero.
Ana: R$ 70,00

O saque grande e o depósito negativo foram recusados, e o saldo continuou em R$ 70,00.

Mudar o saldo direto: erro CS0272

A Ana testou a proteção com a linha que, na versão 1, transformava o saldo em um milhão:

Conta conta = new Conta("Ana");
conta.Depositar(100m);
conta.Saldo = 1_000_000m;
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");
$ dotnet run
/home/ana/projetos/Banco/Program.cs(3,1): error CS0272: A propriedade ou o indexador "Conta.Saldo" não pode ser usado neste contexto porque o acessador set é inacessível [/home/ana/projetos/Banco/Banco.csproj]

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: o set do Saldo é private, então, fora da classe Conta, ninguém pode atribuir um valor a ele (em inglês, the set accessor is inaccessible). É exatamente o que queríamos: o erro aparece na compilação, antes de o programa rodar.

Como consertar: não é para consertar a classe, é para usar a porta certa: conta.Depositar(…).

Sem o try: a exceção derruba o programa

Sem o try/catch em volta do saque grande:

$ dotnet run
Ana: R$ 70,00
Unhandled exception. System.InvalidOperationException: Saldo insuficiente: o saldo é R$ 70,00 e o saque, R$ 500,00.
   at Conta.Sacar(Decimal valor) in /home/ana/projetos/Banco/Conta.cs:line 32
   at Program.<Main>$(String[] args) in /home/ana/projetos/Banco/Program.cs:line 6
$ echo $?
134

O que significa: o rastro (stack trace) se lê de cima para baixo: a exceção nasceu em Conta.Sacar, na linha 32 do Conta.cs (o throw), que foi chamado pela linha 6 do Program.cs. Ninguém a pegou, e o programa terminou com o código 134. A mensagem é a que a própria classe escreveu: uma boa mensagem de exceção diz o que deu errado e com quais valores.

Como consertar: envolva a chamada num try e pegue a exceção que você sabe tratar, como no Program.cs da versão 2 (veja try e catch).

Missão (versão 2): o Sacar da aba Conta.cs ainda aceita sacar mais do que o saldo, e a conta fica negativa. Antes do Saldo -= valor;, recuse o saque: if (valor > Saldo), lance uma InvalidOperationException com a mensagem $"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.".

Conta conta = new Conta("Ana");
conta.Depositar(100m);
conta.Sacar(30m);
Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");

try
{
    conta.Sacar(500m);
}
catch (InvalidOperationException erro)
{
    Console.WriteLine($"Não deu: {erro.Message}");
}

try
{
    conta.Depositar(-20m);
}
catch (ArgumentException erro)
{
    Console.WriteLine($"Não deu: {erro.Message}");
}

Console.WriteLine($"{conta.Titular}: {conta.Saldo:C}");
class Conta
{
    public string Titular { get; }
    public decimal Saldo { get; private set; }

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor)
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
    }

    public void Sacar(decimal valor)
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        // recuse o saque maior que o saldo
        Saldo -= valor;
    }
}
class Conta
{
    public string Titular { get; }
    public decimal Saldo { get; private set; }

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor)
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
    }

    public void Sacar(decimal valor)
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
    }
}

Versão 3 · Extrato e transferência

Um banco não guarda só o saldo: guarda cada movimentação, para o extrato. Cada uma tem uma descrição e um valor (positivo para entradas, negativo para saídas). É um dado simples, que não muda depois de criado: um record, no seu próprio arquivo.

record Movimentacao(string Descricao, decimal Valor);

A Conta ganha a lista de movimentações, uma descrição opcional em Depositar e Sacar, e dois métodos novos: Transferir e MostrarExtrato.

class Conta
{
    private readonly List<Movimentacao> _movimentacoes = new();

    public string Titular { get; }
    public decimal Saldo { get; private set; }

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor, string descricao = "Depósito")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
        _movimentacoes.Add(new Movimentacao(descricao, valor));
    }

    public void Sacar(decimal valor, string descricao = "Saque")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
        _movimentacoes.Add(new Movimentacao(descricao, -valor));
    }

    public void Transferir(Conta destino, decimal valor)
    {
        Sacar(valor, $"Transferência para {destino.Titular}");
        destino.Depositar(valor, $"Transferência de {Titular}");
    }

    public void MostrarExtrato()
    {
        Console.WriteLine($"Extrato de {Titular}");
        foreach (Movimentacao m in _movimentacoes)
        {
            Console.WriteLine($"  {m.Descricao,-26} {m.Valor,12:C}");
        }
        Console.WriteLine($"  {"Saldo",-26} {Saldo,12:C}");
    }
}
Conta ana = new Conta("Ana");
Conta bruno = new Conta("Bruno");

ana.Depositar(1500m);
ana.Sacar(200m);
ana.Transferir(bruno, 350.75m);
bruno.Depositar(80m, "Venda do caderno");

ana.MostrarExtrato();
Console.WriteLine();
bruno.MostrarExtrato();
private readonly List<Movimentacao> _movimentacoes = new();
A lista é private: só a conta acrescenta movimentações, e sempre junto com a mudança do saldo. readonly quer dizer que o campo sempre aponta para a mesma lista (readonly e init). O _ no começo é a convenção para campos privados.
string descricao = "Depósito"
Um parâmetro opcional: quem não passar descrição ganha "Depósito". ana.Depositar(1500m) continua funcionando.
new Movimentacao(descricao, -valor)
No saque, o valor entra negativo: o extrato mostra -R$ 200,00.
public void Transferir(Conta destino, decimal valor)
Recebe outra conta como parâmetro. Primeiro saca desta (se o saldo não der, o Sacar lança a exceção e o depósito nem acontece), depois deposita na outra.
{m.Descricao,-26} {m.Valor,12:C}
Colunas alinhadas: a descrição em 26 posições à esquerda, o valor em 12 à direita.
$ ls
Banco.csproj  bin  Conta.cs  Movimentacao.cs  obj  Program.cs
$ dotnet run
Extrato de Ana
  Depósito                    R$ 1.500,00
  Saque                        -R$ 200,00
  Transferência para Bruno     -R$ 350,75
  Saldo                         R$ 949,25

Extrato de Bruno
  Transferência de Ana          R$ 350,75
  Venda do caderno               R$ 80,00
  Saldo                         R$ 430,75
Por que Transferir usa Sacar e Depositar?

Daria para escrever Saldo -= valor; destino.Saldo += valor; (dentro da classe, o private set deixa, até na outra conta). Mas aí a transferência pularia as regras: aceitaria valor negativo e saldo insuficiente, e não apareceria no extrato. Reaproveitando os métodos, toda regra fica num lugar só.

Missão (versão 3): o Transferir da aba Conta.cs está vazio: o Bruno não recebe nada. Escreva as duas linhas: Sacar(valor, $"Transferência para {destino.Titular}"); e destino.Depositar(valor, $"Transferência de {Titular}");.

Conta ana = new Conta("Ana");
Conta bruno = new Conta("Bruno");

ana.Depositar(1500m);
ana.Sacar(200m);
ana.Transferir(bruno, 350.75m);
bruno.Depositar(80m, "Venda do caderno");

ana.MostrarExtrato();
Console.WriteLine();
bruno.MostrarExtrato();
class Conta
{
    private readonly List<Movimentacao> _movimentacoes = new();

    public string Titular { get; }
    public decimal Saldo { get; private set; }

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor, string descricao = "Depósito")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
        _movimentacoes.Add(new Movimentacao(descricao, valor));
    }

    public void Sacar(decimal valor, string descricao = "Saque")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
        _movimentacoes.Add(new Movimentacao(descricao, -valor));
    }

    public void Transferir(Conta destino, decimal valor)
    {
        // saque nesta conta e depósito na conta destino, com as descrições
    }

    public void MostrarExtrato()
    {
        Console.WriteLine($"Extrato de {Titular}");
        foreach (Movimentacao m in _movimentacoes)
        {
            Console.WriteLine($"  {m.Descricao,-26} {m.Valor,12:C}");
        }
        Console.WriteLine($"  {"Saldo",-26} {Saldo,12:C}");
    }
}
record Movimentacao(string Descricao, decimal Valor);
class Conta
{
    private readonly List<Movimentacao> _movimentacoes = new();

    public string Titular { get; }
    public decimal Saldo { get; private set; }

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor, string descricao = "Depósito")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
        _movimentacoes.Add(new Movimentacao(descricao, valor));
    }

    public void Sacar(decimal valor, string descricao = "Saque")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
        _movimentacoes.Add(new Movimentacao(descricao, -valor));
    }

    public void Transferir(Conta destino, decimal valor)
    {
        Sacar(valor, $"Transferência para {destino.Titular}");
        destino.Depositar(valor, $"Transferência de {Titular}");
    }

    public void MostrarExtrato()
    {
        Console.WriteLine($"Extrato de {Titular}");
        foreach (Movimentacao m in _movimentacoes)
        {
            Console.WriteLine($"  {m.Descricao,-26} {m.Valor,12:C}");
        }
        Console.WriteLine($"  {"Saldo",-26} {Saldo,12:C}");
    }
}

Versão 4 · A poupança herda da conta

A Ana quer uma poupança para a reserva da loja. Uma poupança é uma conta (tem titular, saldo, depósito, saque, extrato) com uma coisa a mais: ela rende juros. Copiar a classe Conta inteira seria duplicar tudo. Com herança, a ContaPoupanca herda tudo da Conta e só acrescenta o que é dela.

Na Conta, uma linha nova: uma propriedade que diz o tipo da conta, marcada como virtual (as filhas podem trocá-la):

    public virtual string Tipo => "Conta corrente";
class ContaPoupanca : Conta
{
    public decimal TaxaMensal { get; }
    public override string Tipo => "Poupança";

    public ContaPoupanca(string titular, decimal taxaMensal) : base(titular)
    {
        TaxaMensal = taxaMensal;
    }

    public void RenderJuros()
    {
        decimal juros = Math.Round(Saldo * TaxaMensal, 2);
        if (juros > 0)
        {
            Depositar(juros, $"Rendimento de {TaxaMensal:P1}");
        }
    }
}
Conta corrente = new Conta("Ana");
ContaPoupanca reserva = new ContaPoupanca("Ana", 0.005m);

corrente.Depositar(1500m);
corrente.Transferir(reserva, 1000m);
reserva.RenderJuros();

List<Conta> contas = [corrente, reserva];
foreach (Conta conta in contas)
{
    Console.WriteLine($"{conta.Tipo,-15} {conta.Titular,-6} {conta.Saldo,12:C}");
}

Console.WriteLine();
reserva.MostrarExtrato();
class ContaPoupanca : Conta
O : diz "é uma Conta": a poupança herda Titular, Saldo, Depositar, Sacar, Transferir e MostrarExtrato.
public virtual string Tipo => "Conta corrente";
Na Conta: uma propriedade calculada (o => devolve o valor), que as classes filhas podem substituir.
public override string Tipo => "Poupança";
Na poupança: substitui (override) a versão da mãe.
: base(titular)
O construtor da poupança chama primeiro o construtor da Conta, que confere e guarda o titular (base). Depois guarda a taxa.
Math.Round(Saldo * TaxaMensal, 2)
Os juros, arredondados para centavos. Saldo é a propriedade herdada: a poupança pode ler, e muda só por Depositar.
{TaxaMensal:P1}
Formato de porcentagem com 1 casa: 0.005m vira 0,5%.
List<Conta> contas = [corrente, reserva];
Uma lista de Conta aceita a poupança, porque toda poupança é uma conta.
conta.Tipo
Para cada conta da lista, o C# usa o Tipo do objeto de verdade: "Conta corrente" para uma, "Poupança" para a outra. Isso é o polimorfismo.
$ ls
Banco.csproj  bin  Conta.cs  ContaPoupanca.cs  Movimentacao.cs  obj  Program.cs
$ dotnet run
Conta corrente  Ana       R$ 500,00
Poupança        Ana     R$ 1.005,00

Extrato de Ana
  Transferência de Ana        R$ 1.000,00
  Rendimento de 0,5%              R$ 5,00
  Saldo                       R$ 1.005,00
Esqueci o : base(titular): erro CS7036
$ dotnet run
/home/ana/projetos/Banco/ContaPoupanca.cs(6,12): error CS7036: Não há nenhum argumento fornecido que corresponda ao parâmetro necessário 'titular' de 'Conta.Conta(string)' [/home/ana/projetos/Banco/Banco.csproj]

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: todo construtor de uma classe filha chama um construtor da mãe. Sem o : base(…), o C# tenta chamar o construtor sem parâmetros da Conta, mas ele não existe: o único pede o titular. O erro aponta o construtor da poupança, na linha 6.

Como consertar: public ContaPoupanca(string titular, decimal taxaMensal) : base(titular).

Esqueci o virtual: erro CS0506
$ dotnet run
/home/ana/projetos/Banco/ContaPoupanca.cs(4,28): error CS0506: "ContaPoupanca.Tipo": não é possível substituir o membro herdado "Conta.Tipo" porque ele não está marcado como virtual, abstract ou override [/home/ana/projetos/Banco/Banco.csproj]

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que significa: no C#, a classe-mãe decide o que pode ser substituído. Sem virtual no Tipo da Conta, o override da poupança não é permitido.

Como consertar: public virtual string Tipo => "Conta corrente"; na Conta (veja virtual e override).

Missão (versão 4): a lista mostra as duas contas como "Conta corrente". Na aba ContaPoupanca.cs, substitua o tipo: public override string Tipo => "Poupança";.

Conta corrente = new Conta("Ana");
ContaPoupanca reserva = new ContaPoupanca("Ana", 0.005m);

corrente.Depositar(1500m);
corrente.Transferir(reserva, 1000m);
reserva.RenderJuros();

List<Conta> contas = [corrente, reserva];
foreach (Conta conta in contas)
{
    Console.WriteLine($"{conta.Tipo,-15} {conta.Titular,-6} {conta.Saldo,12:C}");
}

Console.WriteLine();
reserva.MostrarExtrato();
class ContaPoupanca : Conta
{
    public decimal TaxaMensal { get; }

    public ContaPoupanca(string titular, decimal taxaMensal) : base(titular)
    {
        TaxaMensal = taxaMensal;
    }

    public void RenderJuros()
    {
        decimal juros = Math.Round(Saldo * TaxaMensal, 2);
        if (juros > 0)
        {
            Depositar(juros, $"Rendimento de {TaxaMensal:P1}");
        }
    }
}
class Conta
{
    private readonly List<Movimentacao> _movimentacoes = new();

    public string Titular { get; }
    public decimal Saldo { get; private set; }
    public virtual string Tipo => "Conta corrente";

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor, string descricao = "Depósito")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
        _movimentacoes.Add(new Movimentacao(descricao, valor));
    }

    public void Sacar(decimal valor, string descricao = "Saque")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
        _movimentacoes.Add(new Movimentacao(descricao, -valor));
    }

    public void Transferir(Conta destino, decimal valor)
    {
        Sacar(valor, $"Transferência para {destino.Titular}");
        destino.Depositar(valor, $"Transferência de {Titular}");
    }

    public void MostrarExtrato()
    {
        Console.WriteLine($"Extrato de {Titular}");
        foreach (Movimentacao m in _movimentacoes)
        {
            Console.WriteLine($"  {m.Descricao,-26} {m.Valor,12:C}");
        }
        Console.WriteLine($"  {"Saldo",-26} {Saldo,12:C}");
    }
}
record Movimentacao(string Descricao, decimal Valor);
class ContaPoupanca : Conta
{
    public decimal TaxaMensal { get; }
    public override string Tipo => "Poupança";

    public ContaPoupanca(string titular, decimal taxaMensal) : base(titular)
    {
        TaxaMensal = taxaMensal;
    }

    public void RenderJuros()
    {
        decimal juros = Math.Round(Saldo * TaxaMensal, 2);
        if (juros > 0)
        {
            Depositar(juros, $"Rendimento de {TaxaMensal:P1}");
        }
    }
}

O programa completo

A versão final tem quatro arquivos na pasta ~/projetos/Banco. O Program.cs e o ContaPoupanca.cs estão na versão 4, e o Movimentacao.cs na versão 3. A Conta.cs completa:

class Conta
{
    private readonly List<Movimentacao> _movimentacoes = new();

    public string Titular { get; }
    public decimal Saldo { get; private set; }
    public virtual string Tipo => "Conta corrente";

    public Conta(string titular)
    {
        if (string.IsNullOrWhiteSpace(titular))
        {
            throw new ArgumentException("O titular não pode ficar vazio.");
        }
        Titular = titular;
    }

    public void Depositar(decimal valor, string descricao = "Depósito")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O depósito tem de ser maior que zero.");
        }
        Saldo += valor;
        _movimentacoes.Add(new Movimentacao(descricao, valor));
    }

    public void Sacar(decimal valor, string descricao = "Saque")
    {
        if (valor <= 0)
        {
            throw new ArgumentException("O saque tem de ser maior que zero.");
        }
        if (valor > Saldo)
        {
            throw new InvalidOperationException($"Saldo insuficiente: o saldo é {Saldo:C} e o saque, {valor:C}.");
        }
        Saldo -= valor;
        _movimentacoes.Add(new Movimentacao(descricao, -valor));
    }

    public void Transferir(Conta destino, decimal valor)
    {
        Sacar(valor, $"Transferência para {destino.Titular}");
        destino.Depositar(valor, $"Transferência de {Titular}");
    }

    public void MostrarExtrato()
    {
        Console.WriteLine($"Extrato de {Titular}");
        foreach (Movimentacao m in _movimentacoes)
        {
            Console.WriteLine($"  {m.Descricao,-26} {m.Valor,12:C}");
        }
        Console.WriteLine($"  {"Saldo",-26} {Saldo,12:C}");
    }
}

O que o projeto juntou: classes e objetos, propriedades com private set, construtores, throw e try/catch, um record numa List private readonly, parâmetros opcionais, herança com base, virtual e override e decimal com :C e :P1.


Desafios extras

  1. Exceção própria. Troque a InvalidOperationException por uma SaldoInsuficienteException com as propriedades Saldo e Valor (veja Exceções Próprias).
  2. Cheque especial. Crie uma ContaCorrente com um Limite, que deixa o saldo ficar negativo até o limite. Para isso, o Sacar da Conta precisa ser virtual, ou a regra "quanto pode sacar" pode virar uma propriedade virtual decimal Disponivel.
  3. Extrato só de leitura. Exponha as movimentações com public IReadOnlyList<Movimentacao> Movimentacoes => _movimentacoes; e mova o MostrarExtrato para o Program.cs: a conta guarda os dados, e quem mostra é o programa.
  4. Testes. Num projeto de testes, confira que um saque maior que o saldo lança a exceção e não muda o saldo (veja Testes (xUnit)).
  5. Salvar as contas. Guarde as contas num arquivo JSON, como na Agenda. Cuidado: com o private set, o JsonSerializer não consegue preencher o saldo sozinho (veja JSON).

Exercícios

1. O que public decimal Saldo { get; private set; } permite?

2. Por que o Sacar lança uma exceção em vez de só mostrar "Saldo insuficiente" com Console.WriteLine?

3. Em class ContaPoupanca : Conta, o que a poupança tem?

4. Na lista List<Conta>, por que conta.Tipo mostra "Poupança" para a reserva?

5. Complete o construtor da poupança, que chama o construtor da Conta:

public ContaPoupanca(string titular, decimal taxaMensal) : (titular)

6. Complete o modificador que deixa o saldo ser mudado só dentro da classe:

public decimal Saldo { get; set; }

Resumo

  • Uma classe junta dados e comportamento; cada new cria um objeto independente.
  • Campos públicos deixam qualquer um quebrar as regras. Com { get; private set; }, o saldo só muda pelos métodos da classe (fora dela, CS0272).
  • O construtor obriga a criar o objeto num estado válido; os métodos recusam operações inválidas com throw, e quem chama trata com try/catch.
  • Um record numa lista private readonly guarda o extrato; reaproveitar Sacar e Depositar na transferência mantém as regras num lugar só.
  • Com herança, a filha recebe tudo da mãe e chama o construtor dela com : base(…) (senão, CS7036).
  • Só membros virtual podem ser substituídos com override (senão, CS0506); numa lista da mãe, vale a versão do objeto de verdade.

Próximo projeto: Estoque da Lojinha (LINQ).