Action e Action Chaining no C#

Uma Action no C# funciona como um delegate onde podemos armazenar ações para executá-las depois. Neste artigo, você vai entender como declarar uma Action, como usar funções anônimas para passar parâmetros e como encadear várias funções com o Action Chaining.

O que é uma Action no C#

Uma Action no C# é um tipo que pode receber uma função. Em outras palavras, ela guarda uma referência para um método e permite invocá-lo em outro momento do código.

Tecnicamente, Action é um delegate já definido no namespace System. Por isso, você não precisa declarar um delegate próprio sempre que quiser passar uma função adiante.

A característica principal da Action é que ela representa métodos que não retornam valor, ou seja, métodos void. Para métodos que retornam algo, o .NET oferece o Func<T>, que trataremos em outro artigo.

Se você quer revisar o conceito de delegate antes de seguir, o artigo Delegates aqui do blog explica a base em detalhes.

Action e Action com parâmetros

A forma mais simples, Action, representa um método sem parâmetros e sem retorno. Já as versões genéricas, como Action<T> e Action<T1, T2>, representam métodos que recebem argumentos.

Segundo a documentação do delegate Action, o .NET traz sobrecargas genéricas que aceitam até 16 parâmetros. Na prática, porém, poucas vezes você vai precisar de mais de três.

// Action sem parâmetros
Action sayHello = () => Console.WriteLine("Hello");

// Action com um parâmetro do tipo string
Action<string> printMessage = message => Console.WriteLine(message);

// Action com dois parâmetros
Action<string, int> printTimes = (message, times) =>
{
    for (var i = 0; i < times; i++)
        Console.WriteLine(message);
};

sayHello();
printMessage("Action with one parameter");
printTimes("Repeated message", 3);

Como declarar uma Action no C#

Para definir uma Action no C#, basta criar uma variável desse tipo e apontar para uma função. Você pode fazer isso no momento da declaração ou depois, em qualquer ponto do código.

Action action;

action = MyFunction;

action();

void MyFunction()
{
    Console.WriteLine("I am a function");
}

No exemplo acima, temos uma função chamada MyFunction que imprime um texto na tela. Depois de defini-la, criamos a variável Action action e atribuímos a função a ela com action = MyFunction.

Repare que não usamos parênteses na atribuição. Isso acontece porque não estamos chamando MyFunction, e sim passando uma referência para ela.

Por fim, executamos a Action no C# invocando a variável como qualquer função, abrindo e fechando parênteses: action();. Nesse momento, o C# chama o método que está guardado dentro da Action.

Invoke e checagem de nulo

Além da chamada direta com parênteses, todo delegate tem o método Invoke. Assim, action() e action.Invoke() produzem o mesmo resultado.

A forma com Invoke fica útil quando a Action pode estar nula. Nesse caso, o operador ?. evita a NullReferenceException e simplesmente ignora a chamada.

Action? onCompleted = null;

// não lança exceção, apenas não executa nada
onCompleted?.Invoke();

Funções anônimas

Quando a função não recebe parâmetros (parameterless), podemos atribuí-la diretamente a uma Action, como fizemos em action = MyFunction;. Contudo, quando a função exige parâmetros, a atribuição direta para uma Action sem argumentos não compila.

Nesse cenário, precisamos da chamada completa da função, com os argumentos, para executá-la depois. Para isso, usamos as funções anônimas do C#, também chamadas de expressões lambda, escritas com () =>.

Action action;

action = () => MyFunction("I am a parameter");

action();

void MyFunction(string text)
    => Console.WriteLine(text);

Note que, nesse caso, é como se estivéssemos executando a função. Ou seja, passamos os parâmetros necessários exatamente como já estamos acostumados no C#.

Porém, a execução não acontece no momento em que associamos a lambda à Action. Na verdade, ela só ocorre quando executamos a Action, neste exemplo na linha action();.

Execução adiada e variáveis capturadas

Esse comportamento de execução adiada tem um detalhe importante. Quando a lambda usa uma variável externa, ela captura a variável, e não o valor que a variável tinha naquele momento.

var name = "balta";

Action greet = () => Console.WriteLine($"Hello, {name}");

name = "student";

// imprime "Hello, student", e não "Hello, balta"
greet();

