Métodos: Dividindo o Problema em Partes Que Conversam

Métodos: Dividindo o Problema em Partes Que Conversam

Métodos em C#: declaração, retorno e corpo de expressão, funções locais nas instruções de nível superior, passagem por valor, ref, out e o padrão Try, parâmetros opcionais e nomeados, sobrecarga dentro de uma classe, params, recursão e estouro, e a Livraria reorganizada.
Linguagem C#

• • 16 min de leitura

Um bom método tem um nome que diz o que ele faz, recebe o que precisa e devolve o que promete, e é isso que permite a um programa crescer sem virar um emaranhado.

O menu da Livraria que construímos em "O Programa Que Escolhe e Repete" funciona, mas tem cheiro de problema. A validação de números aparece repetida, o cálculo do desconto está misturado com a interface e o bloco do switch já não cabe numa tela. Todo programa que cresce chega a esse ponto, e a resposta é sempre a mesma: dar nome a trechos de código e reaproveitá-los. Em C#, esses trechos nomeados se chamam métodos. Hoje vamos aprender a declará-los, a passar dados para eles de todas as formas que a linguagem permite e a reorganizar a Livraria com eles.

Declarando e chamando um método

Um método tem um tipo de retorno, um nome, uma lista de parâmetros e um corpo:

decimal total = CalcularTotal(59.90m, 3);
Console.WriteLine(total);   // 179,70

static decimal CalcularTotal(decimal precoUnitario, int quantidade)
{
    return precoUnitario * quantidade;
}

Leia a declaração assim: "CalcularTotal recebe um decimal e um int e devolve um decimal". O return encerra o método e entrega o valor a quem chamou.

Dois detalhes deste exemplo. Nas instruções de nível superior, os métodos são declarados depois do código que os usa, e está tudo bem: o compilador enxerga o arquivo inteiro. Tecnicamente, eles viram funções locais do Main que o compilador gera. E o static na frente garante que o método não tenha acesso às variáveis soltas do programa principal: ele só trabalha com o que recebe por parâmetro. É um bom hábito, e vamos usá-lo sempre. Na forma clássica, com classe, os métodos ficam dentro da classe, e o static tem um significado mais amplo, que fica claro na Fase 2.

Quando o método não devolve nada, o tipo de retorno é void:

ImprimirLinha('=', 30);

static void ImprimirLinha(char simbolo, int tamanho)
{
    Console.WriteLine(new string(simbolo, tamanho));
}

E quando o corpo é uma única expressão, existe uma forma compacta, com =>:

static decimal CalcularTotal(decimal precoUnitario, int quantidade) => precoUnitario * quantidade;
static bool EhPar(int n) => n % 2 == 0;

Convenção de nomes: métodos usam PascalCase (cada palavra com inicial maiúscula) e costumam ser verbos: CalcularTotal, ImprimirLinha, LerInteiro. Parâmetros e variáveis locais usam camelCase: precoUnitario, quantidade. Seguir a convenção faz seu código parecer o da biblioteca padrão, o que ajuda qualquer leitor.

Passagem por valor: o método recebe uma cópia

Em "Cada Valor Tem um Tipo" vimos que atribuir um tipo de valor copia o dado. Passar para um método funciona igual:

int estoque = 10;
TentarZerar(estoque);
Console.WriteLine(estoque);   // 10: nada mudou

static void TentarZerar(int valor)
{
    valor = 0;   // altera só a cópia local
}

O parâmetro valor é uma variável nova, que recebeu uma cópia de estoque. Alterá-la não afeta o original. Esse é o comportamento padrão do C#, e é bom que seja assim: olhando só a chamada TentarZerar(estoque), você sabe que estoque não vai mudar. O jeito idiomático de um método "alterar" um valor é devolver o novo valor: estoque = Zerado(estoque);.

(Com tipos de referência, como arrays, o que é copiado é a referência. O método consegue alterar o conteúdo do array original, pela mesma lógica de y[0] = 99 naquela aula. Voltaremos a isso com arrays e classes.)

ref, out e in

Às vezes você realmente quer que o método trabalhe sobre a variável original. O C# oferece três modificadores para isso, e todos exigem que a chamada também os declare, para que a intenção fique visível nos dois lados.

