Analisando performance em Strings
Foto de Thiago B.
Por: Thiago B.
21 de Março de 2020

Analisando performance em Strings

Computação C# Engenharia de Software Framework C# Geral

Sempre que nos deparamos com uma situação de manipulação de strings, vem aquela dúvida de qual o melhor modo de manipular a string. Se você não tem essa dúvida, melhor começar a ter.

Alguns devs usam StringBuilder como silver bullet sempre que precisam manipular textos, outros partem para o Regex e outros usam as funções nativas da string. Seja como for sua abordagem, é bom refletir sobre o motivo da escolha. Nunca escolha uma só por escolher.

Recentemente passei por uma situação de comparação de strings onde precisava retirar os acentos dos textos antes de compará-los. Felizmente, alguém já havia implementado um método extensão para isso.

public static string RemoveCharacters(this string text)
{
    if (string.IsNullOrEmpty(text))
        return text;

    var aux = text;
    var symbolTable = new Dictionary<char, char[]>();
    symbolTable.Add('a', new char[] { 'à', 'á', 'ä', 'â', 'ã' });
    symbolTable.Add('c', new char[] { 'ç' });
    symbolTable.Add('e', new char[] { 'è', 'é', 'ë', 'ê' });
    symbolTable.Add('i', new char[] { 'ì', 'í', 'ï', 'î' });
    symbolTable.Add('o', new char[] { 'ò', 'ó', 'ö', 'ô', 'õ' });
    symbolTable.Add('u', new char[] { 'ù', 'ú', 'ü', 'û' });
    symbolTable.Add('A', new char[] { 'À', 'Á', 'Ä', 'Â', 'Ã' });
    symbolTable.Add('C', new char[] { 'Ç' });
    symbolTable.Add('E', new char[] { 'È', 'É', 'Ë', 'Ê' });
    symbolTable.Add('I', new char[] { 'Ì', 'Í', 'Ï', 'Î' });
    symbolTable.Add('O', new char[] { 'Ò', 'Ó', 'Ö', 'Ô', 'Õ' });
    symbolTable.Add('U', new char[] { 'Ù', 'Ú', 'Ü', 'Û' });

    foreach (var key in symbolTable.Keys)
    {
        foreach (var symbol in symbolTable[key])
        {
            aux = aux.Replace(symbol, key);
        }
    }
    return aux;
}

Ao me deparar com esse código eu comecei a me perguntar se essa era a melhor maneira de resolver esse problema. Foi então que resolvi fazer alguns testes para entender qual a melhor abordagem.

Primeira coisa que fiz foi implementar o código abaixo, usando StringBuilder. A lógica é a mesma do método anterior, exceto que eu evito criar várias strings e utilizo o Replace do próprio builder para isso, gerando a string somente no final.

public static string RemoveCharactersStringbuiler(this string text)
{
    if (string.IsNullOrEmpty(text))
        return text;

    var aux = new StringBuilder(text);
    var symbolTable = new Dictionary<char, char[]>();
    symbolTable.Add('a', new char[] { 'à', 'á', 'ä', 'â', 'ã' });
    symbolTable.Add('c', new char[] { 'ç' });
    symbolTable.Add('e', new char[] { 'è', 'é', 'ë', 'ê' });
    symbolTable.Add('i', new char[] { 'ì', 'í', 'ï', 'î' });
    symbolTable.Add('o', new char[] { 'ò', 'ó', 'ö', 'ô', 'õ' });
    symbolTable.Add('u', new char[] { 'ù', 'ú', 'ü', 'û' });
    symbolTable.Add('A', new char[] { 'À', 'Á', 'Ä', 'Â', 'Ã' });
    symbolTable.Add('C', new char[] { 'Ç' });
    symbolTable.Add('E', new char[] { 'È', 'É', 'Ë', 'Ê' });
    symbolTable.Add('I', new char[] { 'Ì', 'Í', 'Ï', 'Î' });
    symbolTable.Add('O', new char[] { 'Ò', 'Ó', 'Ö', 'Ô', 'Õ' });
    symbolTable.Add('U', new char[] { 'Ù', 'Ú', 'Ü', 'Û' });

    foreach (var key in symbolTable.Keys)
    {
        foreach (var symbol in symbolTable[key])
        {
            aux.Replace(symbol, key);
        }
    }
    return aux.ToString();
}

Na sequência, implementei uma versão do método utilizando Regex. Na teoria, a busca por Regex é otimizado para varrer textos e encontrar patterns.

