LINQ Coding Guidelines #5–Komplexe Lambda-Funktionen auslagern

16. Januar 2015

Ich sehe oft LINQ-Ausdrücke, in denen versucht wird, alles auf einmal zu machen. Die letzten Empfehlungen hatten bereits zum Inhalt das Mapping der Daten in Hilfsmethoden auszulagern; damit muss aber nicht Schluss sein.

Empfehlung: Komplexe Lambda-Funktionen sollten aus LINQ-Ausdrücken ausgelagert werden.

 

Beginnen wir bei unserem Lieblingsbeispiel für pathologische LINQ-Exzesse: Dem RayTracer.

Der LINQ-Ausdruck hat 61 LOC, die ich zumindest nicht durchschaue. 49 Zeilen davon (ab “let computeTraceRay = …“) dienen aber nur der Definition eines Lambda-Ausdrucks, der dann in der folgenden Zeile (“let traceRay = Y(computeTraceRay)”) verwendet wird.

Definiert man diesen vor der LINQ-Abfrage, dann reduziert sich diese auf 12 LOC. Greift man dem Code noch durch ein paar Operatoren für Vector unter die Arme, dann wird das Ganze zumindest lesbar:

   1: var pixelsQuery =

   2:     from y in Enumerable.Range(0, screenHeight)

   3:     let recenterY = -(y - (screenHeight / 2.0)) / (2.0 * screenHeight)

   4:     select from x in Enumerable.Range(0, screenWidth)

   5:            let recenterX = (x - (screenWidth / 2.0)) / (2.0 * screenWidth)

   6:            let point = Vector.Norm(scene.Camera.Forward + (recenterX * scene.Camera.Right) + (recenterY * scene.Camera.Up))

   7:            let ray = new Ray() { Start = scene.Camera.Pos, Dir = point }

   8:            let traceRay = Y(computeTraceRay)

   9:            select new { X = x, Y = y, Color = traceRay(new TraceRayArgs(ray, scene, 0)) };

Und das Ergebnis stimmt immer noch:

image

Nun ist ein RayTracer sicher nicht der übliche Anwendungsfall für LINQ. Aber auch in produktivem Code finden sich genügend Beispiele:

   1: result = _context.AllocationSet.Where(a => a.Employee.UserName == userName && a.AllocatedFrom >= timestart && a.AllocatedTo <= timeEnd);

Auslagern kann sich in diesem Falle auf die Auslagerung in eine separate Operation beziehen…

   1: result = _context.AllocationSet

   2:     .Where(a => a.Employee.UserName == userName)

   3:     .Where(a => a.AllocatedFrom >= timestart && a.AllocatedTo <= timeEnd);

…wodurch die Bedingungen zumindest inhaltlich getrennt werden.

Insbesondere Bedingungen, die von mehreren Parametern abhängig sind gewinnen an Lesbarkeit wenn sie in Hilfsmethoden ausgelagert werden:

   1: result = _context.AllocationSet

   2:     .Where(a => a.Employee.UserName == userName)

   3:     .Where(a => a.IsWithinRange(timestart, timeEnd));

Das ist lesbarer und weniger fehleranfällig. Gefahr ist aber, dass das den Provider unterläuft, weil sich dieser Funktionsaufruf natürlich nicht in ein SQL-Statement übersetzen lässt. Aber auch das lässt sich mit einer entsprechenden Extension-Methode die mit Expressions arbeitet in den Griff bekommen:

   1: result = _context.AllocationSet

   2:     .ForUser(userName)

   3:     .AllocatedInRange(timestart, timeEnd);

Man kann es natürlich auch übertreiben und jeden Parameter in jeder Abfrage in eine dedizierte Methode packen. Das ist sicher nicht im Sinne des Erfinders. Aber für sehr häufige oder komplexe Parameter lohnt sich das durchaus.