ref passa a própria variável. O método pode ler e alterar o original:

int a = 1, b = 2;
Trocar(ref a, ref b);
Console.WriteLine($"a = {a}, b = {b}");   // a = 2, b = 1

static void Trocar(ref int x, ref int y)
{
    int temporario = x;
    x = y;
    y = temporario;
}

Se você vem do C, ref é o equivalente seguro de passar um ponteiro (int *x), sem * para desreferenciar, sem risco de ponteiro nulo e sem aritmética.

out é uma variação: o método promete atribuir um valor antes de terminar, e quem chama não precisa inicializar a variável. Você já usa out desde "Cada Valor Tem um Tipo", no TryParse. Agora podemos escrever o nosso:

if (TentarLerQuantidade("12", out int qtd))
    Console.WriteLine($"Quantidade: {qtd}");

static bool TentarLerQuantidade(string? texto, out int quantidade)
{
    if (int.TryParse(texto, out quantidade) && quantidade > 0)
        return true;

    quantidade = 0;   // obrigatório: out precisa ter valor em todos os caminhos
    return false;
}

O padrão "devolver bool e entregar o resultado por out" é chamado de padrão Try, e está por toda a biblioteca do .NET. Use-o sempre que uma operação puder falhar de forma esperada, como uma entrada inválida do usuário. Assim você evita usar exceções para o que não é excepcional.

in passa por referência apenas para leitura: o método não pode alterar o original. Parece inútil com um int, e é mesmo. Ele serve para evitar a cópia de estruturas grandes, algo que só faz sentido depois de estudarmos struct na Fase 2 e a memória na Fase 4. Por ora, basta saber que existe.

Uma regra de bom senso: ref e out são ferramentas legítimas, mas pouco frequentes. Se todo método seu tem ref, provavelmente eles deveriam estar devolvendo valores, ou os dados deveriam estar agrupados num objeto, como veremos na Fase 2.

Parâmetros opcionais e argumentos nomeados

Um parâmetro pode ter um valor padrão e se tornar opcional:

Console.WriteLine(CalcularFrete(150m));              // usa o limite padrão
Console.WriteLine(CalcularFrete(150m, 100m));        // informa o limite

static decimal CalcularFrete(decimal subtotal, decimal limiteFreteGratis = 200m, decimal valorFrete = 15m)
{
    return subtotal >= limiteFreteGratis ? 0m : valorFrete;
}

Os parâmetros opcionais precisam vir depois dos obrigatórios. E, quando há vários opcionais, os argumentos nomeados permitem pular os do meio:

decimal frete = CalcularFrete(150m, valorFrete: 20m);

Argumentos nomeados também servem para deixar chamadas mais legíveis mesmo quando não são necessários. ImprimirLinha(simbolo: '-', tamanho: 40) explica-se sozinho. Já Configurar(true, false, true) obriga o leitor a abrir a declaração para descobrir o que cada bool significa.

Sobrecarga

Vários métodos podem ter o mesmo nome, desde que tenham listas de parâmetros diferentes. O compilador escolhe qual chamar pelos tipos dos argumentos:

Console.WriteLine(Formato.Formatar(59.90m));   // R$ 59,90
Console.WriteLine(Formato.Formatar(3));        // 3 exemplares
Console.WriteLine(Formato.Formatar(1));        // 1 exemplar

static class Formato
{
    public static string Formatar(decimal valor) => $"R$ {valor:F2}";
    public static string Formatar(int quantidade) => quantidade == 1 ? "1 exemplar" : $"{quantidade} exemplares";
}

É o que acontece com o Console.WriteLine que você usa desde a primeira aula: existem versões dele para string, int, double, bool e muitos outros. O tipo de retorno sozinho não diferencia sobrecargas: dois métodos com os mesmos parâmetros e retornos diferentes são um erro.

Repare que os dois Formatar estão dentro de uma classe, Formato, e são chamados como Formato.Formatar. Não é capricho. Os métodos escritos soltos depois das instruções de nível superior são funções locais do Main gerado, e funções locais não aceitam sobrecarga: dois Formatar soltos no mesmo arquivo param na compilação com o erro CS0128, que diz que já existe uma função local com esse nome. Dentro de uma classe, a sobrecarga funciona normalmente. Classes são o assunto da Fase 2; por ora, basta saber que uma static class, declarada depois do código principal, é um lugar para agrupar métodos.