Portanto, a lambda lê o valor atual da variável no momento da execução. Esse recurso, chamado de closure, é muito útil, mas pode surpreender quem espera uma "fotografia" do valor.

Por que guardar uma função em uma Action no C#

Mas qual a vantagem de ter uma variável que armazena uma função? A primeira resposta é a flexibilidade, porque você decide em tempo de execução qual comportamento será chamado.

Por exemplo, um método pode receber uma Action como parâmetro e executá-la ao final de uma operação. Dessa forma, quem chama o método define o que acontece depois, sem que o método conheça os detalhes.

void ProcessOrder(int orderId, Action<int> onFinished)
{
    // processa o pedido
    Console.WriteLine($"Processing order {orderId}");

    // avisa quem chamou que terminou
    onFinished(orderId);
}

ProcessOrder(42, id => Console.WriteLine($"Order {id} finished"));

Outra vantagem, e talvez a mais interessante, é o Action Chaining, ou encadeamento de funções. É sobre ele que vamos falar a seguir.

Action Chaining

Em diversas conversas durante os Encontros Premium, mostrei exemplos de como usamos CQRS aqui no balta. Em especial, mostrei como temos uma cadeia de eventos que acontecem em uma ordem específica.

Vamos tomar como base o cadastro de novos clientes e alunos, com os métodos abaixo:

Action handle;

handle = ValidateRequest;
handle += CheckIfEmailIsInUse;
handle += CreateUser;
handle += CreateStudent;
handle += PersistData;
handle += SendActivationCode;
handle += LogNewAccount;

handle();

void ValidateRequest() { }
void CheckIfEmailIsInUse() { }
void CreateUser() { }
void CreateStudent() { }
void PersistData() { }
void SendActivationCode() { }
void LogNewAccount() { }

Nesse caso, usamos o operador += para anexar funções à Action no C#. Em seguida, ao executar handle(), o C# chama todas elas na ordem em que foram atribuídas.

Usamos esse modelo por causa do reuso das funções em diversas partes do sistema. Imagine uma validação simples de e-mail que verifica se o endereço já está cadastrado na base.

Essa verificação vira uma função que pode ser invocada em diferentes handlers. Assim, o cadastro de alunos, a troca de e-mail e o convite de usuários compartilham a mesma regra.

Como o Action Chaining funciona no C#

Por baixo dos panos, toda Action no C# herda de MulticastDelegate. Esse tipo mantém uma lista de invocação, que guarda todos os métodos anexados ao delegate.

De acordo com a documentação sobre delegates multicast, quando você chama o delegate, ele invoca os métodos da lista em ordem. Além disso, só é possível combinar delegates do mesmo tipo.

O operador += é apenas um atalho. Na verdade, handle += CreateUser equivale a handle = handle + CreateUser, que por sua vez usa o método Delegate.Combine.

Um detalhe curioso é que o + devolve um novo delegate. Ou seja, os delegates são imutáveis, e cada += cria uma nova instância com a lista atualizada.

Removendo funções da cadeia

Da mesma forma que o += adiciona, o operador -= remove uma função da cadeia. Isso permite montar fluxos dinâmicos, ligando ou desligando etapas conforme a necessidade.

Action handle = ValidateRequest;
handle += SendActivationCode;
handle += LogNewAccount;

// em ambiente de testes, não enviamos o código de ativação
handle -= SendActivationCode;

handle();

A documentação também lembra que a mesma função pode entrar mais de uma vez na lista. Nesse caso, o -= remove apenas a ocorrência mais à direita.

Inspecionando a lista de invocação

Se você quiser saber quais funções estão na cadeia, use o método GetInvocationList. Ele devolve um array com cada delegate individual, na ordem de execução.

foreach (var step in handle.GetInvocationList())
    Console.WriteLine(step.Method.Name);

Esse recurso ajuda bastante na depuração. Afinal, em fluxos montados dinamicamente, nem sempre fica claro quais etapas foram anexadas.

Action Chaining com parâmetros

O exemplo anterior usa funções sem parâmetros para manter a ideia simples. Entretanto, o encadeamento também funciona com Action<T>, e todas as funções da cadeia recebem o mesmo argumento.

