A cada nova release do .NET, novos recursos são adicionados e aprimorados, e após a inclusão da estrutura Span<T> e ReadOnlySpan<T>, manipular objetos alocados em memória se tornou mais simples e seguro, alinhando performance e legibilidade.
Utilizar o método Slice da estrutura ReadOnlySpan<T> em operações que envolvam strings pode apresentar resultados de performance significativamente melhores se comparado à utilização do tradicional método Substring. O impacto se torna ainda mais perceptível quando implementado em caminhos críticos de aplicações que demandam alta performance.
Às vezes custa caro seguir pelo caminho mais simples
Imagine um cenário onde é necessário incluir uma regra no código que identifique que uma palavra informada pelo usuário é similar à outra palavra já cadastrada. De forma mais detalhada, a validação deve garantir que a palavra informada não tenha 5 caracteres consecutivos iguais aos da palavra cadastrada.
A implementação mais comum em situações onde é necessário extrair uma parte da string é utilizando o método Substring. Seguindo essa ideia, para atender a regra proposta, é necessário navegar pelas partes da string informada pelo usuário e verificar se a palavra cadastrada possui esses segmentos através do método Contains. Se encontrar o segmento, o método retorna true, indicando que encontrou similaridade entre as palavras.
public bool UsingSubstring(string registeredWord, string inputWord) { var quantityOfCharactersInSequenceAllowed = 5; var iterationCount = inputWord.Length - quantityOfCharactersInSequenceAllowed; for (int i = 0; i <= iterationCount; i++) { var innerText = inputWord.Substring(i, quantityOfCharactersInSequenceAllowed); if (registeredWord.Contains(innerText, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; }
Utilizar Substring é simples, porém gera ofensores de performance:
- Uma nova string é criada para armazenar a substring retornada pelo método, gerando alocação na memória heap
- É realizada uma cópia do conjunto de caracteres da string original para a nova string de acordo com o intervalo informado como parâmetro
Em caminhos críticos, essa abordagem pode se tornar um problema, pois uma nova string é alocada a cada iteração, e com mais alocação de memória, mais vezes o garbage collector será acionado, demandando mais processamento e interrompendo com mais frequência a execução das threads, impactando negativamente a performance.
Quando pequenas mudanças apresentam grandes resultados
A partir do C# 7.2 é possível utilizar a estrutura Span<T>, na qual fornece a representação contígua de uma região de memória de forma segura, permitindo a interação com os elementos diretamente na memória. Sendo assim, é possível acessar e alterar o valor dos elementos sem a necessidade de cópia e alocação em um novo objeto.
No caso da string, por se tratar de um objeto imutável, é necessário utilizar a estrutura ReadOnlySpan<T>, que fornece somente recursos de acesso aos valores. Aplicando em nossa regra, precisamos realizar dois ajustes se comparado ao método que utiliza Substring.
- Converter as strings recebidas como parâmetro em ReadOnlySpan<T>
- Substituir o método Substring por Slice
public bool UsingSpanSlice(string registeredWord, string inputWord) { var quantityOfCharactersInSequenceAllowed = 5; var iterationCount = inputWord.Length - quantityOfCharactersInSequenceAllowed; ReadOnlySpan<char> textSpan = registeredWord; ReadOnlySpan<char> innerTextSpan = inputWord; for (int i = 0; i <= iterationCount; i++) { var innerTextSlice = innerTextSpan.Slice(i, quantityOfCharactersInSequenceAllowed); if (textSpan.Contains(innerTextSlice, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; }
Ao utilizar o método Slice em conjunto com a estrutura ReadOnlySpan<T>, eliminamos os dois ofensores de performance observados anteriormente:
- O retorno do método Slice é outro objeto do tipo ReadOnlySpan<T>, no qual é um ref struct, e objetos desse tipo são alocados na memória stack
- De acordo com o intervalo informado como parâmetro, os caracteres retornados pelo método Slice são referências dos mesmos caracteres utilizados na string original, sendo um apontamento para o local de memória onde eles estão armazenados
Aplicar essa técnica permite alcançar melhor performance afetando “pouco” a legibilidade. Se torna ainda mais atrativa em cenários que demandam alta performance, pois por não gerar alocação na memória heap, o garbage collector não é acionado, economizando recursos de processamento e evitando a interrupção da execução de threads.
Medir, medir, medir, sempre medir
Considerando o tempo de execução e a memória consumida, vamos avaliar o impacto de cada implementação através da comparação realizada pela biblioteca BenchmarkDotNet:
[MemoryDiagnoser] public class SubstringVsSpan { private string _registeredWord; private string _inputWord; private int _quantityOfCharactersInSequenceAllowed; [GlobalSetup] public void Setup() { _registeredWord = "RegisteredStringToBeCompared"; _inputWord = "InputSendedByUserWithSimilarString"; _quantityOfCharactersInSequenceAllowed = 5; } [Benchmark] public bool UsingSubstring() { var iterationCount = _inputWord.Length - _quantityOfCharactersInSequenceAllowed; for (int i = 0; i <= iterationCount; i++) { var innerTextSubstring = _inputWord.Substring(i, _quantityOfCharactersInSequenceAllowed); if (_registeredWord.Contains(innerTextSubstring, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } [Benchmark] public bool UsingSpanSlice() { var iterationCount = _inputWord.Length - _quantityOfCharactersInSequenceAllowed; ReadOnlySpan<char> textSpan = _registeredWord; ReadOnlySpan<char> innerTextSpan = _inputWord; for (int i = 0; i <= iterationCount; i++) { var innerTextSlice = innerTextSpan.Slice(i, _quantityOfCharactersInSequenceAllowed); if (textSpan.Contains(innerTextSlice, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } }
Com o exemplo apresentado, utilizar o método Slice da estrutura ReadOnlySpan<T> não só se mostrou mais rápido, como também não gerou qualquer alocação de memória, enquanto o método Substring alocou 928 bytes e levou aproximadamente 15% a mais de tempo para ser executado.
* A quantidade de memória alocada varia de acordo com o tamanho da string informada pelo usuário
Em um cenário de alta escala, onde essa validação é executada intensivamente, o impacto negativo ao utilizar Substring se torna ainda mais perceptível devido à pressão gerada sobre o garbage collector.
Ponderação sobre performance X legibilidade
Escrever código que performa melhor pode demandar maior complexidade, comprometendo a legibilidade e consequentemente a manutenibilidade da aplicação. Entretanto, em alguns casos é possível alcançar melhor performance, causando pouco ou nenhum impacto à legibilidade.
No cenário apresentado, bastou realizar pequenas mudanças para se obter um código que performasse melhor, porém, ao utilizar o ReadOnlySpan<T>, é possível que a manutenção do código tenha se tornado mais complexa devido à inclusão de uma estrutura relativamente nova que ainda não é totalmente difundida. Apesar de parecer ser uma boa escolha padrão por conta da aparente simplicidade, cada caso é um caso, sendo necessário avaliar o trade-off e priorizar o que é mais importante para o sistema.