it-swarm.com.de

Zu viel Abstraktion macht es schwierig, Code zu erweitern

Ich habe Probleme mit dem, was ich für zu viel Abstraktion in der Codebasis halte (oder zumindest damit zu tun habe). Die meisten Methoden in der Codebasis wurden abstrahiert, um das höchste übergeordnete A in der Codebasis aufzunehmen, aber das untergeordnete Element B dieses übergeordneten Elements verfügt über ein neues Attribut, das die Logik einiger dieser Methoden beeinflusst. Das Problem ist, dass diese Attribute in diesen Methoden nicht überprüft werden können, da die Eingabe nach A abstrahiert ist und A dieses Attribut natürlich nicht hat. Wenn ich versuche, eine neue Methode zu erstellen, um B anders zu behandeln, wird sie zur Codeduplizierung aufgerufen. Der Vorschlag meines technischen Leiters besteht darin, eine gemeinsam genutzte Methode zu erstellen, die boolesche Parameter berücksichtigt. Das Problem dabei ist jedoch, dass einige Leute dies als "versteckten Kontrollfluss" betrachten, bei dem die gemeinsam genutzte Methode eine Logik aufweist, die für zukünftige Entwickler möglicherweise nicht erkennbar ist und auch diese gemeinsam genutzte Methode wird einmal zu komplex/kompliziert, wenn zukünftige Attribute hinzugefügt werden müssen, selbst wenn sie in kleinere gemeinsam genutzte Methoden unterteilt ist. Dies erhöht auch die Kopplung, verringert den Zusammenhalt und verstößt gegen das Prinzip der Einzelverantwortung, auf das jemand in meinem Team hingewiesen hat.

Im Wesentlichen hilft ein Großteil der Abstraktion in dieser Codebasis dabei, die Codeduplizierung zu reduzieren, aber es erschwert das Erweitern/Ändern von Methoden, wenn sie die höchste Abstraktion annehmen. Was soll ich in einer solchen Situation tun? Ich bin im Zentrum der Schuld, obwohl sich alle anderen nicht darauf einigen können, was sie für gut halten, also tut es mir am Ende weh.

9
YamizGers

Wenn ich versuche, eine neue Methode zu erstellen, um B anders zu behandeln, wird sie zur Codeduplizierung aufgerufen.

Nicht alle Codeduplikationen sind gleich.

Angenommen, Sie haben eine Methode, die zwei Parameter mit dem Namen total() addiert. Angenommen, Sie haben eine andere namens add(). Ihre Implementierungen sehen völlig identisch aus. Sollten sie zu einer Methode zusammengeführt werden? NEIN!!!

Bei Don't-Repeat-Yourself oder DRY-Prinzip geht es nicht darum, Code zu wiederholen. Es geht darum, eine Entscheidung, eine Idee, zu verbreiten, sodass Sie, wenn Sie Ihre Idee jemals ändern, sie überall neu schreiben müssen, wo Sie diese Idee verbreiten. Blegh. Das ist furchtbar. Tu es nicht. Verwenden Sie stattdessen DRY, um Ihnen zu helfen treffen Sie Entscheidungen an einem Ort .

Das Prinzip DRY (Wiederholen Sie sich nicht)) besagt:

Jedes Wissen muss eine einzige, eindeutige und maßgebliche Darstellung innerhalb eines Systems haben.

wiki.c2.com - Wiederhole dich nicht

Aber DRY kann zur Gewohnheit werden, Code nach einer ähnlichen Implementierung zu durchsuchen, die wie das Kopieren und Einfügen von irgendwo anders aussieht. Dies ist die hirntote Form von DRY. Hölle, Sie könnten Tun Sie dies mit einem statischen Analysetool. Es hilft nicht, weil es den Punkt von DRY, der zu ist) ignoriert Halten Sie den Code flexibel.

