Delegates

Delegates no C# são tipos que guardam referências para métodos, funcionando como ponteiros de função seguros e normalmente apontando para código externo ao método em execução. Neste artigo, vamos sair da teoria e usar esse recurso para resolver um problema real: tratar sucesso e erro de um serviço sem acoplar tudo dentro dele.

Podemos comparar os delegates aos callbacks que usamos em outras linguagens, como o JavaScript, por exemplo. Embora fora do mundo C# esse conceito apareça em toda parte, muita gente não enxerga sua aplicação no dia a dia do .NET.

Talvez isso aconteça por um mal entendimento do assunto ou pela falta de exemplos práticos. Por isso, o foco aqui é mostrar um cenário concreto, como o que vivemos no balta.

O que são delegates no C#

Segundo a documentação oficial da Microsoft, um delegate é um tipo que representa referências a métodos com uma lista de parâmetros e um tipo de retorno específicos. Em outras palavras, ele descreve uma assinatura, e você pode atribuir qualquer método compatível a uma variável desse tipo.

Na prática, os delegates no C# permitem passar métodos como parâmetros para outros métodos. Além disso, eles servem para definir callbacks, ser encadeados para chamar vários métodos e formar a base dos eventos da plataforma.

Diferente de um ponteiro de função do C++, o delegate é orientado a objetos e seguro em relação aos tipos. Assim, o compilador verifica se o método atribuído respeita a assinatura declarada, e o código nem compila quando isso não acontece.

Quando você usa a palavra-chave delegate, o compilador gera uma classe derivada de System.MulticastDelegate. Por isso, todo delegate herda métodos como Invoke e GetInvocationList, mesmo que você raramente precise chamá-los diretamente.

Contexto do problema

Antes de começar, quero contextualizar o uso dos delegates neste cenário. Também vale frisar que existem várias formas de resolver este problema, mas aqui o foco fica no tema em pauta.

Quando começamos a segmentar nossas aplicações em pacotes, as coisas ficam complicadas. Afinal, não é fácil decidir como expor o sucesso ou o fracasso da execução de um serviço.

Vamos tomar como base os serviços de pagamento. No balta, por exemplo, temos suporte a PayPal e PagarMe, além de alguns testes que já fiz com o Stripe.

Dessa forma, são três serviços distintos para gerenciar. Cada um tem seu formato de retorno, seu objeto de sucesso e seu objeto de erro.

Do outro lado, nossa API segue uma padronização rígida. Toda e qualquer resposta vem no formato abaixo.

public class Response<T> : Notifiable<Notification>
{
    public Response()
    {
    }

    public Response(T data)
    {
        Status = 200;
        Data = data;
    }

    public string Message { get; set; }
    public int Status { get; set; }
    public T Data { get; set; }
}

A resposta herda de Notifiable, que vem do Flunt, e traz a mensagem, o status e os dados genéricos. A classe real tem bem mais coisas, mas reduzi a isso para facilitar o entendimento.

Em resumo, qualquer comunicação entre serviços do balta ou qualquer resposta da API segue este padrão. Portanto, cada integração externa precisa se adaptar a esse contrato.

O desafio de tratar erros de APIs externas

O uso dos delegates neste cenário surgiu justamente para gerenciar os erros ocorridos em APIs externas. Tanto no PayPal quanto no PagarMe, fazemos um POST e esperamos apenas uma string, que é o ID da assinatura criada no serviço.

A parte mais difícil é gerenciar os erros. Como chamamos os dois serviços via RestSharp, podemos ter várias falhas diferentes:

  • URL inválida
  • Falha na serialização para JSON
  • Serviço fora do ar
  • Falha na desserialização do JSON
  • Entre outros problemas

A tentação é jogar tudo dentro de um único try/catch. No entanto, isso não funciona bem, porque não saberíamos onde o erro aconteceu nem qual foi o motivo.

Então usamos vários blocos try, pois precisamos ser precisos sobre o erro que está acontecendo. Em pouco tempo, chegamos a algo parecido com o código abaixo.

var response = new Response<IEnumerable<PagarMeSubscriptionModel>>();
var client = new RestClient("URL");
var httpRequest = new RestRequest("URL", DataFormat.Json);
httpRequest.AddParameter("count", pageSize);

IRestResponse<IEnumerable<PagarMeSubscriptionModel>> httpResponse;
try
{
    httpResponse = client.Get<IEnumerable<PagarMeSubscriptionModel>>(httpRequest);
}
catch (Exception ex)
{
    // Falha na requisição (URL inválida, serviço fora, etc.)
    response.ToInternalServerError(ex);
    return response;
}

if (httpResponse.IsSuccessful)
{
    response.ToSuccess(httpResponse.Data);
    return response;
}