public static string RemoveCharactersRegex(this string text)
{
    if (string.IsNullOrEmpty(text))
        return text;

    var symbolTable = new Dictionary<string, string>
    {
        { "a", "à|á|ä|â|ã" },
        { "c", "ç" },
        { "e", "è|é|ë|ê" },
        { "i", "ì|í|ï|î" },
        { "o", "ò|ó|ö|ô|õ" },
        { "u", "ù|ú|ü|û" },
        { "A", "À|Á|Ä|Â|Ã" },
        { "C", "Ç" },
        { "E", "È|É|Ë|Ê" },
        { "I", "Ì|Í|Ï|Î" },
        { "O", "Ò|Ó|Ö|Ô|Õ" },
        { "U", "Ù|Ú|Ü|Û" }
    };

    foreach (var key in symbolTable.Keys)
    {
        text = Regex.Replace(text, symbolTable[key], key);
    }

    return text;
}

Rodei, então, testes com cada uma das implementações. O teste consiste em dar Replace de caracteres especiais em um texto simples, uma única vez. Cada teste foi executado separadamente, um teste por execução do programa. O projeto de teste é um projeto Console usando .Net Core 3.1. Os resultados estão na tabela baixo.

Método Tempo de execução
Método Original 1,98 ms
Método com StringBuilder 2,07 ms
Método com Regex 19,37 ms

Como podemos ver, o tempo de execução foi pior com o Regex e tempos semelhantes entre a implementação original e a implementação com StringBuilder.

Nessa ideia, resolvi olhar para a implementação base e ver se poderia ganhar alguma performance com ela. Tirei o uso de Dictionary e troquei o foreach por um for.

public static string RemoveCharactersOptimized(this string text)
{
    if (string.IsNullOrEmpty(text))
        return text;

    var symbolTable = new char[][]
    {
        new char[] { 'a', 'à', 'á', 'ä', 'â', 'ã' },
        new char[] { 'c', 'ç' },
        new char[] { 'e', 'è', 'é', 'ë', 'ê' },
        new char[] { 'i', 'ì', 'í', 'ï', 'î' },
        new char[] { 'o', 'ò', 'ó', 'ö', 'ô', 'õ' },
        new char[] { 'u', 'ù', 'ú', 'ü', 'û' },
        new char[] { 'A', 'À', 'Á', 'Ä', 'Â', 'Ã' },
        new char[] { 'C', 'Ç' },
        new char[] { 'E', 'È', 'É', 'Ë', 'Ê' },
        new char[] { 'I', 'Ì', 'Í', 'Ï', 'Î' },
        new char[] { 'O', 'Ò', 'Ó', 'Ö', 'Ô', 'Õ' },
        new char[] { 'U', 'Ù', 'Ú', 'Ü', 'Û' },
    };

    for (var i = 0; i < symbolTable.Length; i++)
    {
        for (var j = 1; j < symbolTable[i].Length; j++)
        {
            text = text.Replace(symbolTable[i][j], symbolTable[i][0]);
        }
    }
    return text;
}

Rodando o teste novamente, obtive um tempo de 0,6 ms, um terço do tempo da implementação original.

Método Tempo de execução
Método Original 1,98 ms
Método com StringBuilder 2,07 ms
Método com Regex 19,37 ms
Método Otimizado 0,6 ms

Pode parecer fácil a mudança, mas a otimização não foi do nada. Primeiro, tirei o uso de Dictionary para não demandar uso do GC. Já a troca do foreach pelo for foi motivado pois a instrução foreach executa o método MoveNext para iterar pela lista. Assim, otimizei o código para usar somente tipos primitivos.

Então, para a minha situação, eu pude otimizar a execução do Replace dos caracteres com acentos.

Mas claro que como bom dev, quero ver até onde vai essa melhoria, então refiz os testes com um texto bem maior.

O novo resultado pode ser visto na tabela abaixo.

Método Tempo de execução
Método Original 1,97 ms
Método com StringBuilder 3,61 ms
Método com Regex 19,31 ms
Método Otimizado 0,64 ms

Com exceção da versão com StringBuilder que ganhou 1,5 ms a mais, as demais implementações mantiveram um tempo constate e o método otimizado continuou sendo o mais rápido.

Essa não é a única solução existente e talvez não seja a melhor, mas foi a solução utilizada para o meu caso e fui capaz de otimizar ela. O link para o projeto está no meu Github. O tempo de execução que você encontrará na sua máquina será diferente do meu, mas o esperado é que o padrão seja o mesmo.

Até a próxima!

Thiago B.
Thiago B.
São Paulo / SP
Responde em 1 dia
Identidade verificada
1ª hora grátis
nota média
0
avaliações
R$ 70
por hora
Graduação: Análise e Desenvolvimento de Sistemas (Centro Universitário da Serra Gaúcha)
Ciência da Computação, Computação - Análise de Sistemas, Computação - Análise de Algoritmos
Professor de programação com mais de 9 anos de experiência com desenvolvimento de software

Aprenda sobre qualquer assunto