Wenn sich meine Gesamtanforderungen ändern, muss ich möglicherweise meine total -Implementierung ändern. Das bedeutet nicht, dass ich meine add Implementierung ändern muss. Wenn ein Goober sie zu einer Methode zusammengefügt hat, habe ich jetzt ein bisschen unnötigen Schmerz.

Wie viel Schmerz? Sicherlich könnte ich den Code einfach kopieren und eine neue Methode erstellen, wenn ich sie brauche. Also keine große Sache, oder? Malarky! Wenn nichts anderes kostet dich mich einen guten Namen! Gute Namen sind schwer zu finden und reagieren nicht gut, wenn Sie an ihrer Bedeutung herumspielen. Gute Namen, die die Absicht klar machen, sind wichtiger als das Risiko, dass Sie einen Fehler kopiert haben, der offen gesagt leichter zu beheben ist, wenn Ihre Methode den richtigen Namen hat.

Mein Rat ist also, aufhören zu lassen, dass Knie-Ruck-Reaktionen auf ähnlichen Code Ihre Codebasis in Knoten binden. Ich sage nicht, dass Sie die Tatsache, dass Methoden existieren, ignorieren und stattdessen wohl oder übel kopieren und einfügen können. Nein, jede Methode sollte einen verdammt guten Namen haben, der die eine Idee unterstützt, um die es geht. Wenn die Implementierung mit der Implementierung einer anderen guten Idee übereinstimmt, wen interessiert das heute?

Wenn Sie jedoch eine sum() -Methode haben, die eine identische oder sogar andere Implementierung als total() hat, aber immer dann, wenn sich Ihre Gesamtanforderungen ändern, müssen Sie sum() ändern eine gute Chance, dass dies die gleiche Idee unter zwei verschiedenen Namen sind. Der Code wäre nicht nur flexibler, wenn er zusammengeführt würde, es wäre auch weniger verwirrend, ihn zu verwenden.

Was boolesche Parameter betrifft, ja, das ist ein übler Codegeruch. Dieses Kontrollflussmaterial ist nicht nur ein Problem, es zeigt auch, dass Sie eine Abstraktion an einem schlechten Punkt eingeschnitten haben. Abstraktionen sollen die Verwendung einfacher und nicht komplizierter machen. Das Übergeben von Bools an eine Methode zur Steuerung ihres Verhaltens ist wie das Erstellen einer geheimen Sprache, die entscheidet, welche Methode Sie wirklich aufrufen. Au! Tu mir das nicht an. Geben Sie jeder Methode ihren eigenen Namen, es sei denn, Sie haben etwas ehrlich gesagt, mein Gott Polymorphismus los.

Jetzt scheinen Sie von der Abstraktion ausgebrannt zu sein. Das ist schade, denn Abstraktion ist eine wunderbare Sache, wenn sie gut gemacht wird. Sie benutzen es oft, ohne darüber nachzudenken. Jedes Mal, wenn Sie ein Auto fahren, ohne das Zahnstangensystem verstehen zu müssen, jedes Mal, wenn Sie einen Druckbefehl verwenden, ohne an Betriebssystemunterbrechungen zu denken, und jedes Mal, wenn Sie Ihre Zähne putzen, ohne an jede einzelne Borste zu denken.

Nein, das Problem, mit dem Sie anscheinend konfrontiert sind, ist eine schlechte Abstraktion. Die Abstraktion dient einem anderen Zweck als Ihren Bedürfnissen. Sie benötigen einfache Schnittstellen zu komplexen Objekten, mit denen Sie die Erfüllung Ihrer Anforderungen anfordern können, ohne diese Objekte jemals verstehen zu müssen.

Wenn Sie Client-Code schreiben, der ein anderes Objekt verwendet, wissen Sie, welche Anforderungen Sie haben und was Sie von diesem Objekt benötigen. Das tut es nicht. Aus diesem Grund besitzt Client-Code die Schnittstelle. Wenn Sie der Kunde sind, kann Ihnen nichts sagen, was Sie brauchen, außer Sie. Sie stellen eine Benutzeroberfläche zur Verfügung, die Ihre Anforderungen zeigt, und fordern, dass alles, was Ihnen übergeben wird, diese Anforderungen erfüllt.