try
{
    // A API respondeu com erro, então lemos o corpo da resposta
    var error = JsonConvert.DeserializeObject<PagarMeErrorJson>(httpResponse.Content);
    response.Message = "Could not list subscriptions";
    foreach (var item in error.Errors)
        response.AddNotification(item.ParameterName, item.Message);

    return response;
}
catch (Exception ex)
{
    response.ToInternalServerError(ex);
    return response;
}

Se você observar, dá para reaproveitar boa parte desse código. Por exemplo, podemos usar T como parâmetro na requisição e tornar o método todo genérico.

Por que delegates no C# resolvem esse cenário

O exemplo anterior trata o sucesso e diversas falhas diferentes dentro do mesmo método. Além disso, todas essas tratativas ficam acopladas a um único modelo.

No caso do PayPal, o código seria bem parecido e teríamos que reescrevê-lo. Com isso, terminaríamos com dois serviços fazendo praticamente a mesma coisa.

IMPORTANTE: não vale criar uma flag isPayPal ou isPagarMe e chavear dentro do método. A ideia aqui é reduzir complexidade e código, e não aumentar.

É exatamente aqui que os delegates no C# entram. Em vez de o método decidir o que fazer com o resultado, ele apenas avisa quem o chamou, e quem chamou decide como tratar cada caso.

O papel do RestSharp

O RestSharp faz requisições HTTP em APIs e já consegue serializar o resultado em um objeto. Isso facilita muito a vida de quem integra serviços externos.

Tudo o que ele precisa é de um Request e de um Client, e então ele retorna um Response. Esse retorno pode ser tipado, inclusive com o uso de generics.

Nosso maior problema, então, é tratar os dados em caso de sucesso e tratar os erros em caso de falha. Porém, queremos fazer isso fora do método que executa a requisição.

Por que fora? Porque a maior parte do código acima existe só para isso, e é justamente ela que engessa nossas mudanças.

Iniciando o projeto

Para este exemplo, você só precisa criar uma aplicação de console. Em seguida, basta adicionar duas classes de modelo a ela.

dotnet new console -o DelegatesSample
cd DelegatesSample
code .

Com o VS Code aberto, crie uma pasta chamada Models com dois arquivos, ArticleModel.cs e ErrorModel.cs. Eles representam, respectivamente, o dado de sucesso e o dado de erro.

namespace DelegatesSample.Models;

public class ArticleModel
{
    public ArticleModel(int id, string title)
    {
        Id = id;
        Title = title;
    }

    public int Id { get; }
    public string Title { get; }
}

public class ErrorModel
{
    public ErrorModel(string message)
    {
        Message = message;
    }

    public string Message { get; }
}

Os dois modelos são propositalmente simples. Assim, toda a atenção fica no mecanismo de callbacks, e não nos dados.

Criando delegates no C#

Nosso primeiro passo é declarar os delegates. No exemplo, eles ficam dentro da classe Program, acima do método Main.

private delegate void OnSuccessDelegate(IEnumerable<ArticleModel> data);
private delegate void OnErrorDelegate(ErrorModel data);

Com os "ponteiros" definidos, podemos usá-los como parâmetros de métodos. Repare também que os nomes remetem a sucesso e erro.

Cada declaração define uma assinatura. Ou seja, OnSuccessDelegate aceita qualquer método que receba uma coleção de ArticleModel e não retorne nada.

Nosso objetivo agora é colocar esses delegates como parâmetros do serviço e gerenciar o sucesso ou o erro fora dele. Também é importante notar que enviamos dados para os delegates no momento da chamada.

private static void GetArticles(
    OnSuccessDelegate onSuccess,
    OnErrorDelegate onError)
{
    // ...
}

Com tudo declarado, temos dois "métodos" que podem ser chamados durante a execução: onError e onSuccess. Na prática, você os invoca da mesma forma que um método comum.

onSuccess(data);
onError(new ErrorModel($"An error occurred: {ex.Message}"));

Você já consegue imaginar o que vai acontecer neste ponto? Se sim, parabéns, você já entendeu a essência dos delegates no C#.

Implementando o serviço com delegates no C#

Continuando, o resultado final do nosso serviço fica como o código a seguir.

private static void GetArticles(
    OnSuccessDelegate onSuccess,
    OnErrorDelegate onError)
{
    try
    {
        var data = new List<ArticleModel>
        {
            new(1, "Object-oriented programming"),
            new(2, "C# fundamentals")
        };

        // Descomente a linha abaixo para forçar o cenário de erro
        // throw new Exception("Something went wrong");

        onSuccess(data);
    }
    catch (Exception ex)
    {
        onError(new ErrorModel($"An error occurred: {ex.Message}"));
    }
}

Note que deixei um throw new Exception comentado para forçarmos um erro. Dessa forma, conseguimos ver os dois cenários funcionando.

