Überschreiben von Equals und GetHashCode. Aber ist es notwendig?

Wenn Sie mit C # vertraut sind, wissen Sie höchstwahrscheinlich, dass Sie Equals und GetHashCode immer überschreiben GetHashCode , um Leistungseinbußen zu vermeiden. Aber was passiert, wenn dies nicht getan wird? Heute vergleichen wir die Leistung mit zwei Optimierungsoptionen und betrachten Tools, um Fehler zu vermeiden.



Wie ernst ist dieses Problem?


Nicht jedes potenzielle Leistungsproblem wirkt sich auf die Laufzeit der Anwendung aus. Die Enum.HasFlag Methode Enum.HasFlag nicht sehr effizient (*). Wenn Sie sie jedoch nicht für einen ressourcenintensiven Code verwenden, treten im Projekt keine ernsthaften Probleme auf. Dies ist auch bei geschützten Kopien der Fall, die von nicht schreibgeschützten Strukturtypen im schreibgeschützten Kontext erstellt wurden. Das Problem besteht, ist jedoch bei normalen Anwendungen wahrscheinlich nicht erkennbar.

(*) In .NET Core 2.1 behoben. Wie bereits in einer früheren Veröffentlichung erwähnt , können die Konsequenzen jetzt mithilfe des selbstkonfigurierten HasFlag für ältere Versionen gemindert werden.

Aber das Problem, über das wir heute sprechen werden, ist etwas Besonderes. Wenn die Methoden Equals und GetHashCode nicht in der Struktur erstellt werden, werden ihre Standardversionen von System.ValueType . Und sie können die Leistung der endgültigen Anwendung erheblich reduzieren.

Warum sind Standardversionen langsam?


Die CLR-Autoren haben ihr Bestes getan, um die Standardversionen von Equals und GetHashCode für Werttypen so effizient wie möglich zu gestalten. Es gibt jedoch mehrere Gründe, warum diese Methoden an Effektivität der Benutzerversion verlieren, die für einen bestimmten Typ manuell geschrieben (oder vom Compiler generiert) wurde.

1. Verteilung der Verpackungsumwandlung. Die CLR ist so konzipiert, dass jeder Aufruf eines Elements, das in den Typen System.ValueType oder System.Enum definiert ist, eine Wrapping-Transformation (**) auslöst.

(**) Wenn die Methode die JIT-Kompilierung nicht unterstützt. In Core CLR 2.1 erkennt der JIT-Compiler beispielsweise die Enum.HasFlag Methode und generiert geeigneten Code, der nicht mit dem Enum.HasFlag beginnt.

2. Mögliche Konflikte in der Standardversion der GetHashCode Methode. Bei der Implementierung einer Hash-Funktion stehen wir vor einem Dilemma: Die Verteilung der Hash-Funktion muss gut oder schnell sein. In einigen Fällen können Sie beides tun, aber beim Typ ValueType.GetHashCode ist dies normalerweise schwierig.

Eine traditionelle Hash-Funktion vom Typ struct "kombiniert" die Hash-Codes aller Felder. Die einzige Möglichkeit, den Feld-Hash-Code in der ValueType Methode ValueType , ist die Verwendung von Reflection. Aus diesem Grund haben die CLR-Autoren beschlossen, die Geschwindigkeit für die Verteilung zu opfern. Die Standardversion von GetHashCode nur den Hash-Code des ersten Felds ungleich Null zurück und „verdirbt“ ihn mit einer RegularGetValueTypeHashCode (***) (weitere Informationen finden Sie unter RegularGetValueTypeHashCode in coreclr repo auf github).

(***) Gemessen an den Kommentaren im CoreCLR-Repository kann sich die Situation in Zukunft ändern.

 public readonly struct Location { public string Path { get; } public int Position { get; } public Location(string path, int position) => (Path, Position) = (path, position); } var hash1 = new Location(path: "", position: 42).GetHashCode(); var hash2 = new Location(path: "", position: 1).GetHashCode(); var hash3 = new Location(path: "1", position: 42).GetHashCode(); // hash1 and hash2 are the same and hash1 is different from hash3 