Das ist Abstraktion. Als Kunde weiß ich nicht einmal , mit was ich spreche. Ich weiß nur, was ich davon brauche. Wenn das bedeutet, dass Sie etwas einpacken müssen, um die Benutzeroberfläche zu ändern, bevor Sie es mir übergeben. Es ist mir egal. Tu einfach was ich tun muss. Hör auf, es kompliziert zu machen.

Wenn ich in eine Abstraktion schauen muss, um zu verstehen, wie man sie verwendet, ist die Abstraktion fehlgeschlagen. Ich sollte nicht wissen müssen, wie es funktioniert. Nur dass es funktioniert. Gib ihm einen guten Namen und wenn ich hineinschaue, sollte mich das, was ich finde, nicht überraschen. Lassen Sie mich nicht weiter nach innen schauen, um mich daran zu erinnern, wie man es benutzt.

Wenn Sie darauf bestehen, dass die Abstraktion auf diese Weise funktioniert, spielt die Anzahl der dahinter stehenden Ebenen keine Rolle. Solange Sie nicht hinter die Abstraktion schauen. Sie bestehen darauf, dass die Abstraktion Ihren Bedürfnissen entspricht und sich nicht an ihre anpasst. Damit dies funktioniert, muss es einfach zu bedienen sein, einen guten Namen haben und nicht Leck .

Das ist die Einstellung, die Dependency Injection hervorgebracht hat (oder nur Referenzübergabe, wenn Sie wie ich in der alten Schule sind). Es funktioniert gut mit Komposition und Delegierung der Vererbung vorziehen . Die Haltung hat viele Namen. Mein Favorit ist sag, frag nicht .

Ich könnte dich den ganzen Tag in Prinzipien ertränken. Und es hört sich so an, als ob Ihre Mitarbeiter es bereits sind. Aber hier ist die Sache: Im Gegensatz zu anderen technischen Bereichen ist diese Software-Sache weniger als 100 Jahre alt. Wir alle finden es immer noch heraus. Lassen Sie sich also nicht von jemandem mit viel einschüchterndem Hörbuch dazu zwingen, schwer lesbaren Code zu schreiben. Hören Sie ihnen zu, aber bestehen Sie darauf, dass sie Sinn ergeben. Nimm nichts vom Glauben. Leute, die auf irgendeine Weise codieren, nur weil ihnen gesagt wurde, dass dies der Weg ist, ohne zu wissen, warum sie die größten Probleme von allen machen.

27
candied_orange

Das übliche Sprichwort, das wir alle hier und da lesen, lautet:

Alle Probleme können durch Hinzufügen einer weiteren Abstraktionsebene gelöst werden.

Nun, das ist nicht wahr! Ihr Beispiel zeigt es. Ich würde daher die leicht modifizierte Aussage vorschlagen (zögern Sie nicht, sie wiederzuverwenden ;-)):

Jedes Problem kann mit der richtigen Abstraktionsebene gelöst werden.

In Ihrem Fall gibt es zwei verschiedene Probleme:

  • die Überverallgemeinerung , die durch Hinzufügen jeder Methode auf abstrakter Ebene verursacht wird;
  • die Fragmentierung der konkreten Verhaltensweisen, die den Eindruck erwecken, nicht den Überblick zu behalten und sich verloren zu fühlen. Ein bisschen wie in einer Windows-Ereignisschleife.