params: número variável de argumentos

Quando um método pode receber quantos valores o chamador quiser, use params:

Console.WriteLine(Somar(59.90m, 42.50m));            // 102,40
Console.WriteLine(Somar(59.90m, 42.50m, 38.00m));    // 140,40

static decimal Somar(params decimal[] valores)
{
    decimal soma = 0;
    foreach (decimal v in valores)
        soma += v;
    return soma;
}

O compilador junta os argumentos num array. Só pode haver um params, e ele precisa ser o último parâmetro.

Recursão: um método que chama a si mesmo

Um método pode chamar a si próprio. O exemplo clássico é o fatorial (5! = 5 × 4 × 3 × 2 × 1):

Console.WriteLine(Fatorial(5));    // 120
Console.WriteLine(Fatorial(20));   // 2432902008176640000

static long Fatorial(int n)
{
    if (n <= 1)
        return 1;              // caso base: onde a recursão para
    return n * Fatorial(n - 1);
}

Toda recursão precisa de um caso base, uma condição que devolve sem chamar a si mesma. Sem ele, o método se chama indefinidamente. Cada chamada ocupa um espaço na pilha (a mesma stack que aparece em qualquer curso de C), e quando a pilha acaba o .NET encerra o processo com um StackOverflowException. Diferente das outras exceções que vimos, essa não pode ser capturada com try/catch: o programa simplesmente termina.

Há outra armadilha escondida aqui. Fatorial(20) cabe num long, mas Fatorial(21) passa de 9,2 quintilhões e dá a volta em silêncio, produzindo um número negativo. É o mesmo estouro de "Cada Valor Tem um Tipo". Um checked(n * Fatorial(n - 1)) transformaria o lixo numa exceção clara.

Recursão é elegante para problemas que têm estrutura recursiva, como árvores, pastas dentro de pastas e divisão e conquista. Para uma simples repetição, como o fatorial, o laço for é mais eficiente e não corre risco de estourar a pilha. Veremos recursão de verdade quando estudarmos estruturas de dados e LINQ.

Documentando com ///

Comentários que começam com três barras, logo acima de um método, viram documentação que o editor mostra ao passar o mouse sobre qualquer chamada:

/// <summary>
/// Calcula o percentual de desconto conforme a quantidade comprada.
/// </summary>
/// <param name="quantidade">Número de exemplares no pedido.</param>
/// <returns>Percentual entre 0 e 0,15.</returns>
static decimal PercentualDesconto(int quantidade) => quantidade switch
{
    >= 10 => 0.15m,
    >= 3  => 0.10m,
    _     => 0m
};

Não documente o óbvio. Um método chamado EhPar não precisa de summary. Documente regras de negócio, limites e comportamentos que o nome não conta.

Juntando tudo: a Livraria reorganizada

Hora de reescrever o menu da aula anterior. Cada responsabilidade ganha seu método:

using System.Globalization;

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

int opcao;
do
{
    ExibirMenu();
    opcao = LerInteiro("Escolha: ", minimo: 0);

    switch (opcao)
    {
        case 1: ExibirCatalogo(); break;
        case 2: FazerPedido(); break;
        case 0: Console.WriteLine("Até logo!"); break;
        default: Console.WriteLine("Opção inválida."); break;
    }
}
while (opcao != 0);

static void ExibirMenu()
{
    Console.WriteLine();
    Console.WriteLine("=== Livraria ===");
    Console.WriteLine("1 - Ver catálogo");
    Console.WriteLine("2 - Fazer pedido");
    Console.WriteLine("0 - Sair");
}

static void ExibirCatalogo()
{
    ImprimirItem("Dom Casmurro", 59.90m);
    ImprimirItem("O Cortiço", 42.50m);
    ImprimirItem("Vidas Secas", 38.00m);
}

static void ImprimirItem(string titulo, decimal preco) =>
    Console.WriteLine($"{titulo,-25}{preco,12:C}");

