Conteúdo

Dicas para Melhorar a Performance em .NET e C#: Quando o uso do First no LINQ pode ser gargalo

Entender o uso do método First() no LINQ te dá uma compreensão sobre a necessidade de performance em .NET e C#. Muitas vezes consideramos otimizar nosso código para sempre obter a melhor performance. Esse trabalho pode ser inútil, pois o tempo eventualmente diminuído por uma otimização é extremamente irrelevante. Portanto, cuidado pela obsessão por performance, uma vez que tratar de performance muitas vezes gera complexidade e complexidade é custo.

Entretanto, como forma de aprendizado, para melhorar a performance de aplicações desenvolvidas em .NET e C# é preciso entender o que está por trás dos métodos internos que utilizamos. É muito comum nos depararmos com situações que não compreendemos completamente e em particular quero falar do uso do método First() do LINQ.

No código apresentado a seguir, temos um record chamada “Pessoa” que possui uma lista de registros. A lista é inicializada com 13 registros predefinidos, contendo informações como nome e total. A classe também possui dois métodos: PegarComFirst() e PegarPeloIndex().

No método PegarComFirst(), utilizamos o método First() do LINQ para obter o primeiro registro da lista. Por outro lado, no método PegarPeloIndex(), utilizamos o acesso direto pelo índice [0] para obter o mesmo primeiro registro.

O código nos mostra dois métodos que têm o mesmo objetivo de obter o primeiro registro da lista, mas utilizando abordagens diferentes. Vamos explorar e analisar as implicações de desempenho e eficiência dessas abordagens ao trabalhar com uma lista de registros pré-definidos.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace LinqFirst;

public record Registro
{
    public required string Nome { get; init; }
    public int Total { get; init; }
}

public class Pessoa
{
    private List<Registro> _registros = new(13)
        {
            new() { Nome = "Maria", Total = 133569652 },
            new() { Nome = "José", Total = 77815153 },
            new() { Nome = "Antônio ", Total = 35507524 },
            new() { Nome = "João", Total = 29887445 },
            new() { Nome = "Francisco", Total = 22421466 },
            new() { Nome = "Ana", Total = 19963777 },
            new() { Nome = "Luiz ", Total = 15418958 },
            new() { Nome = "Paulo", Total = 14167689 },
            new() { Nome = "Carlos ", Total = 138420110 },
            new() { Nome = "Manoel", Total = 133418211 },
            new() { Nome = "Pedro ", Total = 99525412 },
            new() { Nome = "Francisca", Total = 85359013 },
            new() { Nome = "Raimundo ", Total = 82124215 }
        };

    [Benchmark]
    public Registro PegarComFirst()
    {
        return _registros.First();
    }

    [Benchmark]
    public Registro PegarPeloIndex()
    {
        return _registros[0];
    }
}

internal class Program
{
    private static void Main()
    {
        BenchmarkRunner.Run<Pessoa>();
        Console.ReadKey();
    }
}
Dicas para Melhorar a Performance em .NET e C#: Uso do First() no LINQ

Ao analisar os resultados do benchmark, é possível observar que o método PegarComFirst() levou mais tempo em comparação com o método PegarPeloIndex(). À primeira vista, pode-se pensar que o método First() é mais lento. No entanto, isso não é verdade. O método First(), que acessa o método TryGetFirst(), possui a mesma complexidade assintótica, O(1), uma vez que também realiza o acesso por índice, exceto quando um predicado é utilizado ou quando a lista não é do tipo IList.

Quando acessamos um elemento por índice, como no exemplo utilizando _registros[0], o tempo de acesso é direto e possui uma complexidade de tempo constante O(1). Isso ocorre porque a lista interna mantém uma estrutura de dados que permite o acesso imediato ao elemento desejado, sem a necessidade de percorrer a lista em busca dele. Por outro lado, o método First() precisa realizar algumas validações adicionais, como verificar se a lista é nula, se é do tipo IList, entre outras. Essas validações extras podem aumentar um pouco o tempo de execução em comparação com o acesso direto pelo índice.