Beide sind miteinander verbunden:

  • wenn Sie eine Methode abstrahieren, bei der jede Spezialisierung anders vorgeht, ist alles in Ordnung. Niemand hat ein Problem damit, zu verstehen, dass ein Shape seine surface() auf spezielle Weise berechnen kann.
  • Wenn Sie eine Operation abstrahieren, bei der es ein allgemeines Verhaltensmuster gibt, haben Sie zwei Möglichkeiten:

    • entweder wiederholen Sie das allgemeine Verhalten in jeder Spezialisierung: Dies ist sehr redundant. und schwer zu warten, insbesondere um sicherzustellen, dass der gemeinsame Teil über die Spezialisierungen hinweg im Einklang bleibt:
    • sie verwenden eine Variante des Template-Methodenmusters : Dies ermöglicht es Ihnen, das allgemeine Verhalten zu berücksichtigen, indem Sie zusätzliche abstrakte Methoden verwenden, die leicht spezialisiert werden können. Es ist weniger redundant, aber die zusätzlichen Verhaltensweisen neigen dazu, extrem gespalten zu werden. Zu viel würde bedeuten, dass es vielleicht zu abstrakt ist.

Darüber hinaus kann dieser Ansatz zu einem abstrakten Kopplungseffekt auf Entwurfsebene führen. Jedes Mal, wenn Sie ein neues spezialisiertes Verhalten hinzufügen möchten, müssen Sie es abstrahieren, das abstrakte übergeordnete Element ändern und alle anderen Klassen aktualisieren. Dies ist nicht die Art von Änderungsausbreitung , die man sich wünschen kann. Und es ist nicht wirklich im Sinne von Abstraktionen, unabhängig von der Spezialisierung (zumindest im Design).

Ich kenne Ihr Design nicht und kann nicht mehr helfen. Vielleicht ist es wirklich ein sehr komplexes und abstraktes Problem, und es gibt keinen besseren Weg. Aber wie stehen die Chancen? Die Symptome einer Übergeneralisierung sind hier. Vielleicht ist es an der Zeit, es noch einmal zu betrachten und Betrachten Sie Komposition über Generalisierung ?

7
Christophe

Immer wenn ich eine Methode sehe, bei der das Verhalten den Typ ihres Parameters einschaltet, überlege ich sofort, ob diese Methode tatsächlich zum Methodenparameter gehört. Zum Beispiel, anstatt eine Methode wie:

public void sort(List values) {
    if (values instanceof LinkedList) {
        // do efficient linked list sort
    } else { // ArrayList
        // do efficient array list sort
    }
}

Ich würde das tun:

values.sort();

// ...

class ArrayList {
    public void sort() {
        // do efficient array list sort
    }
}

class LinkedList {
    public void sort() {
        // do efficient linked list sort
    }
}

Wir verschieben das Verhalten an den Ort, der weiß, wann es verwendet werden soll. Wir erstellen eine echte Abstraktion, bei der Sie die Typen oder Details der Implementierung nicht kennen müssen. In Ihrer Situation ist es möglicherweise sinnvoller, diese Methode von der ursprünglichen Klasse (die ich O nennen werde) in den Typ A zu verschieben und sie im Typ B zu überschreiben. Wenn die Methode für ein Objekt doIt heißt, verschieben Sie doIt nach A und überschreiben Sie das unterschiedliche Verhalten in B. Wenn es Datenbits gibt, von denen doIt ursprünglich aufgerufen wurde, oder wenn die Methode an genügend Stellen verwendet wird, können Sie die ursprüngliche Methode verlassen und delegieren:

class O {
    int x;
    int y;

    public void doIt(A a) {
        a.doIt(this.x, this.y);
    }
}

Wir können jedoch etwas tiefer tauchen. Schauen wir uns den Vorschlag an, stattdessen einen booleschen Parameter zu verwenden, und sehen wir, was wir über die Denkweise Ihres Kollegen lernen können. Sein Vorschlag ist zu tun:

public void doIt(A a, boolean isTypeB) {
    if (isTypeB) {
        // do B stuff
    } else { 
        // do A stuff
    }
}

