Expressões regulares são fantásticas. Entretanto, precisam ser utilizadas com moderação pois podem impactar de forma perceptível a performance.
A expressão regular do código abaixo (criado para esse post) estava no caminho crítico de execução do sistema de um cliente.
public static bool VersionWithRegex(string strToFind, string input)
{
var regex = new Regex($".*({strToFind})[/.].*", RegexOptions.IgnoreCase);
return regex.Match(input).Success;
}
Basicamente, essa expressão verifica se uma string, seguida por um “.” ou por uma “/” estão contidos em uma outra string, desconsiderando minúsculos e maiúsculos.
Para que possamos apenas ter “um cheiro” do peso de execução dessa expressão regular, criei um teste simples.
static void Main(string[] args)
{
Func<string, string, bool> sut = VersionWithRegex;
var g0 = GC.CollectionCount(0);
var g1 = GC.CollectionCount(1);
var g2 = GC.CollectionCount(2);
var sw = new Stopwatch();
sw.Start();
for (int i = 0; i < 100_000; i++)
{
if (!sut("abc", "xptoABC.abc"))
throw new Exception();
if (!sut("abc", "xptoAbC/abc"))
throw new Exception();
if (!sut("abc", "xptoABC."))
throw new Exception();
if (!sut("abc", "xptoAbC/"))
throw new Exception();
if (!sut("abc", "ABC."))
throw new Exception();
if (!sut("abc", "AbC/"))
throw new Exception();
if (!sut("abc", "xptoabC.xpto"))
throw new Exception();
if (!sut("abc", "xptoabC/xpto"))
throw new Exception();
if (sut("abcd", "xptoabC/xpto"))
throw new Exception();
if (sut("abc/", "xptoabC/xpto"))
throw new Exception();
}
Console.WriteLine($"Total time: {sw.ElapsedMilliseconds}ms.");
Console.WriteLine($"Gen0: {GC.CollectionCount(0) - g0}.");
Console.WriteLine($"Gen1: {GC.CollectionCount(1) - g1}.");
Console.WriteLine($"Gen2: {GC.CollectionCount(2) - g2}.");
}
Resultado:

Pouco mais de nove segundos para verificar mais de 1_000_000 de strings não parece um tempo ruim. Entretanto, como sempre digo, 10s, para um computador, é muito tempo. Vejamos se conseguimos melhorar.
Primeira modificação: adeus Regex
Minha primeira proposta foi remover o Regex. Afinal, o teste não seria tão custo de implementar.
public static class Attempt1
{
public static bool VersionWithoutRegex(string strToFind, string input)
{
return
input.IndexOf($"{strToFind}/", StringComparison.InvariantCultureIgnoreCase) != -1 ||
input.IndexOf($"{strToFind}.", StringComparison.InvariantCultureIgnoreCase) != -1;
}
}
Resultado:

Surpreendente, não? O código acima é mais simples (na minha opnião) e ~22 vezes mais rápido. Aliás, interessante observar o número de coletas que o Regex provoca.
Essa performance já seria suficiente para o cliente. Mas, apenas por curiosidade, será que conseguimos melhorar?
Segunda modificação: Adeus GC
Sabemos que o GC impacta a performance da aplicação. Então, vamos ver se podemos ter algum ganho não alocando memória.
public static bool VersionWithoutRegex(string strToFind, string input)
=> input.Contains(strToFind, '.') ||
input.Contains(strToFind, '/');
private static bool Contains(this string input, string strToFind, char suffix)
{
var limit = input.Length - (strToFind.Length + 1);
var c0 = char.ToUpperInvariant(strToFind[0]);
for (int i = 0; i <= limit; i++)
{
if (char.ToUpperInvariant(input[i]) == c0)
{
bool success = true;
for (int j = 1; j < strToFind.Length; j++)
{
if (char.ToUpperInvariant(input[i + j]) != char.ToUpperInvariant(strToFind[j]))
{
success = false;
break;
}
}
if (success && char.ToUpperInvariant(input[i + strToFind.Length]) == char.ToUpperInvariant(suffix))
{
return true;
}
}
}
return false;
}
Observe que, definitivamente, abraçamos alguma complexidade nesse código. Vejamos o resultado:

Terceira modifição: Comparando de forma mais inteligente
Reparei que estava usando uma “comparação” pesada demais para os caracteres, por isso, resolvi “aliviar”
public static bool VersionWithoutRegex(string strToFind, string input)
{
return
(input.Contains(strToFind, '.') || input.Contains(strToFind, '/'));
}
private static bool Contains(this string input, string strToFind, char suffix)
{
var limit = input.Length - (strToFind.Length + 1);
for (int i = 0; i <= limit; i++)
{
if (strToFind[0].EqualsIgnoringCase(input[i]))
{
bool success = true;
for (int j = 1; j < strToFind.Length; j++)
{
if (input[i + j].EqualsIgnoringCase(strToFind[j]))
{
success = false;
break;
}
}
if (success && input[i + strToFind.Length].EqualsIgnoringCase(suffix))
{
return true;
}
}
}
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool EqualsIgnoringCase(this char c1, char c2)
{
if (!char.IsLetter(c1))
{
return c1 == c2;
}
if (char.IsLower(c1))
{
return char.IsLower(c2) ? c1 == c2 : c1 == char.ToLowerInvariant(c2);
}
return char.IsUpper(c2) ? c1 == c2 : c1 == char.ToUpperInvariant(c2);
}
Resultado:

Quarta modifição: Comparando de forma AINDA mais inteligente
Minha abordagem anterior para comparar caracteres se mostrou mais inteligente (e mais performática). Vejamos outra ainda mais efetiva:
public static bool VersionWithoutRegex(string strToFind, string input)
{
return
(input.Contains(strToFind, '.') || input.Contains(strToFind, '/'));
}
private static bool Contains(this string input, string strToFind, char suffix)
{
var limit = input.Length - (strToFind.Length + 1);
for (int i = 0; i <= limit; i++)
{
if (strToFind[0].EqualsIgnoringCase(input[i]))
{
bool success = true;
for (int j = 1; j < strToFind.Length; j++)
{
if (input[i + j].EqualsIgnoringCase(strToFind[j]))
{
success = false;
break;
}
}
if (success && input[i + strToFind.Length].EqualsIgnoringCase(suffix))
{
return true;
}
}
}
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool EqualsIgnoringCase(this char c1, char c2)
{
return (
(c1 - 'a') == (c2 - 'a') ||
(c1 - 'a') == (c2 - 'A') ||
(c1 - 'A') == (c2 - 'a') ||
(c1 - 'A') == (c2 - 'A')
);
}
Resultado:

Quinta (última) modificação: Adeus processamento em duplicidade
Percebi que meu código de “contido” rodava duas vezes – uma para cada sufixo. Vamos resolver isso.
public static bool VersionWithoutRegex(string strToFind, string input)
{
return
(input.Contains(strToFind, '.', '/'));
}
private static bool Contains(this string input, string strToFind, char suffix1, char suffix2)
{
var limit = input.Length - (strToFind.Length + 1);
for (int i = 0; i <= limit; i++)
{
if (strToFind[0].EqualsIgnoringCase(input[i]))
{
bool success = true;
for (int j = 1; j < strToFind.Length; j++)
{
if (input[i + j].EqualsIgnoringCase(strToFind[j]))
{
success = false;
break;
}
}
if (success && input[i + strToFind.Length].EqualsIgnoringCase(suffix1) || input[i + strToFind.Length].EqualsIgnoringCase(suffix2))
{
return true;
}
}
}
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool EqualsIgnoringCase(this char c1, char c2)
{
return (
(c1 - 'a') == (c2 - 'a') ||
(c1 - 'a') == (c2 - 'A') ||
(c1 - 'A') == (c2 - 'a') ||
(c1 - 'A') == (c2 - 'A')
);
}
Resultado:

22ms!
Comparando a performance de forma mais “profissional”
Nosso código, que originalmente consumia quase 10 segundos, agora performa em ~22ms. Vejamos uma análise mais qualificada com o BenchmarkDotNet.

Conclusão
[tweet]Performance é uma feature. Escrever código de boa performance é um hábito.[/tweet] Código que performa melhor pode, eventualmente, ficar mais complexo. Entretanto, dependendo da demanda, essa complexidade é compensada.
O primeiro código desse post, que está em produção hoje em uma grande empresa, provavelmente é o mais simples. Também é ~440x mais lento que o último código.
No próximo dia 29, irei falar, de forma gratuita, para o Canal.NET sobre como escrever código com performance ótima. Faça sua inscrição.

