Atualizado em 12/07/17!
Todo mundo aprende, quando inicia os estudos em desenvolvimento de software, que existem os tipos de variáveis. Se você quer que uma variável armazene um número inteiro, você deve declará-la como sendo do tipo Inteiro (Int32, int, Integer, que seja). Se você quer que sua variável contenha um nome, você deve declará-la como uma cadeia de caracteres (string, char[], etc).
Até aí tudo bem e extremamente óbvio (ou nem tanto para os programadores que não usam linguagens fortemente tipadas como JavaScript…), mas e quando complicamos a situação, descobrindo que com diferentes tipos de variáveis obtemos resultados aparentemente iguais?
Ou, porque diabos existe em diversas linguagens o float, o double e o decimal?
Ou o short, o int e o long?
Quando devo usar este ou aquele tipo para determinada variável?
O que acontece se usar o tipo errado?
Estas e outras dúvidas serão discutidas no decorrer do post.
Este post é básico e nem um pouco purista no que tange o domínio de algoritmos realmente eficientes. Apenas quero mostrar como utilizar os tipos de variáveis certas e o impacto disso no desempenho do código final.
Neste artigo você vai ver:
Entendendo os Tipos e os cálculos
Tipos de Variáveis são as estruturas de dados mais elementares da computação.
Sim, isso mesmo.
Um char unicode (caso do C#) nada mais é do que 2 bytes na memória do computador. Uma string é um ponteiro para a primeira posição de uma cadeia de chars em sequência, terminando em um char ‘/0’, indicando final da string. Um inteiro é uma cadeia de 4 bytes (32-bit na maioria das linguagens) permitindo número de aproximadamente -2.1B até +2.1B.
Nada além de estruturas de dados. Agora imagine você o custo que existe para o processador de armazenar e calcular milhões de vezes estas estruturas nos algoritmos que você desenvolve. Qualquer um que ignore estes conhecimentos com certeza não está desenvolvendo software otimizado e mais cedo ou mais tarde terá gargalos de performance em suas aplicações.
Os testes mais à frente mostrarão do que estou falando.
Mas voltando ao que interessa, cada um dos tipos existem para um propósito. Enquanto que alguns apenas provêem formas mais simples de manipular valores complexos (como Strings) outros fazem parte da essência da arquitetura do processador.
O uso incorreto de tipos faz com que o processador tenha de efetuar operações desnecessárias, como LOAD, STORE e outras operações Assembly que nunca conseguiremos escapar. No centro do processador existe a ULA, ou Unidade Lógico Aritmética, um “circuito” capaz de fazer apenas uma operação, a soma. A soma é a operação elementar e com base nela conseguimos fazer qualquer outra operação existente. Uma subtração nada mais é do que uma soma entre um número positivo e outro negativo. Uma multiplicação nada mais é do que somas consecutivas. E por aí vai. Ou seja, a soma é a operação que menos exige do processador.
Assim como a soma é a operação elementar, o inteiro é o tipo de dado elementar, pois ele é construído com o tamanho padrão de palavras da arquitetura 32-bits, que até poucos anos atrás era a mais comum em processadores. Resumindo: a soma entre inteiros é o cálculo de maior desempenho e menor custo a um processador.
Quando manipulamos variáveis de outros tipos, aumentamos proporcionalmente a complexidade do cálculo e o número de ciclos que o processador levará para executá-lo. Quanto mais complexa a estrutura, mais tempo levará para ser calculada.
Ok, devo estar parecendo um programador C falando, mas é a mais pura verdade e uma das coisas que mais respeito nos programadores C é seu apreço pelo uso correto da linguagem de programação. E sim, eu já fui programador C um dia, em meados de 2007. 🙂
Entendendo o funcionamentos dos Tipos
O pessoal que trabalha com Internet adora falar de semântica web certo? Mas eles costumam se esquecer de algo muito mais importante quando o assunto são os SISTEMAS web: a semântica dos tipos de variáveis.
Renderização de páginas HTML por browsers é algo feito no lado do cliente, ou seja, de forma distribuída e independente do servidor. Já a lógica do sistema é feita toda no servidor (de todas as requisições dos usuários), onde está realmente o gargalo de tempo, onde está a lógica do negócio, é muitas vezes relegado à segundo plano nos projetos web e mobile modernos.
Então vamos falar de algo que me interessa mais do que porque devemos usar DIVs e não TABLEs: semântica de tipos. Primeiramente uma pequena lista dos tipos de variáveis do C# e uma rápida explicação de seu funcionamento e/ou capacidade (no Java e outras linguagens não muda muito):
- int ou Int32 = números inteiros de 32 bit (indo de -2.1B a +2.1B)
- short ou Int16 = números inteiros de 16 bit (indo de -32.500 a +32.500 aproximadamente)
- long ou Int64 = números inteiros de 64-bit (-9 quinquilhões até +9 quinquilhões)
- byte = números inteiros de 0 a 255 (8-bit sem sinal)
- char = apenas um caractere unicode, ocupando 16-bit (diferente do C, onde ocupa apenas 4-bit por ser ASCII)
- string = um ponteiro apontando para a primeira posição de uma cadeia de caracteres, terminando em ‘/0’
- float ou Single = um número com ponto flutuante de 32-bit com baixa precisão
- double = um número com ponto flutuante de 64-bit com média precisão (precisão dupla em relação ao float)
- decimal = um número com ponto flutuante de 128-bit com alta precisão, mas com um intervalo inteiro curto (digamos que tem mais casas depois da vírgula do que antes dela)
Ninguém duvida que sempre que queremos declarar variáveis que armazenarão números inteiros devemos usar o tipo int certo?
Mas e porque existem tantos outros tipos de números inteiros então?
Apenas uma resposta: necessidade. Os inteiros são eficientes pois possuem o tamanho exato para serem somados em apenas um ciclo de processamento com outro inteiro. Mas isto os limita a 32-bit de valores possíveis (4.5B aproximadamente).
E quando precisamos mais do que 5B de opções?
Aí que entra, por exemplo, o long. Inteiros longos somente devem ser usados quando o intervalo de números de um inteiro não atende às necessidades da aplicação.
E o short?
À primeira vista você deve pensar que inteiros curtos servem para os cálculos serem realizados mais rápidos certo?
Errado.
O cálculo de operações sobre inteiros curtos leva o mesmo tempo do que os inteiros comuns, pois nada demora menos do que um ciclo de processamento, mesmo que ocupe meia-palavra na arquitetura do processador (atualmente até os inteiros comuns ocupam meia-palavra por causa dos processadores 64-bit).
Então para que serve o short?
Economia de memória.
Shorts ocupam apenas 16-bit de memória, o que faz com que consumam metade do que um inteiro consumiria e isto é muito útil considerando o custo de memória atual, principalmente a memória cache dos processadores, ainda assim, não produzem, mesmo na casa das milhões de operações, alguma diferença a ser considerada (os exemplos mais à frente mostrarão isso também). Ou seja, não se preocupe com shorts e ints, a menos que o recurso de memória seja algo crítico em sua aplicação.
O byte por sua vez não foi criado para armazenar números pequenos entre 0 e 255. Bytes são usados, geralmente em arrays, para representar cadeias binárias, como streams, imagens bufferizadas e por aí vai. Simplesmente não vale a pena usá-lo se não for para realmente representar bytes. Se alguém quiser fazer um benchmark sobre o uso de bytes vs inteiros e me mostrar do contrário, eu atualizo o post, mas até o momento não tenho provas do contrário.
Quando entramos nos números com ponto flutuante é que pegamos no “calcanhar de Aquiles” dos processadores.
Operações com ponto flutuante, até mesmo a soma, a mais elementar de todas, é onde o bicho pega e o desempenho da aplicação cai drasticamente. É aqui que todo engenheiro de software que se preze deveria colocar sua atenção e evitar bobagens como doubles no lugar de floats ou floats no lugar de inteiros.
Operações com ponto flutuante são tão custosas para os processadores que existe uma unidade de medida de capacidade de processamento chamada de GFLOP, ou GigaFlops. Esta unidade é muito utilizada em computação pesada como placas de vídeo e super-processadores (incluindo as GPUs de videogames). A quantidade de GFLOPs de um processador indica quantos bilhões de operações com ponto flutuante o processador pode executar por segundo. E embora uma valor de 1GFLOP possa parecer mais do que você precisa, lembre-se que o processador nunca está apenas processando o SEU algoritmo.
O float está para os números com ponto assim como o inteiro está para os números sem ponto. Ou seja, se você tem certeza que necessita armazenar as casas decimais da sua variável e não necessita de grande precisão, use o float.
Isto por si só já terá um impacto de 20% mais ciclos nas somas elementares, mesmo que a soma seja entre um float e um int.
Sim, uma vez que você coloca um float em uma expressão aritmética, todos os demais números da expressão serão “formatados” como se fossem floats para que o cálculo possa ser completado. Lembre-se das somas da primeira série quando temos um número de uma casa sendo somado com outro de duas. O número menor terá zeros colocados à esquerda. É a mesma coisa com o float e por mais que uma soma com zero pareça inofensiva, ela consome ciclos. No caso dos floats, muitos ciclos pois os zeros também existirão à direita…
O double é utilizado na mesma ocasião que o long + os problemas do float. Ou seja, intervalos superiores a 2.5B + uma precisão decimal muito boa. Imagine o estrago que isso causa nos cálculos! Apesar disso, nossas arquiteturas atuais operam muito bem com longs e doubles devido à sua natureza de 64-bit, elevando apenas um pouco mais do que o normal o tempo de execução de tais cálculos. Ainda assim, evite sempre que puder.
O decimal é utilizado quando precisamos de extrema precisão depois da vírgula. Isso porque o float tradicional tem uma maneira bem peculiar de armazenar os números decimais dentro dele que faz com que tenha pouca precisão. Isso fica mais evidente quando salvamos floats simples como 1.9 no banco de dados e depois fazemos uma consulta e nos deparamos com algo como 1.899999. Estranho não?
Isso se resolve facilmente usando um decimal, que é um tipo que permite poucas casas antes da vírgula mas muitas depois, com uma precisão e fidelidade muito boas. O benchmark a seguir mostrará que o decimal possui um gargalo gigante de processamento, o que o torna mal visto em testes de performance e seu uso não é lá muito recomendado, exceto se necessário.
Uma outra ideia para quem quer evitar os decimais é usar Int. Sim, isso mesmo. Todo valor monetário é um número inteiro expresso em centavos. Assim, R$10,00 nada mais é do que 1000 centavos. Na hora de exibir na tela, você terá o mesmo trabalho que teria com o float, usar um simples String.Format. Pense nisso.
Não tenho muitas considerações sobre caracteres e strings.
Strings são um mal necessário e mesmo que o pessoal do C se gabe de não usá-las, lembrem-se que ponteiros de caracterer (char*) nada mais são do que strings enrustidas, hehehehehe. Antes que me crucifiquem-me por não falar dos gargalos de performance de strings, lembrem-se que todo e qualquer cálculo sobre strings (Replaces, por exemplo) são custosos. Já debati um pouco a respeito no Benchmark de Expressões Regulares e mais pra frente quero fazer um post sobre Benchmark de Strings, mas por ora, usem a premissa básica de que se você tem uma palavra, o mais sensato é usar String. Se tem caracteres individuais, use char.
Here we go!
Criei uma aplicação console simples com .NET Core que mostra o tempo de processamento de operações simples sobre os tipos de variáveis mais comuns de serem encontrados em aplicações.
A aplicação se divide em duas partes, a soma e a multiplicação. Enquanto que na soma a diferença de manipulação dos tipos é mínima, quando efetuamos multiplicações isso aumenta um pouco nos tipos inteiros e bastante nos com ponto flutuante.
Nestes testes é possível ver, também, que inteiros curtos não têm ganho sobre inteiros comuns.
Note também que na multiplicação foi utilizado um número com ponto flutuante para os tipos flutuantes, por dois motivos: primeiro, para não estourar o limite das variáveis antes da bilionésima iteração do laço e segundo: para mostrar o gargalo que são as operações com ponto flutuante.
Os testes foram feitos em um Macbook Pro Mid-2009 rodando Mac OS X 64-bit com um Core 2 Duo 2.2GHz e 8GB RAM, com o .NET Core 1.1 instalado.
Resultado da soma de variáveis
Os resultados foram muito próximos, com exceção do decimal. Da soma de inteiros para as demais somas, temos uma diferença de aproximadamente 1 segundo a mais, uma vez que meu processador e sistema operacional são 64-bit e lidam com longs e doubles com a mesma facilidade que ints.
Vale atenção ao short que como é um tipo com capacidade muito pequena, conforme vamos incrementando e sua capacidade estoura, ele “dá a volta” começando a usar os números negativos também. Se apenas somarmos até o short chegar no seu limite, seu tempo é menor que 1ms pois chega a pouco mais de 32k positivo no máximo.
No entanto a soma entre decimais (mesmo que usando apenas números inteiros) leva aproximadamente 10 vezes mais tempo do que com os demais tipos, como mostra no gráfico abaixo construído após meus testes.
Note que apesar de visualmente a maioria dos tipos não apresentar grande diferença, saliento que ela chega a 25% do menor para o maior tipo (excluindo o decimal da comparação que é obviamente lento no gráfico). Esses 25% podem fazer bastante diferença dependendo do seu sistema.
Os fontes estão abaixo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
private static void Adicao() { Stopwatch sw = Stopwatch.StartNew(); short curto = 1; for (int i = 0; i < 1000000000; i++) curto += 1; sw.Stop(); Console.WriteLine("Short demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); int inteiro = 1; for (int i = 0; i < 1000000000; i++) inteiro += 1; sw.Stop(); Console.WriteLine("Int demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); long longo = 1; for (int i = 0; i < 1000000000; i++) longo += 1; sw.Stop(); Console.WriteLine("Long demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); float flutuante = 1; for (int i = 0; i < 1000000000; i++) flutuante += 1; sw.Stop(); Console.WriteLine("Float demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); double duplo = 1; for (int i = 0; i < 1000000000; i++) duplo += 1; sw.Stop(); Console.WriteLine("Double demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); decimal virgula = 1; for (int i = 0; i < 1000000000; i++) virgula += 1; sw.Stop(); Console.WriteLine("Decimal demorou:" + sw.ElapsedMilliseconds); } |
Subtrações, que são apenas somas com sinal negativo, devem ter a mesma performance. Mas e quando usamos outras operações?
É o que veremos a seguir…
Resultado da multiplicação de variáveis
Quando entramos no campo das multiplicações, eu tive de adicionar números com ponto flutuante para as variáveis de tipos flutuantes, para que o cálculo fosse aumentando o tamanho do número inicial em uma pequena % a cada iteração, evitando estourá-lo muito rápido, principalmente o decimal.
Para os números sem casas decimais, dobramos eles 1B de vezes. Para os números com casas decimais incrementamos em 10% 1B de vezes, com exceção do decimal.
Neste cenário floats e double possuem quase o mesmo desempenho, que é bem proporcional ao de tempo dos cálculos com inteiros (menos de 500ms de diferença). No entanto, da execução mais rápida à mais lenta, temos 25% de diferença também. Já o decimal é quase 40x mais lento que os demais para efetuar 1B multiplicações e você já deve ter sacado que não é uma boa ideia usar ele em sistemas que exijam grande processamento de requisições.
Para divisões, que nada mais são do que multiplicações com frações, o desempenho deve se mostrar o mesmo.
Os fontes desse segundo teste podem ser conferido abaixo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
private static void Multiplicacao() { Stopwatch sw = Stopwatch.StartNew(); short curto = 1; for (int i = 0; i < 1000000000; i++) curto *= 2; sw.Stop(); Console.WriteLine("Short demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); int inteiro = 1; for (int i = 0; i < 1000000000; i++) inteiro *= 2; sw.Stop(); Console.WriteLine("Int demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); long longo = 1; for (long i = 0; i < 1000000000; i++) longo *= 2L; sw.Stop(); Console.WriteLine("Long demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); float flutuante = 1; for (int i = 0; i < 1000000000; i++) flutuante *= 1.1f; sw.Stop(); Console.WriteLine("Float demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); double duplo = 1; for (int i = 0; i < 1000000000; i++) duplo *= 1.1d; sw.Stop(); Console.WriteLine("Double demorou:" + sw.ElapsedMilliseconds); sw = Stopwatch.StartNew(); decimal virgula = 1m; for (int i = 0; i < 1000000000; i++) virgula *= 1.00000001m; sw.Stop(); Console.WriteLine("Decimal demorou:" + sw.ElapsedMilliseconds); } |
Conclusões
Espero que tenham gostado do post e que tenha sido de alguma utilidade para vocês desenvolvedores. Nós cientistas da computação muitas vezes temos problemas complexos para solucionar e muitas vezes caímos no “achismo” ao invés de utilizarmos a experimentação científica, que seria a maneira correta de levantar evidências que comprovem nossas opiniões.
Enquanto que alguns subestimam a instrução formal e o estudo das Ciências da Computação, é no “frigir dos ovos” e nos verdadeiros embates de conhecimento que vemos a diferença entre desenvolvedores de sistemas e digitadores de código.
Obviamente não consegui explanar aqui tudo que se pode aprender sobre tipos de variáveis e usei exemplos mais práticos do que teóricos. Outros estudos neste campo incluem a documentação da linguagem C# presente no MSDN, o estudo da precisão dos pontos-flutuantes, o estudo dos valores inteiros e dos valores decimais.
Também não falei sobre os unsigned types (tipos sem sinal) que aproveitam o bit que é utilizado para armazenar o sinal (+ ou -) da variável para obter o dobro de números possíveis de serem armazenados. Futuramente quero dedicar um post único sobre desempenho de Strings.
Até a próxima!
Olá, tudo bem?
O que você achou deste conteúdo? Conte nos comentários.