static void FazerPedido()
{
    decimal preco = LerDecimal("Preço unitário: ");
    int quantidade = LerInteiro("Quantidade: ", minimo: 1);

    decimal subtotal = preco * quantidade;
    decimal percentual = PercentualDesconto(quantidade);
    decimal total = subtotal * (1 - percentual);

    Console.WriteLine($"Subtotal: {subtotal:C}");
    Console.WriteLine($"Desconto: {percentual:P0}");
    Console.WriteLine($"Total:    {total:C}");
}

/// <summary>Percentual de desconto por volume: 10% a partir de 3, 15% a partir de 10.</summary>
static decimal PercentualDesconto(int quantidade) => quantidade switch
{
    >= 10 => 0.15m,
    >= 3  => 0.10m,
    _     => 0m
};

static int LerInteiro(string mensagem, int minimo)
{
    while (true)
    {
        Console.Write(mensagem);
        if (int.TryParse(Console.ReadLine(), out int valor) && valor >= minimo)
            return valor;
        Console.WriteLine($"Digite um número inteiro a partir de {minimo}.");
    }
}

static decimal LerDecimal(string mensagem)
{
    while (true)
    {
        Console.Write(mensagem);
        if (decimal.TryParse(Console.ReadLine(), out decimal valor) && valor > 0)
            return valor;
        Console.WriteLine("Digite um valor positivo, como 59,90.");
    }
}

Compare com a versão da aula anterior. O trecho principal cabe em poucas linhas e se lê quase como português. A validação existe em um só lugar e não pode mais ser esquecida. E a regra de desconto está isolada em PercentualDesconto, que não sabe nada sobre console. Isso significa que ela pode ser testada sozinha, e é exatamente isso que faremos quando chegarmos aos testes automatizados na Fase 4.

Uma limitação honesta: se a entrada padrão acabar (por exemplo, quando alguém redireciona um arquivo para o programa), Console.ReadLine() passa a devolver null para sempre, e LerInteiro entra num laço infinito. Para um programa interativo isso raramente acontece, mas um programa robusto trataria o null e encerraria. Fica como desafio pensar em como. A aula sobre exceções dá uma boa ferramenta para isso.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um método EhPar em forma de expressão (=>) e use-o num laço que imprime quantos números pares existem entre 1 e 100.

Ver resposta

✓ Resposta:

int pares = 0;
for (int i = 1; i <= 100; i++)
{
    if (EhPar(i))
        pares++;
}
Console.WriteLine($"Pares entre 1 e 100: {pares}");   // 50

static bool EhPar(int n) => n % 2 == 0;

O método devolve bool, então pode ser usado direto na condição do if. Nada de if (EhPar(i) == true), que é redundante. O nome no formato de pergunta (EhPar, EstaDisponivel, PodeComprar) é a convenção para métodos que devolvem bool, porque a chamada se lê como uma frase.

Exercício 2

O método abaixo não dobra o valor original. Explique por quê e corrija de duas formas: devolvendo o resultado e usando ref.

int preco = 50;
Dobrar(preco);
Console.WriteLine(preco);   // 50

static void Dobrar(int valor) { valor *= 2; }
Ver resposta

✓ Resposta: int é um tipo de valor, e parâmetros são passados por valor por padrão. Dobrar recebe uma cópia de preco e altera só a cópia, que deixa de existir quando o método termina. Primeira correção, a idiomática:

preco = Dobrado(preco);
static int Dobrado(int valor) => valor * 2;

Segunda correção, com ref:

Dobrar(ref preco);
static void Dobrar(ref int valor) { valor *= 2; }

A primeira é preferível na maioria dos casos: a chamada preco = Dobrado(preco) deixa óbvio que preco muda, e Dobrado é um método sem efeitos colaterais, mais fácil de testar e de entender. O ref se justifica quando o método precisa alterar mais de uma variável, como no Trocar, ou quando copiar o valor seria caro.

Exercício 3

Crie duas sobrecargas de um método Area: uma que recebe o raio de um círculo e outra que recebe largura e altura de um retângulo. Ambas devolvem double.

Ver resposta

✓ Resposta:

Console.WriteLine($"{Geometria.Area(2.0):F2}");        // 12,57
Console.WriteLine($"{Geometria.Area(3.0, 4.0):F2}");   // 12,00