Embora as validações realizadas pelo TryGetFirst() sejam relativamente simples e rápidas, elas ainda adicionam um custo de processamento, mesmo que pequeno, em comparação com o acesso direto por índice. Essas verificações podem envolver condicionais e chamadas de métodos adicionais, o que pode levar a uma pequena diferença no tempo de execução em comparação com o acesso por índice direto.

Qual é o código por trás do método First() no LINQ?

O código a seguir é o método First() que está disponível no Github. O código apresentado implementa os métodos First() e FirstOrDefault() da classe Enumerable no namespace System.Linq. Esses métodos são responsáveis por retornar o primeiro elemento de uma sequência, levando em consideração um predicado opcional.

A complexidade do método First() é determinada pela implementação do método TryGetFirst(), que é invocado internamente. A complexidade varia dependendo do tipo de sequência fornecida.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;

namespace System.Linq
{
    public static partial class Enumerable
    {
        public static TSource First<TSource>(this IEnumerable<TSource> source)
        {
            TSource? first = source.TryGetFirst(out bool found);
            if (!found)
            {
                ThrowHelper.ThrowNoElementsException();
            }

            return first!;
        }

        public static TSource First<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
        {
            TSource? first = source.TryGetFirst(predicate, out bool found);
            if (!found)
            {
                ThrowHelper.ThrowNoMatchException();
            }

            return first!;
        }

        public static TSource? FirstOrDefault<TSource>(this IEnumerable<TSource> source) =>
            source.TryGetFirst(out _);

        /// <summary>Returns the first element of a sequence, or a default value if the sequence contains no elements.</summary>
        /// <typeparam name="TSource">The type of the elements of <paramref name="source" />.</typeparam>
        /// <param name="source">The <see cref="IEnumerable{T}" /> to return the first element of.</param>
        /// <param name="defaultValue">The default value to return if the sequence is empty.</param>
        /// <returns><paramref name="defaultValue" /> if <paramref name="source" /> is empty; otherwise, the first element in <paramref name="source" />.</returns>
        /// <exception cref="ArgumentNullException"><paramref name="source" /> is <see langword="null" />.</exception>
        public static TSource FirstOrDefault<TSource>(this IEnumerable<TSource> source, TSource defaultValue)
        {
            TSource? first = source.TryGetFirst(out bool found);
            return found ? first! : defaultValue;
        }

        public static TSource? FirstOrDefault<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) =>
            source.TryGetFirst(predicate, out _);

        /// <summary>Returns the first element of the sequence that satisfies a condition or a default value if no such element is found.</summary>
        /// <typeparam name="TSource">The type of the elements of <paramref name="source" />.</typeparam>
        /// <param name="source">An <see cref="IEnumerable{T}" /> to return an element from.</param>
        /// <param name="predicate">A function to test each element for a condition.</param>
        /// <param name="defaultValue">The default value to return if the sequence is empty.</param>
        /// <returns><paramref name="defaultValue" /> if <paramref name="source" /> is empty or if no element passes the test specified by <paramref name="predicate" />; otherwise, the first element in <paramref name="source" /> that passes the test specified by <paramref name="predicate" />.</returns>
        /// <exception cref="ArgumentNullException"><paramref name="source" /> or <paramref name="predicate" /> is <see langword="null" />.</exception>
        public static TSource FirstOrDefault<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate, TSource defaultValue)
        {
            TSource? first = source.TryGetFirst(predicate, out bool found);
            return found ? first! : defaultValue;
        }


        private static TSource? TryGetFirst<TSource>(this IEnumerable<TSource> source, out bool found)
        {
            if (source == null)
            {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
            }

            if (source is IPartition<TSource> partition)
            {
                return partition.TryGetFirst(out found);
            }

            if (source is IList<TSource> list)
            {
                if (list.Count > 0)
                {
                    found = true;
                    return list[0];
                }
            }
            else
            {
                using (IEnumerator<TSource> e = source.GetEnumerator())
                {
                    if (e.MoveNext())
                    {
                        found = true;
                        return e.Current;
                    }
                }
            }

            found = false;
            return default;
        }

        private static TSource? TryGetFirst<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate, out bool found)
        {
            if (source == null)
            {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
            }

            if (predicate == null)
            {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.predicate);
            }

            foreach (TSource element in source)
            {
                if (predicate(element))
                {
                    found = true;
                    return element;
                }
            }

            found = false;
            return default;
        }
    }
}