Repare também que o método GetArticles não sabe o que acontece com os dados. Ele não escreve no console, não monta resposta HTTP e não conhece o formato de erro da API.

Gerenciando os callbacks

Neste caso, teremos dois métodos, um para sucesso e outro para erro. Como comentei nas aulas de delegates do curso de Fundamentos do C#, as assinaturas precisam ser as mesmas dos delegates.

private static void OnSuccess(IEnumerable<ArticleModel> data)
{
    foreach (var item in data)
    {
        Console.WriteLine(item.Title);
    }
}

private static void OnError(ErrorModel data)
{
    Console.WriteLine($"ERROR: {data.Message}");
}

O método OnSuccess recebe uma coleção de ArticleModel e retorna void, exatamente como OnSuccessDelegate. Da mesma forma, OnError recebe um ErrorModel, como pede OnErrorDelegate.

Se a assinatura for diferente, o compilador acusa o erro na hora. Essa verificação em tempo de compilação é uma das grandes vantagens dos delegates no C# sobre callbacks sem tipo.

Ligando tudo

Até o momento, temos a declaração dos delegates, o uso deles como parâmetro do método GetArticles e a invocação dentro do mesmo método. Agora precisamos conectar tudo isso.

A chave para essa conexão são os handlers, que vão manipular as interações entre as partes do código. O primeiro passo é ligar os delegates aos métodos que criamos.

OnSuccessDelegate onSuccessHandler = OnSuccess;
OnErrorDelegate onErrorHandler = OnError;

Essa atribuição usa a conversão de grupo de métodos. Ou seja, o compilador cria a instância do delegate a partir do nome do método, sem precisar de new OnSuccessDelegate(OnSuccess).

Depois, chamamos o método GetArticles, que espera os dois manipuladores criados acima.

GetArticles(onSuccessHandler, onErrorHandler);

Também é possível passar o nome do método diretamente, como GetArticles(OnSuccess, OnError). Contudo, as variáveis intermediárias deixam o exemplo mais didático.

Resultado da execução

Execute o programa com dotnet run e você verá uma lista com dois artigos. Em seguida, descomente a linha que força a Exception e rode novamente para ver o erro na tela.

Em suma, declaramos os delegates no topo e, nesse momento, eles representam literalmente ponteiros. Assim que criamos uma função que atende à assinatura, já podemos ligá-la a eles.

Essa ligação acontece por meio de um handler. Ele pode invocar o método e também pode ser passado para outras funções.

Por fim, o método GetArticles não se preocupa com o destino do resultado, pois quem trata os casos é o código externo. Com isso, as funções ficam menores e mais expressivas.

Além disso, evitamos espalhar try/catch por todo lado. Também deixamos de retornar um objeto de sucesso que, na verdade, representa um erro.

Usando Action e Func no lugar de delegates próprios

Declarar um tipo de delegate para cada situação pode ficar repetitivo. Por isso, o .NET oferece duas famílias de delegates genéricos prontos, Action e Func, que cobrem a maioria dos casos.

O Action representa um método que não retorna valor, enquanto o Func representa um método que retorna algo. Em ambos, existem versões com zero a dezesseis parâmetros de entrada.

No Func, o último parâmetro genérico é sempre o tipo de retorno. Por exemplo, Func<int, int, int> descreve um método com duas entradas int e um resultado int.

Nosso exemplo poderia ser reescrito sem declarar nenhum delegate próprio, como mostra o código a seguir.

private static void GetArticles(
    Action<IEnumerable<ArticleModel>> onSuccess,
    Action<ErrorModel> onError)
{
    try
    {
        var data = new List<ArticleModel>
        {
            new(1, "Object-oriented programming"),
            new(2, "C# fundamentals")
        };

        onSuccess(data);
    }
    catch (Exception ex)
    {
        onError(new ErrorModel($"An error occurred: {ex.Message}"));
    }
}

Nesse caso, a chamada continua idêntica, porque os métodos OnSuccess e OnError são compatíveis com Action<IEnumerable<ArticleModel>> e Action<ErrorModel>. Ainda assim, um delegate nomeado pode comunicar melhor a intenção em APIs públicas.

Delegates no C# com expressões lambda

Uma expressão lambda é uma função anônima e compacta, escrita com o operador =>. Em certos contextos, o compilador converte a lambda para um tipo de delegate compatível.

Dessa forma, nem precisamos criar os métodos OnSuccess e OnError separadamente. O tratamento pode ficar direto na chamada.

GetArticles(
    data =>
    {
        foreach (var item in data)
            Console.WriteLine(item.Title);
    },
    error => Console.WriteLine($"ERROR: {error.Message}"));

