ref locaux et ref renvoie en C #: pièges de performance

Dès le début, C # a pris en charge le passage d'arguments par valeur ou par référence. Mais avant la version 7, le compilateur C # ne supportait qu'une seule façon de renvoyer une valeur à partir d'une méthode (ou d'une propriété) - retour par valeur. En C # 7, la situation a changé avec l'introduction de deux nouvelles fonctionnalités: ref return et ref local. En savoir plus sur eux et leurs performances - sous la coupe.



Raisons


Il existe de nombreuses différences entre les tableaux et les autres collections en termes d'exécution de langage commun. Dès le début, les baies prises en charge par CLR peuvent être considérées comme des fonctionnalités intégrées. L'environnement CLR et le compilateur JIT peuvent fonctionner avec des tableaux, et ils ont également une autre fonctionnalité: l'indexeur de tableaux renvoie des éléments par référence, et non par valeur.

Pour le démontrer, nous devrons nous tourner vers la méthode interdite - utilisez le type de valeur mutable:

public struct Mutable { private int _x; public Mutable(int x) => _x = x; public int X => _x; public void IncrementX() { _x++; } } [Test] public void CheckMutability() { var ma = new[] {new Mutable(1)}; ma[0].IncrementX(); // X has been changed! Assert.That(ma[0].X, Is.EqualTo(2)); var ml = new List<Mutable> {new Mutable(1)}; ml[0].IncrementX(); // X hasn't been changed! Assert.That(ml[0].X, Is.EqualTo(1)); } 

Les tests réussiront car l'indexeur de tableaux est très différent de l'indexeur de listes.

Le compilateur C # donne une instruction spéciale à l'indexeur de tableau - ldelema, qui renvoie un lien géré vers un élément de ce tableau. Essentiellement, un indexeur de tableau renvoie un élément par référence. Cependant, List ne peut pas se comporter de la même manière, car en C # il n'était pas possible * de renvoyer un alias d'état interne. Par conséquent, l'indexeur List retourne un élément par valeur, c'est-à-dire qu'il renvoie une copie de cet élément.

* Comme nous le verrons bientôt, l'indexeur List ne peut toujours pas renvoyer un élément par référence.

Cela signifie que ma [0] .IncrementX () appelle la méthode qui modifie le premier élément du tableau, tandis que ml [0] .IncrementX () appelle la méthode qui modifie la copie de l'élément sans affecter la liste d'origine.

Valeurs de retour et variables locales de référence: principes de base


La signification de ces fonctions est très simple: déclarer la valeur de référence retournée vous permet de renvoyer l'alias d'une variable existante, et la variable locale de référence peut stocker un tel alias.

1. Un exemple simple:

 [Test] public void RefLocalsAndRefReturnsBasics() { int[] array = { 1, 2 }; // Capture an alias to the first element into a local ref int first = ref array[0]; first = 42; Assert.That(array[0], Is.EqualTo(42)); // Local function that returns the first element by ref ref int GetByRef(int[] a) => ref a[0]; // Weird syntax: the result of a function call is assignable GetByRef(array) = -1; Assert.That(array[0], Is.EqualTo(-1)); } 

2. Valeurs de référence retournées et modificateur en lecture seule

La valeur de référence renvoyée peut renvoyer l'alias du champ d'instance, et à partir de la version 7.2 de C #, vous pouvez renvoyer l'alias sans pouvoir écrire dans l'objet correspondant à l'aide du modificateur ref readonly:

 class EncapsulationWentWrong { private readonly Guid _guid; private int _x; public EncapsulationWentWrong(int x) => _x = x; // Return an alias to the private field. No encapsulation any more. public ref int X => ref _x; // Return a readonly alias to the private field. public ref readonly Guid Guid => ref _guid; } [Test] public void NoEncapsulation() { var instance = new EncapsulationWentWrong(42); instance.X++; Assert.That(instance.X, Is.EqualTo(43)); // Cannot assign to property 'EncapsulationWentWrong.Guid' because it is a readonly variable // instance.Guid = Guid.Empty; } 

  • Les méthodes et propriétés peuvent renvoyer un «alias» de l'état interne. Dans ce cas, la méthode de tâche ne doit pas être définie pour la propriété.
  • Le retour par référence interrompt l'encapsulation, car le client acquiert un contrôle total sur l'état interne de l'objet.
  • Le retour via un lien en lecture seule évite de copier inutilement des types de valeur, tout en ne permettant pas au client de modifier l'état interne.
  • Les liens en lecture seule peuvent être utilisés pour les types de référence, bien que cela n'ait pas beaucoup de sens dans les cas non standard.

3. Restrictions existantes. Renvoyer un alias peut être dangereux: l'utilisation d'un alias pour une variable placée sur la pile une fois la méthode terminée provoquera le plantage de l'application. Pour rendre cette fonction sûre, le compilateur C # applique diverses restrictions:

  • Impossible de renvoyer le lien vers la variable locale.
  • Impossible de renvoyer une référence à cela dans les structures.
  • Vous pouvez renvoyer un lien vers une variable située sur le tas (par exemple, vers un membre de la classe).
  • Vous pouvez renvoyer un lien vers les paramètres ref / out.

Pour plus d'informations, nous vous recommandons de consulter l'excellente publication Safe to return rules for ref Returns . L'auteur de l'article, Vladimir Sadov, est le créateur de la fonction de référence de retour pour le compilateur C #.

Maintenant que nous avons une idée générale des valeurs de référence renvoyées et des variables locales référencées, regardons comment elles peuvent être utilisées.

Utilisation de valeurs de référence retournées dans les indexeurs


Pour tester l'effet de ces fonctions sur les performances, nous allons créer une collection immuable unique appelée NaiveImmutableList <T> et la comparer avec T [] et List pour les structures de différentes tailles (4, 16, 32 et 48).

 public class NaiveImmutableList<T> { private readonly int _length; private readonly T[] _data; public NaiveImmutableList(params T[] data) => (_data, _length) = (data, data.Length); public ref readonly T this[int idx] // R# 2017.3.2 is completely confused with this syntax! // => ref (idx >= _length ? ref Throw() : ref _data[idx]); { get { // Extracting 'throw' statement into a different // method helps the jitter to inline a property access. if ((uint)idx >= (uint)_length) ThrowIndexOutOfRangeException(); return ref _data[idx]; } } private static void ThrowIndexOutOfRangeException() => throw new IndexOutOfRangeException(); } struct LargeStruct_48 { public int N { get; } private readonly long l1, l2, l3, l4, l5; public LargeStruct_48(int n) : this() => N = n; } // Other structs like LargeStruct_16, LargeStruct_32 etc 

Un test de performance est effectué pour toutes les collections et additionne toutes les valeurs de propriété N pour chaque élément:

 private const int elementsCount = 100_000; private static LargeStruct_48[] CreateArray_48() => Enumerable.Range(1, elementsCount).Select(v => new LargeStruct_48(v)).ToArray(); private readonly LargeStruct_48[] _array48 = CreateArray_48(); [BenchmarkCategory("BigStruct_48")] [Benchmark(Baseline = true)] public int TestArray_48() { int result = 0; // Using elementsCound but not array.Length to force the bounds check // on each iteration. for (int i = 0; i < elementsCount; i++) { result = _array48[i].N; } return result; } 

Les résultats sont les suivants:



Apparemment, quelque chose ne va pas! Les performances de notre collection NaiveImmutableList <T> sont les mêmes que celles de la liste. Que s'est-il passé?

Valeurs de retour avec modificateur en lecture seule: comment cela fonctionne


Comme vous pouvez le voir, l'indexeur NaiveImmutableList <T> renvoie un lien en lecture seule à l'aide du modificateur ref readonly. Cela est pleinement justifié, car nous voulons limiter la capacité des clients à modifier l'état sous-jacent d'une collection immuable. Cependant, les structures que nous utilisons dans le test de performances ne sont pas seulement lisibles.

Ce test nous aidera à comprendre le comportement de base:

 [Test] public void CheckMutabilityForNaiveImmutableList() { var ml = new NaiveImmutableList<Mutable>(new Mutable(1)); ml[0].IncrementX(); // X has been changed, right? Assert.That(ml[0].X, Is.EqualTo(2)); } 

Le test a échoué! Mais pourquoi? Parce que la structure des «liens en lecture seule» est similaire à la structure des modificateurs et des champs en lecture seule en ce qui concerne les structures: le compilateur génère une copie de protection chaque fois qu'un élément de structure est utilisé. Cela signifie que ml [0]. crée toujours une copie du premier élément, mais cela n'est pas fait par l'indexeur: la copie est créée au point d'appel.

Ce comportement est en fait logique. Le compilateur C # prend en charge le passage d'arguments par valeur, par référence et par «lien en lecture seule» à l'aide du modificateur in (pour plus de détails, voir Le modificateur in et les structures en lecture seule en C # («Le modificateur in et les structures en lecture seule en C # ")). Maintenant, le compilateur prend en charge trois façons différentes de renvoyer une valeur à partir d'une méthode: par valeur, par référence et par lien en lecture seule.

Les liens en lecture seule sont tellement similaires aux liens réguliers que le compilateur utilise le même InAttribute pour distinguer leurs valeurs de retour:

 private int _n; public ref readonly int ByReadonlyRef() => ref _n; 

Dans ce cas, la méthode ByReadonlyRef se compile efficacement en:

 [InAttribute] [return: IsReadOnly] public int* ByReadonlyRef() { return ref this._n; } 

La similitude entre le modificateur in et le lien en lecture seule signifie que ces fonctions ne sont pas très adaptées aux structures régulières et peuvent entraîner des problèmes de performances. Prenons un exemple:

 public struct BigStruct { // Other fields public int X { get; } public int Y { get; } } private BigStruct _bigStruct; public ref readonly BigStruct GetBigStructByRef() => ref _bigStruct; ref readonly var bigStruct = ref GetBigStructByRef(); int result = bigStruct.X + bigStruct.Y; 

Outre la syntaxe inhabituelle lors de la déclaration d'une variable pour bigStruct, le code semble correct. L'objectif est clair: BigStruct revient par référence pour des raisons de performances. Malheureusement, étant donné que la structure BigStruct est accessible en écriture, une copie de protection est créée à chaque accès à l'élément.

Utilisation de valeurs de référence renvoyées dans les indexeurs. Tentative numéro 2


Essayons le même ensemble de tests pour des structures en lecture seule de différentes tailles:



Maintenant, les résultats ont beaucoup plus de sens. Le temps de traitement continue d'augmenter pour les grandes structures, mais cela est attendu, car le traitement de plus de 100 000 structures plus grandes prend plus de temps. Mais maintenant le runtime pour NaiveimmutableList <T> est très proche du temps T [] et bien meilleur que dans le cas de List.

Conclusion


  • Les valeurs de référence renvoyées doivent être traitées avec soin car elles peuvent rompre l'encapsulation.
  • Les valeurs de référence retournées avec le modificateur en lecture seule ne sont efficaces que pour les structures en lecture seule. Dans le cas de structures conventionnelles, des problèmes de performances peuvent survenir.
  • Lorsque vous travaillez avec des structures inscriptibles, les valeurs de référence renvoyées avec le modificateur en lecture seule créent une copie de protection chaque fois que la variable est utilisée, ce qui peut entraîner des problèmes de performances.

Les valeurs de référence retournées et les variables locales référencées sont des fonctions utiles pour les créateurs de bibliothèques et les développeurs de code d'infrastructure. Cependant, ils sont très dangereux à utiliser dans le code de la bibliothèque: pour utiliser une collection qui renvoie efficacement des éléments à l'aide d'un lien en lecture seule, chaque utilisateur de la bibliothèque doit se souvenir: un lien en lecture seule vers une structure accessible en écriture crée une copie de protection «au point d'appel ". Dans le meilleur des cas, cela annulera une éventuelle augmentation de la productivité, et dans le pire des cas, cela entraînera une grave détérioration si, en même temps, un grand nombre de demandes sont adressées à une variable locale de référence, en lecture seule.

Les liens PS en lecture seule apparaîtront dans BCL. Les méthodes ref en lecture seule pour accéder aux éléments des collections immuables ont été présentées dans la demande suivante pour incorporer les modifications dans le référentiel corefx ( Implémentation de la proposition d'API ItemRef («Proposition d'inclure l'API ItemRef»)). Par conséquent, il est très important que tout le monde comprenne les caractéristiques de l'utilisation de ces fonctions et comment et quand elles doivent être appliquées.

Source: https://habr.com/ru/post/fr423061/


All Articles