Conclusão

Ao decidir entre utilizar o método First() ou acessar diretamente pelo índice, é importante considerar o contexto e a necessidade específica do código. Se o acesso direto pelo índice for suficiente e não houver a necessidade de validar predicados ou tratar casos especiais, o acesso pelo índice é mais eficiente em termos de desempenho. No entanto, se houver a necessidade de aplicar predicados ou tratar casos especiais, o método First() oferece uma abordagem mais flexível e conveniente.

Honestamente, a menos que você precise de um desempenho extremamente otimizado, a diferença de nanossegundos no tempo de execução não terá um impacto significativo em projetos line of business. É preciso ponderar os atributos de qualidade do seu projeto e avaliar se a obsessão por desempenho realmente trará benefícios tangíveis para o negócio, considerando também a complexidade adicional que pode ser introduzida na manutenção do código.

Sendo assim, a escolha entre desempenho e simplicidade de manutenção deve ser baseada nas necessidades e metas específicas do projeto, levando em consideração os aspectos práticos e reais de sua aplicação no contexto do negócio.

FAQ: Perguntas Frequentes

1. Por que devo considerar o uso do método First() no LINQ para melhorar a performance em .NET e C#?

Ao analisar o desempenho do código, é importante entender o comportamento dos métodos utilizados. O método First() do LINQ é uma opção comumente utilizada para obter o primeiro elemento de uma lista, ela é, sem dúvidas mais intuitiva, mas pouco mais lento – irrelevante – ao se comparar com o acesso por índice. Portanto, compreender como esse método funciona e quando utilizá-lo pode ajudar a otimizar a performance do código.

2. Qual é a diferença entre acessar o primeiro elemento por índice direto e utilizar o método First()?

Acessar o primeiro elemento por índice direto, como no exemplo _registros[0], oferece um acesso direto e de complexidade O(1), ou seja, tempo constante. Já o método First() pode ter uma complexidade semelhante, desde que a lista seja do tipo IList. No entanto, quando predicados ou casos especiais são necessários, o método First() oferece uma abordagem mais flexível.

3. O método First() é sempre mais lento que o acesso direto por índice?

Não necessariamente. O tempo de execução do método First() pode ser levemente maior devido às validações extras realizadas, como verificar se a lista é nula ou se é do tipo IList. No entanto, essas diferenças são geralmente insignificantes e dependem do contexto e da implementação específica.

4. Devo me preocupar excessivamente com a performance ao usar o método First()?

Não é necessário se preocupar excessivamente com a performance ao utilizar o método First(). Em projetos lineares de negócio, a diferença de tempo de execução em nanossegundos é geralmente irrelevante. É importante ponderar os atributos de qualidade do projeto e avaliar se a obsessão por desempenho trará benefícios tangíveis para o negócio em relação à complexidade adicional introduzida na manutenção do código.

5. Como devo escolher entre desempenho e simplicidade de manutenção?

A escolha entre desempenho e simplicidade de manutenção deve ser baseada nas necessidades e metas específicas do projeto. Se o acesso direto por índice atender aos requisitos e não houver necessidade de validações extras, pode ser a opção mais eficiente em termos de desempenho. No entanto, se houver a necessidade de aplicar predicados ou tratar casos especiais, o método First() oferece uma abordagem mais flexível e conveniente. Avalie os aspectos práticos e reais de sua aplicação no contexto do negócio para fazer uma escolha adequada.

Compartilhe:

Tiago Tartari

Tiago Tartari

Eu ajudo e capacito pessoas e organizações a transformar problemas complexos em soluções práticas usando a tecnologia para atingir resultados extraordinários.

Qual é o desafio
que você tem hoje?