Esse é o mesmo padrão que você usa no LINQ todos os dias. Afinal, métodos como Where e Select recebem um Func como parâmetro, e a lambda é o delegate que você passa para eles.

Delegates no C# com generics

Fizemos um exemplo inicial com conceitos básicos, mas os delegates no C# também aceitam generics. Assim, podemos declarar algo como o código abaixo.

private delegate void OnSuccessDelegate<T>(T data) where T : Entity;

Dado esse delegate, podemos ter vários handlers para manipular um ou vários callbacks, cada um de uma forma diferente. Por exemplo, um handler pode gravar log, enquanto outro converte o resultado para o Response<T> da API.

Voltando ao problema original, essa abordagem permite um único método genérico de requisição HTTP. Então, o PayPal e o PagarMe passam apenas seus próprios handlers de sucesso e de erro.

Multicast: um delegate, vários métodos

Todo delegate pode chamar mais de um método quando é invocado, recurso chamado de multicast. Para adicionar métodos à lista de invocação, usamos os operadores + ou +=.

OnErrorDelegate onErrorHandler = OnError;
onErrorHandler += LogError; // LogError tem a mesma assinatura de OnError

// Chama OnError e depois LogError, nessa ordem
onErrorHandler(new ErrorModel("Payment gateway unavailable"));

O delegate chama os métodos na ordem em que você os adicionou. Para remover um deles, basta usar - ou -=.

Há dois detalhes importantes nesse comportamento. Primeiro, se um dos métodos lançar uma exceção não tratada, os métodos seguintes da lista não são chamados.

Segundo, quando o delegate retorna valor, o resultado da chamada é o retorno do último método invocado. Por isso, multicast faz mais sentido com delegates que retornam void.

Delegates ou eventos

Os eventos do C# se apoiam em delegates. Na verdade, um evento é um campo de delegate com restrições extras impostas pela palavra-chave event.

Com um evento, o código externo só pode se inscrever com += ou cancelar a inscrição com -=. Apenas a classe que declara o evento consegue dispará-lo.

Então, quando usar cada um? A documentação sobre delegates e eventos sugere uma pergunta simples: o seu código precisa obrigatoriamente chamar o código fornecido por quem o usa?

Se a resposta for sim, use delegates, como no nosso GetArticles, que sempre precisa de um handler de sucesso e de erro. Por outro lado, se o código funciona sem nenhum assinante, como uma notificação de progresso, os eventos comunicam melhor a intenção.

Além disso, delegates que retornam valor indicam que o resultado afeta o algoritmo. Os eventos, em geral, usam void e o padrão EventHandler<TEventArgs>.

Boas práticas ao usar delegates no C#

Algumas práticas ajudam a manter o código limpo quando você adota delegates no C#. A primeira é preferir Action e Func em código interno e reservar delegates nomeados para APIs públicas.

Também vale dar nomes claros aos parâmetros, como onSuccess e onError. Assim, quem chama o método entende o papel de cada callback sem abrir a implementação.

Outra prática é usar lambdas static quando a função não precisa capturar variáveis externas. Dessa forma, você evita capturas acidentais e deixa a intenção explícita.

Por fim, cuidado com handlers de eventos que nunca são removidos. Enquanto o publicador mantém a referência, o assinante continua vivo na memória.

Conclusão

Muitas vezes subestimamos alguns recursos da linguagem. Normalmente focamos em propriedades e métodos, mas esquecemos de delegates, eventos e vários outros recursos que o C# nos fornece.

Neste exemplo, os delegates no C# permitiram separar quem executa a tarefa de quem trata o resultado. Com isso, o serviço ficou menor, reaproveitável e livre de flags para cada provedor de pagamento.

Se você quiser explorar o código completo, ele está disponível no repositório do exemplo no GitHub. Depois, vale ler também sobre extension methods, outro recurso que deixa o código mais expressivo.

FAQ

Delegate é a mesma coisa que ponteiro de função?

O conceito é parecido, mas o delegate é orientado a objetos e seguro em relação aos tipos. Além disso, ele pode guardar a instância do objeto e vários métodos ao mesmo tempo.

Quando usar Action e quando usar Func?

Use Action quando o método não retorna valor e Func quando ele retorna. No Func, o último parâmetro genérico é o tipo de retorno.

Preciso declarar um delegate próprio?

Na maioria dos casos, não, porque Action e Func cobrem quase todas as assinaturas. Delegates nomeados ainda são úteis quando o nome comunica melhor a intenção.

Qual a diferença entre delegate e evento?

O evento é um delegate com restrições. Somente a classe que o declara consegue dispará-lo, e o código externo apenas se inscreve ou cancela a inscrição.

Lambda é um delegate?

A lambda é uma função anônima que o compilador converte para um tipo de delegate compatível. Por isso, ela pode ser passada onde se espera um Action ou um Func.