Dies sieht sehr nach dem instanceof aus, das ich in meinem ersten Beispiel verwendet habe, außer dass wir diese Prüfung externalisieren. Dies bedeutet, dass wir es auf zwei Arten nennen müssten:

o.doIt(a, a instanceof B);

oder:

o.doIt(a, true); //or false

Erstens hat der Aufrufpunkt keine Ahnung, welche Art von A er hat. Sollten wir deshalb Boolesche Werte ganz nach unten weitergeben? Ist das wirklich ein Muster, das wir überall in der Codebasis haben wollen? Was passiert, wenn es einen dritten Typ gibt, den wir berücksichtigen müssen? Wenn die Methode so aufgerufen wird, sollten wir sie in den Typ verschieben und das System die Implementierung für uns polymorph auswählen lassen.

Auf die zweite Weise müssen wir müssen es bereits wissen den Typ von a am Aufrufpunkt. Normalerweise bedeutet dies, dass wir entweder die Instanz dort erstellen oder eine Instanz dieses Typs als Parameter verwenden. Das Erstellen einer Methode für O, die hier ein B benötigt, würde funktionieren. Der Compiler würde wissen, welche Methode er wählen soll. Wenn wir durch solche Änderungen fahren, Duplizieren ist besser als die falsche Abstraktion zu erstellen , zumindest bis wir herausfinden, wohin wir wirklich gehen. Natürlich schlage ich vor, dass wir nicht wirklich fertig sind, egal was wir bis jetzt geändert haben.

Wir müssen uns die Beziehung zwischen A und B genauer ansehen. Im Allgemeinen wird uns gesagt, wir sollten Komposition gegenüber Vererbung bevorzugen . Dies ist nicht in jedem Fall der Fall, aber es ist in einer überraschenden Anzahl von Fällen der Fall, wenn wir uns eingegraben haben. B erbt von A, was bedeutet, dass wir glauben, dass B ist ein A. B sollte genau wie A verwendet werden, nur dass es etwas anders funktioniert. Aber was sind diese Unterschiede? Können wir den Unterschieden einen konkreteren Namen geben? Ist es nicht B ist ein A, aber wirklich A hat ein X, das A' Oder B' Sein könnte. ? Wie würde unser Code aussehen, wenn wir das tun würden?

Wenn wir die Methode wie zuvor vorgeschlagen auf A verschieben, können wir eine Instanz von X in A einfügen und diese Methode an X delegieren:

class A {
    X x;
    A(X x) {
        this.x = x;
    }

    public void doIt(int x, int y) {
        x.doIt(x, y);
    }
}

Wir können A' Und B' Implementieren und B loswerden. Wir haben den Code verbessert, indem wir einem möglicherweise impliziteren Konzept einen Namen gegeben haben, und uns erlaubt, dieses Verhalten zur Laufzeit anstatt zur Kompilierungszeit festzulegen. A ist auch weniger abstrakt geworden. Anstelle einer erweiterten Vererbungsbeziehung werden Methoden für ein delegiertes Objekt aufgerufen. Dieses Objekt ist abstrakt, konzentriert sich jedoch nur auf die Unterschiede in der Implementierung.

Es gibt jedoch noch eine letzte Sache zu beachten. Kehren wir zum Vorschlag Ihres Kollegen zurück. Wenn wir an allen Anrufstellen explizit den Typ von A kennen, den wir haben, sollten wir Anrufe tätigen wie:

B b = new B();
o.doIt(b, true);

Wir haben früher beim Verfassen angenommen, dass A ein X hat, das entweder A' Oder B' Ist. Aber vielleicht ist auch diese Annahme nicht richtig. Ist dies der einzige Ort, an dem dieser Unterschied zwischen A und B von Bedeutung ist? Wenn ja, können wir vielleicht einen etwas anderen Ansatz wählen. Wir haben immer noch ein X, das entweder A' Oder B' Ist, aber nicht zu A gehört. Nur O.doIt Kümmert sich darum, also lasst es uns nur an O.doIt Weitergeben:

class O {
    int x;
    int y;

    public void doIt(A a, X x) {
        x.doIt(a, x, y);
    }
}

Jetzt sieht unsere Anrufseite so aus:

A a = new A();
o.doIt(a, new B'());

Wieder verschwindet B und die Abstraktion bewegt sich in das fokussiertere X. Diesmal ist A jedoch noch einfacher, wenn Sie weniger wissen. Es ist noch weniger abstrakt.

Es ist wichtig, die Duplizierung in einer Codebasis zu reduzieren, aber wir müssen überlegen, warum die Duplizierung überhaupt auftritt. Duplizierung kann ein Zeichen für tiefere Abstraktionen sein, die versuchen, herauszukommen.

5
cbojar

Abstraktion durch Vererbung kann ziemlich hässlich werden. Parallele Klassenhierarchien mit typischen Fabriken. Refactoring kann zu Kopfschmerzen werden. Und auch spätere Entwicklung, der Ort, an dem Sie sich befinden.

Es gibt eine Alternative: Erweiterungspunkte mit strengen Abstraktionen und abgestuften Anpassungen. Sagen wir eine Anpassung von Regierungskunden, basierend auf dieser Anpassung für eine bestimmte Stadt.

Eine Warnung: Leider funktioniert dies am besten, wenn alle (oder die meisten) Klassen erweitert werden. Keine Option für Sie, vielleicht in kleinen.

Diese Erweiterbarkeit funktioniert, indem eine erweiterbare Objektbasisklasse Erweiterungen enthält:

void f(CreditorBO creditor) {
    creditor.as(AllowedCreditorBO.class).ifPresent(allowedCreditor -> ...);
}

Intern erfolgt eine verzögerte Zuordnung von Objekten zu erweiterten Objekten nach Erweiterungsklassen.

Für GUI-Klassen und -Komponenten die gleiche Erweiterbarkeit, teilweise mit Vererbung. Hinzufügen von Schaltflächen und dergleichen.

In Ihrem Fall sollte eine Validierung prüfen, ob sie erweitert ist, und sich anhand der Erweiterungen validieren. Das Einführen von Erweiterungspunkten nur für einen Fall fügt unverständlichen Code hinzu, der nicht gut ist.

Es gibt also keine Lösung, als zu versuchen, im aktuellen Kontext zu arbeiten.

0
Joop Eggen

'Hidden Flow Control' klingt für mich zu handgewellt.
Jedes Konstrukt oder Element, das aus dem Kontext genommen wurde, kann diese Eigenschaft haben.

Abstraktionen sind gut. Ich temperiere sie mit zwei Richtlinien:

  • Besser nicht zu früh abstrahieren. Warten Sie auf weitere Beispiele für Muster, bevor Sie abstrahieren. "Mehr" ist natürlich subjektiv und spezifisch für die schwierige Situation.

  • Vermeiden Sie zu viele Abstraktionsebenen, nur weil die Abstraktion gut ist. Ein Programmierer muss diese Ebenen im Kopf behalten, um neuen oder geänderten Code zu erhalten, wenn er die Codebasis auslotet und 12 Ebenen tief geht. Der Wunsch nach gut abstrahiertem Code kann zu so vielen Ebenen führen, dass es für viele Leute schwierig ist, ihnen zu folgen. Dies führt auch dazu, dass nur Ninja-Codebasen verwaltet werden.

In beiden Fällen sind "mehr" und "zu viele" keine festen Zahlen. Es hängt davon ab, ob. Das macht es schwer.

Ich mag auch diesen Artikel von Sandi Metz

https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstraction

Duplizierung ist weitaus billiger als die falsche Abstraktion
und
bevorzugen die Vervielfältigung gegenüber der falschen Abstraktion

0
Michael Durrant