Dies ist ein vernünftiger Algorithmus, bis etwas schief geht. Wenn Sie jedoch kein Glück haben und der Wert des ersten Felds Ihres Strukturtyps in den meisten Fällen gleich ist, führt die Hash-Funktion immer zum gleichen Ergebnis. Wie Sie vielleicht vermutet haben, sinkt die Leistung, wenn Sie diese Instanzen in einem Hash-Set oder einer Hash-Tabelle speichern.

3. Die auf Reflexion basierende Implementierungsgeschwindigkeit ist gering. Sehr niedrig. Reflexion ist ein mächtiges Werkzeug, wenn es richtig eingesetzt wird. Die Konsequenzen sind jedoch schrecklich, wenn Sie es auf einem ressourcenintensiven Code ausführen.

Mal sehen, wie sich eine fehlgeschlagene Hash-Funktion, die sich aus (2) und einer reflexionsbasierten Implementierung ergeben kann, auf die Leistung auswirkt:

 public readonly struct Location1 { public string Path { get; } public int Position { get; } public Location1(string path, int position) => (Path, Position) = (path, position); } public readonly struct Location2 { // The order matters! // The default GetHashCode version will get a hashcode of the first field public int Position { get; } public string Path { get; } public Location2(string path, int position) => (Path, Position) = (path, position); } public readonly struct Location3 : IEquatable<Location3> { public string Path { get; } public int Position { get; } public Location3(string path, int position) => (Path, Position) = (path, position); public override int GetHashCode() => (Path, Position).GetHashCode(); public override bool Equals(object other) => other is Location3 l && Equals(l); public bool Equals(Location3 other) => Path == other.Path && Position == other.Position; } private HashSet<Location1> _locations1; private HashSet<Location2> _locations2; private HashSet<Location3> _locations3; [Params(1, 10, 1000)] public int NumberOfElements { get; set; } [GlobalSetup] public void Init() { _locations1 = new HashSet<Location1>(Enumerable.Range(1, NumberOfElements).Select(n => new Location1("", n))); _locations2 = new HashSet<Location2>(Enumerable.Range(1, NumberOfElements).Select(n => new Location2("", n))); _locations3 = new HashSet<Location3>(Enumerable.Range(1, NumberOfElements).Select(n => new Location3("", n))); _locations4 = new HashSet<Location4>(Enumerable.Range(1, NumberOfElements).Select(n => new Location4("", n))); } [Benchmark] public bool Path_Position_DefaultEquality() { var first = new Location1("", 0); return _locations1.Contains(first); } [Benchmark] public bool Position_Path_DefaultEquality() { var first = new Location2("", 0); return _locations2.Contains(first); } [Benchmark] public bool Path_Position_OverridenEquality() { var first = new Location3("", 0); return _locations3.Contains(first); } 


  Method | NumOfElements | Mean | Gen 0 | Allocated | -------------------------------- |------ |--------------:|--------:|----------:| Path_Position_DefaultEquality | 1 | 885.63 ns | 0.0286 | 92 B | Position_Path_DefaultEquality | 1 | 127.80 ns | 0.0050 | 16 B | Path_Position_OverridenEquality | 1 | 47.99 ns | - | 0 B | Path_Position_DefaultEquality | 10 | 6,214.02 ns | 0.2441 | 776 B | Position_Path_DefaultEquality | 10 | 130.04 ns | 0.0050 | 16 B | Path_Position_OverridenEquality | 10 | 47.67 ns | - | 0 B | Path_Position_DefaultEquality | 1000 | 589,014.52 ns | 23.4375 | 76025 B | Position_Path_DefaultEquality | 1000 | 133.74 ns | 0.0050 | 16 B | Path_Position_OverridenEquality | 1000 | 48.51 ns | - | 0 B | 

Wenn der Wert des ersten Felds immer gleich ist, gibt die Hash-Funktion standardmäßig einen gleichen Wert für alle Elemente zurück und die Hash-Menge wird effektiv in eine verknüpfte Liste mit O (N) Einfüge- und Suchoperationen konvertiert. Die Anzahl der Operationen zum Füllen der Sammlung wird zu O (N ^ 2) (wobei N die Anzahl der Einfügungen mit der Komplexität O (N) für jede Einfügung ist). Dies bedeutet, dass das Einfügen in einen Satz von 1000 Elementen fast 500.000 Aufrufe von ValueType.Equals . Hier sind die Konsequenzen einer Methode mit Reflexion!

Wie der Test zeigt, ist die Leistung akzeptabel, wenn Sie Glück haben und das erste Element der Struktur eindeutig ist (im Fall von Position_Path_DefaultEquality ). Ist dies jedoch nicht der Fall, ist die Produktivität äußerst gering.

Echtes Problem


Ich denke, jetzt können Sie erraten, auf welches Problem ich kürzlich gestoßen bin. Vor einigen Wochen erhielt ich eine Fehlermeldung: Die Laufzeit der Anwendung, an der ich arbeite, wurde von 10 auf 60 Sekunden erhöht. Glücklicherweise war der Bericht sehr detailliert und enthielt eine Spur von Windows-Ereignissen, sodass der Problempunkt schnell ValueType.Equals - ValueType.Equals 50 Sekunden ValueType.Equals geladen.

Nach einem kurzen Blick auf den Code wurde klar, wo das Problem lag:

 private readonly HashSet<(ErrorLocation, int)> _locationsWithHitCount; readonly struct ErrorLocation { // Empty almost all the time public string OptionalDescription { get; } public string Path { get; } public int Position { get; } } 

Ich habe ein Tupel verwendet, das einen benutzerdefinierten Strukturtyp mit der Standardversion von Equals enthielt. Und leider hatte es ein optionales erstes Feld, das fast immer gleich String.equals . Die Produktivität blieb hoch, bis die Anzahl der Elemente im Satz signifikant anstieg. Innerhalb weniger Minuten wurde eine Sammlung mit Zehntausenden von Elementen initialisiert.

ValueType.Equals/GetHashCode die Standardimplementierung von ValueType.Equals/GetHashCode immer langsam?


Sowohl ValueType.Equals als auch ValueType.GetHashCode verfügen über spezielle Optimierungsmethoden. Wenn der Typ keine „Zeiger“ hat und korrekt gepackt ist (ich werde in einer Minute ein Beispiel zeigen), werden optimierte Versionen verwendet: GetHashCode Iterationen werden für GetHashCode ausgeführt, XOR von 4 Bytes wird verwendet, die Equals Methode vergleicht zwei Instanzen mit memcmp .

 // Optimized ValueType.GetHashCode implementation static INT32 FastGetValueTypeHashCodeHelper(MethodTable *mt, void *pObjRef) { INT32 hashCode = 0; INT32 *pObj = (INT32*)pObjRef; // this is a struct with no refs and no "strange" offsets, just go through the obj and xor the bits INT32 size = mt->GetNumInstanceFieldBytes(); for (INT32 i = 0; i < (INT32)(size / sizeof(INT32)); i++) hashCode ^= *pObj++; return hashCode; } // Optimized ValueType.Equals implementation FCIMPL2(FC_BOOL_RET, ValueTypeHelper::FastEqualsCheck, Object* obj1, Object* obj2) { TypeHandle pTh = obj1->GetTypeHandle(); FC_RETURN_BOOL(memcmp(obj1->GetData(), obj2->GetData(), pTh.GetSize()) == 0); } 

Die Prüfung selbst wird in ValueTypeHelper::CanCompareBits . Sie wird sowohl aus der Iteration von ValueType.Equals als auch aus der Iteration von ValueType.GetHashCode .

Aber Optimierung ist eine sehr heimtückische Sache.

Erstens ist es schwer zu verstehen, wann es eingeschaltet ist; Selbst geringfügige Änderungen am Code können ihn ein- und ausschalten:

 public struct Case1 { // Optimization is "on", because the struct is properly "packed" public int X { get; } public byte Y { get; } } public struct Case2 { // Optimization is "off", because struct has a padding between byte and int public byte Y { get; } public int X { get; } } 

Weitere Informationen zur Speicherstruktur finden Sie in meinem Blog „Interne Elemente eines verwalteten Objekts, Teil 4. Feldstruktur“ .

Zweitens führt ein Vergleich des Speichers nicht unbedingt zum richtigen Ergebnis. Hier ist ein einfaches Beispiel:

 public struct MyDouble { public double Value { get; } public MyDouble(double value) => Value = value; } double d1 = -0.0; double d2 = +0.0; // True bool b1 = d1.Equals(d2); // False! bool b2 = new MyDouble(d1).Equals(new MyDouble(d2)); 

-0,0 und +0,0 sind gleich, haben aber unterschiedliche binäre Darstellungen. Dies bedeutet, dass Double.Equals wahr und MyDouble.Equals falsch ist. In den meisten Fällen ist der Unterschied nicht signifikant, aber stellen Sie sich vor, wie viele Stunden Sie damit verbringen werden, das durch diesen Unterschied verursachte Problem zu beheben.

Wie vermeide ich ein ähnliches Problem?


Können Sie mich fragen, wie das oben genannte in einer realen Situation passieren kann? Eine naheliegende Möglichkeit, die Methoden Equals und GetHashCode in Strukturtypen GetHashCode , ist die Verwendung der FxCop CA1815-Regel . Es gibt jedoch ein Problem: Dies ist ein zu strenger Ansatz.

Eine Anwendung, für die die Leistung entscheidend ist, kann Hunderte von Strukturtypen haben, die nicht unbedingt in Hash-Sets oder Wörterbüchern verwendet werden. Daher können Anwendungsentwickler die Regel deaktivieren, was unangenehme Folgen hat, wenn der Strukturtyp geänderte Funktionen verwendet.

Ein korrekterer Ansatz besteht darin, den Entwickler zu warnen, wenn die Struktur vom Typ "unangemessen" mit gleichen Standardwerten von Elementen (in der Anwendung oder in einer Bibliothek eines Drittanbieters definiert) in einem Hash-Set gespeichert ist. Natürlich spreche ich über ErrorProne.NET und die Regel, die ich dort hinzugefügt habe, sobald ich auf dieses Problem gestoßen bin:



Die ErrorProne.NET-Version ist nicht perfekt und gibt dem korrekten Code die Schuld, wenn im Konstruktor ein benutzerdefinierter Gleichheitsauflöser verwendet wird:



Ich denke jedoch immer noch, dass es eine Warnung wert ist, wenn eine Struktur mit gleichen Elementen standardmäßig nicht verwendet wird, wenn sie erstellt wird. Als ich beispielsweise meine Regel überprüfte, stellte ich fest, dass die in mscorlib definierte System.Collections.Generic.KeyValuePair <TKey, TValue> -Struktur Equals und GetHashCode nicht überschreibt. Es ist unwahrscheinlich, dass heute jemand eine Variable wie HashSet <KeyValuePair<string, int>> , aber ich glaube, dass sogar BCL die Regel brechen kann. Daher ist es nützlich, dies zu entdecken, bevor es zu spät ist.

Fazit


  • Das Implementieren der Standardgleichheit für Strukturtypen kann schwerwiegende Folgen für Ihre Anwendung haben. Dies ist ein reales, kein theoretisches Problem.
  • Die Standardgleichheitselemente für Werttypen basieren auf Reflexion.
  • Die von der Standardversion von GetHashCode durchgeführte Verteilung ist sehr schlecht, wenn das erste Feld vieler Instanzen denselben Wert hat.
  • Es gibt optimierte Versionen für die Standardmethoden Equals und GetHashCode , aber Sie sollten sich nicht auf sie verlassen, da sie bereits durch eine kleine Codeänderung GetHashCode werden können.
  • Verwenden Sie die FxCop-Regel, um sicherzustellen, dass jeder Strukturtyp Gleichheitselemente überschreibt. Es ist jedoch besser, das Problem mit dem Analysator zu vermeiden, wenn die „unangemessene“ Struktur in einem Hash-Set oder in einer Hash-Tabelle gespeichert ist.

Zusätzliche Ressourcen


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


All Articles