Action<SignUpRequest> handle = ValidateRequest;
handle += CheckIfEmailIsInUse;
handle += SendActivationCode;

handle(new SignUpRequest("Andre", "andre@balta.io"));

void ValidateRequest(SignUpRequest request)
    => Console.WriteLine($"Validating {request.Name}");

void CheckIfEmailIsInUse(SignUpRequest request)
    => Console.WriteLine($"Checking {request.Email}");

void SendActivationCode(SignUpRequest request)
    => Console.WriteLine($"Sending code to {request.Email}");

public record SignUpRequest(string Name, string Email);

Com isso, cada etapa recebe os dados do cadastro e executa sua parte. Assim, a cadeia de uma Action no C# se aproxima bastante de um pequeno pipeline de processamento.

Cuidados com o encadeamento

O primeiro cuidado envolve exceções. Segundo a documentação de MulticastDelegate, as chamadas da lista acontecem de forma síncrona e, se uma delas falhar, a exceção sobe para quem invocou o delegate.

Na prática, isso significa que as funções seguintes da cadeia não são executadas. Por isso, se PersistData lançar uma exceção, SendActivationCode e LogNewAccount nunca rodam.

Esse comportamento pode até ser desejável, porque não faz sentido enviar o código de ativação de uma conta que não foi salva. Ainda assim, vale deixar essa regra explícita para o time.

O segundo cuidado envolve métodos assíncronos. Uma Action não retorna Task, então anexar um método async cria um async void, que não pode ser aguardado com await.

Nesse caso, o fluxo pode seguir antes de a operação assíncrona terminar. Portanto, para etapas que acessam banco ou rede, prefira uma lista de Func<Task> e execute cada item com await.

var steps = new List<Func<Task>>
{
    ValidateRequestAsync,
    PersistDataAsync,
    SendActivationCodeAsync
};

// executa as etapas em ordem, aguardando cada uma
foreach (var step in steps)
    await step();

Task ValidateRequestAsync() => Task.CompletedTask;
Task PersistDataAsync() => Task.CompletedTask;
Task SendActivationCodeAsync() => Task.CompletedTask;

Por fim, lembre que a ordem importa. Como a Action no C# executa as funções na ordem de inclusão, trocar duas linhas de += pode mudar o comportamento do sistema.

Action no C# em métodos do .NET

Você provavelmente já usou uma Action no C# sem perceber. Isso acontece porque várias APIs do próprio .NET recebem uma Action ou uma Action<T> como parâmetro.

Por exemplo, o método List<T>.ForEach recebe uma Action<T> e a executa para cada item da lista. Da mesma forma, Array.ForEach segue a mesma ideia para arrays.

var names = new List<string> { "Andre", "Bruna", "Carlos" };

// cada item é passado para a Action<string>
names.ForEach(name => Console.WriteLine(name.ToUpper()));

Outro exemplo conhecido é o Task.Run, que tem uma sobrecarga que recebe uma Action. Assim, você entrega o trabalho para o pool de threads sem criar um método separado.

// a lambda é convertida para Action e roda em outra thread
await Task.Run(() => Console.WriteLine("Running in the thread pool"));

Perceber esse padrão ajuda a ler a documentação do .NET com mais facilidade. Afinal, sempre que um parâmetro tem o tipo Action, a API está pedindo um comportamento, e não um valor.

Action no C# e eventos

A sintaxe do += deve parecer familiar para quem já trabalhou com eventos. De fato, a documentação do operador de adição mostra que o mesmo += serve para assinar um evento.

Isso acontece porque os eventos do C# também usam delegates por baixo dos panos. Inclusive, você pode declarar um evento usando uma Action no C# como tipo.

public class Order
{
    // evento baseado em Action<string>
    public event Action<string>? StatusChanged;

    public void Ship()
    {
        // somente a própria classe pode disparar o evento
        StatusChanged?.Invoke("Shipped");
    }
}

var order = new Order();
order.StatusChanged += status => Console.WriteLine($"New status: {status}");
order.Ship();

A diferença está no controle de acesso. Com a palavra-chave event, código externo só pode usar += e -=, enquanto apenas a classe dona pode invocar ou substituir a lista.