static class Geometria
{
    public static double Area(double raio) => Math.PI * raio * raio;
    public static double Area(double largura, double altura) => largura * altura;
}

As sobrecargas ficam dentro de uma classe porque funções locais, que é o que os métodos soltos de um programa com instruções de nível superior viram, não podem ser sobrecarregadas: soltos, os dois Area dariam o erro CS0128. Elas se diferenciam pelo número de parâmetros, e o compilador escolhe pela chamada. Math.PI é a constante π da biblioteca padrão. Uma reflexão: se existisse também um quadrado, Area(double lado) teria a mesma assinatura que a versão do círculo, e o compilador recusaria. A sobrecarga não resolve ambiguidade de significado. Nesse caso, nomes diferentes (AreaCirculo, AreaQuadrado) seriam a saída. E na Fase 2 veremos uma solução ainda melhor, com tipos.

Exercício 4

Escreva um método recursivo Potencia(long b, int expoente) que calcule b elevado ao expoente (expoente ≥ 0). Qual é o caso base? O que acontece com Potencia(2, 64)?

Ver resposta

✓ Resposta:

Console.WriteLine(Potencia(2, 10));   // 1024
Console.WriteLine(Potencia(5, 0));    // 1

static long Potencia(long b, int expoente)
{
    if (expoente == 0)
        return 1;                      // caso base: qualquer número elevado a 0 é 1
    return b * Potencia(b, expoente - 1);
}

O caso base é expoente == 0, que devolve 1 sem nova chamada. Cada chamada reduz o expoente em 1, garantindo que ele seja alcançado. Potencia(2, 64) estoura o long. O maior valor de um long é 2⁶³ − 1, então já 2⁶³ não cabe. Pela regra do estouro silencioso, 2⁶³ vira o mínimo negativo e 2⁶⁴ dá a volta completa e vira 0. Um resultado absurdo, e sem aviso. Com return checked(b * Potencia(b, expoente - 1));, o estouro vira uma OverflowException. Um bônus para os curiosos: existe um algoritmo muito mais rápido, a exponenciação rápida, que usa b^n = (b^(n/2))² e resolve com cerca de log₂(n) multiplicações em vez de n.

Exercício 5

Escreva um método TentarDividir(int dividendo, int divisor, out int quociente) no padrão Try: ele devolve false quando o divisor é zero, em vez de lançar exceção. Use-o num programa que lê dois números.

Ver resposta

✓ Resposta:

int a = LerInteiro("Dividendo: ");
int b = LerInteiro("Divisor: ");

if (TentarDividir(a, b, out int resultado))
    Console.WriteLine($"{a} / {b} = {resultado}");
else
    Console.WriteLine("Não é possível dividir por zero.");

static bool TentarDividir(int dividendo, int divisor, out int quociente)
{
    if (divisor == 0)
    {
        quociente = 0;
        return false;
    }
    quociente = dividendo / divisor;
    return true;
}

static int LerInteiro(string mensagem)
{
    while (true)
    {
        Console.Write(mensagem);
        if (int.TryParse(Console.ReadLine(), out int valor))
            return valor;
        Console.WriteLine("Digite um número inteiro.");
    }
}

O out obriga o método a atribuir quociente em todos os caminhos, inclusive no de falha. Por isso o quociente = 0; antes do return false. Sem ele, o compilador acusa erro. Quem chama usa out int resultado, declarando a variável na própria chamada, igualzinho ao int.TryParse. Isso não é coincidência: você acabou de escrever um método no mesmo padrão da biblioteca padrão. Há ainda um caso de borda que vale conhecer: int.MinValue / -1 também estoura, porque o resultado positivo não cabe num int, e lança OverflowException mesmo sem checked. Um TentarDividir à prova de tudo trataria esse caso também.

Comentários

Mais em Linguagem C#

Interfaces: Contratos Que Qualquer Tipo Pode Assinar
Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces em C#: contratos que tipos sem parentesco assinam, várias…

ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection
ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection

Como o C# conversa com o banco pelo ADO.NET: conexões e pool, ExecuteNonQuery…

Razor Pages: HTML montado no servidor para a vitrine da Livraria
Razor Pages: HTML montado no servidor para a vitrine da Livraria

A Livraria ganha uma vitrine com Razor Pages: páginas montadas no servidor…