Passagem por valor ou por referência - O que é mais rápido?

Imagem de capa Passagem por valor ou por referência - O que é mais rápido?

Passagem por valor ou por referência - O que é mais rápido?

?? Apenas como uma isenção de responsabilidade: esteja ciente de que as diferenças de desempenho mostradas são super pequenas. Dito isso, não execute seu código e altere tudo para uma estrutura. A Microsoft recomenda classes por padrão por um motivo. Se você tiver um atalho supercrítico, isso pode ser justificado.

Quando estamos passando objetos, podemos fazer isso por referência ou por valor. Qual desses dois métodos é mais rápido?

Para responder a essa pergunta, precisamos mergulhar um pouco no que acontece exatamente quando você passa algo e como o outro lado receberá isso.

Passando por valor

Um tipo de valor como int é passado para um método por valor. Isso significa que uma cópia desse objeto é feita e passada para o chamador. Como? Acabamos de colocar a nova cópia no quadro de pilha que a função de chamada pode ver.

Melhor explicado com este exemplo simples

int a = 0;
Change(a); // We pass a copy of a to the function
Console.Write(a); // Prints still 0

void Change(int a) => a = 5; // The function changes the copy to 5

Passando por referência

Tecnicamente falando, o passe por referência não existe realmente. Tudo é passado por valor. Quando dizemos passar por referência, queremos dizer passar o valor da referência para o chamado. Se dermos uma olhada no exemplo anterior, encontraremos uma pequena mudança no resultado:

int a = 0;
Change(ref a); // We pass the reference of a to the function
Console.Write(a); // Prints 5

void Change(ref int a) => a = 5; // The function changes the "original a" to 5

Acho que essa parte é bem conhecida. Mas também não é toda a verdade. Para entender o que e por que algo é mais rápido, temos que ir um pouco mais fundo.

Desreferenciamento

Agora, apenas passar uma referência a uma função é apenas uma parte. Também temos que desreferenciar esse valor para poder lê-lo. Assim, podemos ver que há duas operações envolvidas quando se trata de passar por referência. Lembre-se que uma referência é basicamente um endereço de memória. Isso é tudo!

  1. Coloque a referência no quadro de pilha para que o receptor possa acessá-lo
  2. Desreferencie e leia o conteúdo real

A parte interessante é (2), porque a desreferenciação acontece toda vez que acessamos essa variável dentro do nosso código:

void  DoSomething(ref MyStruct myStruct)
{
     int x = myStruct.Prop1; // Dereferencing and reading 4 bytes
     int y = myStruct.Prop2; // Dereferencing and reading 4 bytes

Tamanho de uma referência

Eu disse anteriormente que estamos copiando o valor do endereço de memória de um tipo de referência para o receptor. Em C/C++ (assim como em C#) essas referências a endereços de memória são chamadas de ponteiros. Os ponteiros têm (geralmente) 4 bytes de largura em um processo de 32 bits ou 8 bytes de largura em um processo de 64 bits. Isso não é coincidência. Isso significa que copiar este valor pode ser feito dentro de um único registro em sua CPU. Importante saber é que isso é super rápido e basicamente o melhor caso.

estrutura(s) e seu tamanho

Agora talvez você tenha uma ideia de onde tudo isso vai dar. Vimos anteriormente que o tamanho é um fator muito, muito importante. (Alerta de spoiler: também o layout desempenha um papel, mais sobre isso depois). Então, quando passamos uma estrutura pelo seu valor, o tamanho é a fábrica de chaves que determina se ela é mais rápida ou não. Lembre-se de que temos menos trabalho para passar por valor do que por referência. Só se tivermos que copiar muito mais coisas seremos mais lentos. Dê uma olhada nesta estrutura:

public  struct Point2D
{
     public  int X { get; set; }
     public  int Y { get; set; }
 }

Esta é uma estrutura que tem dois int's. Cada int tem 32 bits, respectivamente, 4 bytes de largura. Isso significa que a estrutura tem exatamente 64 bits de largura. Portanto, exatamente o mesmo tamanho do nosso ponteiro quando passamos referências. Mas, ao contrário de uma referência, depois de copiar, terminamos. O chamado não precisa desreferenciar nada.

Referência

Aqui está uma pequena referência para mostrar isso na natureza.

public  class  PassByBenchmark
{
     private  readonly PointClass pointClass = new();
     private  readonly PointStruct pointStruct = new();

     [Benchmark(Baseline = true)]
     public  int  GetSumViaClassReference()
     {
           var sum = 0;
           for(var i = 0; i < 20_000; i++)
               sum += GetSumClass(pointClass);

           return sum;
     }

     [Benchmark]
     public int GetSumViaStruct()
     {
          var sum = 0;
          for(var i = 0; i < 20_000; i++)
              sum += GetSumStruct(pointStruct);

           return sum;
     }

     [Benchmark]
     public  int  GetSumViaStructReference()
     {
         {
             var sum = 0;
             for(var i = 0; i < 20_000; i++)
                 sum += GetSumRefStruct(in pointStruct);

             return sum;
         }
     }

     private int GetSumClass(PointClass c) => c.X + c.Y;
     private int GetSumStruct(PointStruct c) => c.X + c.Y;
     private int GetSumRefStruct(in PointStruct c) => c.X + c.Y;
}

public  class  PointClass
{
   public  int X { get; set; }
   public  int Y { get; set; }
}

public  struct PointStruct
{
   public  int X { get; set; }
   public  int Y { get; set; }
}

Resultados:

|                 Método | Quer dizer |     Erro |   StdDev | Razão | RatioSD |
|------------------------|-----------:| --------:| --------:| -----:|--------:|
|GetSumViaClassReference |  12.94 us  | 0.259 us | 0.318 us | 1.00  | 0.00    |
|       GetSumViaStruct  |  12.05 us  | 0.229 us | 0.214 us | 0.92  | 0.03    |
|GetSumViaStructReference|  12.82 us  | 0.244 us | 0.365 us | 0.99  | 0.04    |

Passar uma classe por referência ou uma estrutura por referência não faz diferença alguma. Isso é esperado, pois o mesmo mecanismo está em vigor. Mas podemos ver que nosso estrutura Point é o método mais rápido pelo único motivo de ser pequeno e copiar essa estrutura tem o mesmo impacto que copiar o ponteiro para nosso callee.

Estrutura de gordura

gora, isso parecerá muito diferente quando nossa estrutura for um pouco mais ampla. Vamos ter a mesma configuração, mas "refatoramos" nossa estrutura e classe da seguinte forma:

public  class  PointClass
{
   public  int A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z;
}

public  struct PointStruct
{
   public  int A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z;
}

Nossa estrutura agora tem 4 bytes * 26 letras = 104 bytes de largura. Nossa referência ainda é de apenas 8 bytes (em um processo de 64 bits).

|                   Método | Quer dizer |     Erro |   StdDev | Razão | RatioSD |
|--------------------------|-----------:| --------:| --------:| -----:|--------:|
| GetSumViaClassReference  |  13.17 us  | 0.140 us | 0.131 us | 1.00  | 0.00    |
|          GetSumViaStruct |  109.50 us | 2.161 us | 4.058 us | 8.28  | 0.40    |
| GetSumViaStructReference |  13.22 us  | 0.253 us | 0.260 us | 1.00  | 0.02    |

Podemos ver que o tempo de execução de nossas referências ainda é o mesmo esperado. Mas nossa estrutura é 8x mais lenta. A única coisa que mudou foi a cópia maior que temos que fazer.

Layout da estrutura

Uma última parte que pode ser interessante é o layout de uma estrutura. Já estamos na toca do coelho, então por que não ir um pouco mais fundo ??. Dê uma olhada nessas duas estruturas:

public  struct Struct1
{
    public  int Number1;
    public  bool HasNumber1;
    public  int Number2;
    public  bool HasNumber2;
}

public  struct Struct2
{
    public  int Number1;
    public  int Number2;
    public  bool HasNumber1;
    public  bool HasNumber2;

}

Eles são os mesmos? Sim e não. Claro que eles têm as mesmas propriedades. Mas o layout é bem diferente:

Tirado daqui

Apesar de termos a mesma quantidade de propriedades, o layout da estrutura é diferente. O que acontece aqui é que uma estrutura será preenchido em blocos de 4 bytes (esse valor depende da sua plataforma). Isso significa que se começarmos com um bool, teremos apenas 3 bytes restantes, portanto, um int não pode mais caber e 3 bytes devem ser preenchidos para preencher o bloco. Isso pode ter implicações de desempenho, mas mais importante que pode levar a grandes problemas se você tiver interoperabilidade com outras linguagens (nativas) como C/C++. Neste artigo, não vou me aprofundar nesse assunto, pois pode ficar bastante complicado. Talvez algo para o futuro ??

Conclusão

Espero que você tenha uma melhor compreensão do que acontece quando você passa algo por referência e por valor. E por que estruturas podem ser mais rápidos que classes ou estruturas de referência.