Portanto, quando a cadeia de funções representa uma notificação pública, o event protege melhor o seu fluxo. Já para uso interno, uma variável Action comum costuma bastar.

Action no C# versus delegate personalizado

Antes dos delegates genéricos, era comum declarar um delegate próprio para cada assinatura. Ainda hoje, essa opção continua válida.

// delegate personalizado com nome expressivo
public delegate void NotificationHandler(string message);

O delegate personalizado ganha em legibilidade, porque o nome do tipo explica a intenção. Além disso, ele aceita parâmetros ref e out, algo que a Action genérica não suporta.

Por outro lado, a Action no C# evita declarar tipos extras e já é reconhecida por qualquer desenvolvedor .NET. Por isso, ela costuma ser a escolha padrão para callbacks simples.

Em resumo, use Action na maioria dos casos e reserve o delegate personalizado para assinaturas com ref, out ou para APIs públicas em que o nome faz diferença.

Boas práticas com Action Chaining

A primeira boa prática é manter cada função da cadeia pequena e com uma única responsabilidade. Assim, CheckIfEmailIsInUse apenas verifica o e-mail, sem também criar o usuário ou enviar notificações.

Em seguida, dê nomes que descrevam a etapa com clareza. Quando alguém lê a sequência de +=, o fluxo inteiro deve fazer sentido sem abrir cada método.

Também vale centralizar a montagem da cadeia em um único lugar. Dessa forma, você evita que partes diferentes do sistema adicionem etapas sem que o restante do time perceba.

Outra prática útil é testar as funções de forma isolada. Como cada etapa é um método comum, você consegue escrever testes de unidade para ela sem montar a cadeia inteira.

Além disso, documente as dependências entre as etapas. Por exemplo, SendActivationCode depende de PersistData, então essa ordem precisa ficar explícita no código ou em um comentário.

Por fim, evite guardar estado escondido em variáveis externas capturadas pelas lambdas. Prefira passar um objeto de contexto com Action<T>, como fizemos com SignUpRequest, porque isso deixa claro quais dados circulam entre as etapas.

Quando usar Action no C#

A Action brilha em callbacks, como no exemplo de ProcessOrder. Também funciona bem para configurar componentes, um padrão muito comum no próprio ASP.NET, como em builder.Services.AddCors(options => ...).

O encadeamento, por sua vez, faz sentido quando você tem etapas pequenas, síncronas e reutilizáveis. Validações, regras de negócio simples e notificações em memória são bons candidatos.

Já para fluxos longos, com muitas dependências e chamadas assíncronas, outras abordagens tendem a ser mais claras. Nesses casos, classes de handler, pipelines de mediator ou middlewares organizam melhor a responsabilidade de cada etapa.

Se você quer aprofundar a base de orientação a objetos que sustenta esses padrões, o curso Fundamentos da Orientação a Objetos tem uma aula dedicada a delegates.

Conclusão

O C# permite criar ações, ou cadeias delas, para executar depois. Dessa forma, você ganha mais reuso das funções e consegue montar comportamentos de forma dinâmica.

Em resumo, a Action no C# guarda uma referência a um método void, aceita funções anônimas para passar parâmetros e combina várias funções com +=. Com isso, o Action Chaining vira uma ferramenta simples para organizar fluxos curtos e reaproveitar regras em diferentes handlers.

FAQ

Qual a diferença entre Action e Func no C#?

A Action representa métodos que não retornam valor. Já o Func representa métodos que retornam um valor, sempre indicado pelo último parâmetro genérico.

Quantos parâmetros uma Action pode receber?

O .NET oferece versões genéricas de Action com até 16 parâmetros. Na prática, mais de três parâmetros costuma indicar que vale criar um tipo para agrupá-los.

O que acontece se uma função da cadeia lançar exceção?

A exceção sobe para quem invocou a Action. Consequentemente, as funções seguintes da cadeia não são executadas.

Posso usar métodos async em uma Action?

Pode, mas o método vira async void e não pode ser aguardado. Para fluxos assíncronos, prefira Func<Task>.

Como remover uma função de uma Action encadeada?

Use o operador -= com a mesma função que foi adicionada. Se ela aparecer mais de uma vez, apenas a última ocorrência sai da lista.