Methodenextraktion gegen zugrunde liegende Annahmen

27

Wenn ich große Methoden (oder Prozeduren oder Funktionen ) in viele kleine aufspalte, ist diese Frage nicht OOP-spezifisch, aber da ich 99% der Zeit in OOP-Sprachen arbeite, ist es die Terminologie, mit der ich mich am wohlsten fühle Ich finde mich oft mit den Ergebnissen unzufrieden. Es wird schwieriger, über diese kleinen Methoden nachzudenken, als wenn sie nur Codeblöcke in der großen waren, da ich beim Extrahieren viele zugrunde liegende Annahmen verliere, die aus dem Kontext des Aufrufers stammen.

Wenn ich später diesen Code betrachte und einzelne Methoden sehe, weiß ich nicht sofort, woher sie aufgerufen werden, und stelle sie mir als gewöhnliche private Methoden vor, die von einer beliebigen Stelle in der Datei aus aufgerufen werden können. Stellen Sie sich zum Beispiel eine Initialisierungsmethode (Konstruktor oder auf andere Weise) vor, die in eine Reihe kleinerer Methoden aufgeteilt ist: Im Kontext der Methode selbst wissen Sie eindeutig, dass der Status des Objekts immer noch ungültig ist, aber bei einer normalen privaten Methode gehen Sie wahrscheinlich von der Annahme dieses Objekts aus ist bereits initialisiert und befindet sich in einem gültigen Zustand.

Die einzige Lösung, die ich dafür gesehen habe, ist die whereKlausel in Haskell, mit der Sie kleine Funktionen definieren können, die nur in der übergeordneten Funktion verwendet werden. Grundsätzlich sieht es so aus:

len x y = sqrt $ (sq x) + (sq y)
    where sq a = a * a

Aber andere Sprachen, die ich verwende, haben so etwas nicht - am nächsten kommt es darauf an, ein Lambda in einem lokalen Bereich zu definieren, was wahrscheinlich noch verwirrender ist.

Meine Frage lautet also: Stoßen Sie darauf und sehen Sie überhaupt, dass dies ein Problem ist? Wie lösen Sie das Problem normalerweise, insbesondere in "Mainstream" -OOP-Sprachen wie Java / C # / C ++?

Bearbeiten von Duplikaten: Wie andere bemerkt haben, gibt es bereits Fragen zu Aufteilungsmethoden und kleinen Fragen, die einzeilig sind. Ich habe sie gelesen und sie diskutieren nicht das Problem der zugrunde liegenden Annahmen , die aus dem Kontext des Aufrufers abgeleitet werden können (im obigen Beispiel wird das Objekt initialisiert). Das ist der Punkt meiner Frage, und deshalb ist meine Frage anders.

Update: Wenn Sie dieser Frage und der folgenden Diskussion gefolgt sind, könnte Ihnen dieser Artikel von John Carmack zu diesem Thema gefallen , insbesondere:

Inlining-Funktionen kennen nicht nur den Code, der gerade ausgeführt wird, sondern haben auch den Vorteil, dass sie nicht von anderen Stellen aus aufgerufen werden können. Das klingt lächerlich, hat aber einen Sinn. Wenn eine Codebasis im Laufe der Jahre wächst, gibt es viele Möglichkeiten, eine Verknüpfung zu erstellen und eine Funktion aufzurufen, die nur die Arbeit leistet, die Ihrer Meinung nach erledigt werden muss. Möglicherweise gibt es eine FullUpdate () - Funktion, die PartialUpdateA () und PartialUpdateB () aufruft. In bestimmten Fällen stellen Sie jedoch möglicherweise fest (oder denken), dass Sie nur PartialUpdateB () ausführen müssen, und Sie sind effizient, indem Sie die andere Funktion vermeiden Arbeit. Daraus resultieren viele, viele Bugs. Die meisten Fehler sind darauf zurückzuführen, dass der Ausführungsstatus nicht genau dem entspricht, was Sie denken.

Max Yankov
quelle
@gnat die Frage, die Sie verlinkt haben, diskutiert, ob Funktionen überhaupt extrahiert werden sollen oder nicht, während ich es nicht in Frage stelle. Stattdessen frage ich die optimale Methode, um es zu tun.
Max Yankov
2
@gnat es gibt andere verwandte Fragen, die von dort verlinkt sind, aber keine dieser Fragen diskutiert die Tatsache, dass dieser Code auf bestimmten Annahmen beruhen kann, die nur im Kontext des Aufrufers gültig sind.
Max Yankov
1
@Doval nach meiner Erfahrung tut es wirklich. Wenn es mühsam Hilfsmethoden sind rumhängen wie Sie beschreiben, eine neue extrazusammenhängende Klasse kümmert sich um diese
gnat

Antworten:

29

Stellen Sie sich zum Beispiel eine Initialisierungsmethode vor, die in eine Reihe kleinerer Methoden aufgeteilt ist: Im Kontext der Methode selbst wissen Sie eindeutig, dass der Status des Objekts immer noch ungültig ist, aber bei einer normalen privaten Methode gehen Sie wahrscheinlich von der Annahme aus, dass das Objekt bereits initialisiert ist und dies auch ist in einem gültigen Zustand. Die einzige Lösung, die ich dafür gesehen habe, ist ...

Ihr Anliegen ist begründet. Es gibt noch eine andere Lösung.

Geh einen Schritt zurück. Was ist der grundsätzliche Zweck einer Methode? Methoden machen nur eines von zwei Dingen:

  • Wert erzeugen
  • Verursacht eine Wirkung

Oder leider beides. Ich versuche, Methoden zu vermeiden, die beides tun, aber es gibt viele. Angenommen, der erzeugte Effekt oder der erzeugte Wert ist das "Ergebnis" der Methode.

Sie stellen fest, dass Methoden in einem "Kontext" aufgerufen werden. Was ist das für ein Kontext?

  • Die Werte der Argumente
  • Der Status des Programms außerhalb der Methode

Sie weisen im Wesentlichen darauf hin, dass die Richtigkeit des Ergebnisses der Methode von dem Kontext abhängt, in dem sie aufgerufen wird .

Wir bezeichnen die Bedingungen, die erforderlich sind, bevor ein Methodenkörper beginnt, damit die Methode ein korrektes Ergebnis erzeugt, als seine Vorbedingungen , und wir bezeichnen die Bedingungen, die erzeugt werden, nachdem der Methodenkörper seine Nachbedingungen zurückgibt .

Im Wesentlichen weisen Sie also darauf hin, dass ich beim Extrahieren eines Codeblocks in eine eigene Methode Kontextinformationen zu den Vor- und Nachbedingungen verliere .

Die Lösung für dieses Problem besteht darin , die Vor- und Nachbedingungen im Programm explizit anzugeben . In C # können Sie beispielsweise Debug.Assertoder Code Contracts verwenden, um Vor- und Nachbedingungen auszudrücken.

Zum Beispiel: Ich habe an einem Compiler gearbeitet, der mehrere "Phasen" der Kompilierung durchlief. Zuerst würde der Code lexiert, dann analysiert, dann würden Typen aufgelöst, dann würden Vererbungshierarchien auf Zyklen überprüft und so weiter. Jeder Teil des Codes war sehr kontextsensitiv. Es wäre zum Beispiel katastrophal, zu fragen, ob dieser Typ in diesen Typ konvertierbar ist. wenn die graphische Darstellung der Basistypen noch nicht als azyklisch bekannt war! Daher hat jedes Codebit seine Voraussetzungen klar dokumentiert. Wir würden assertin der Methode, die die Konvertierbarkeit von Typen überprüfte, feststellen, dass wir die Prüfung "Basistypen acylic" bereits bestanden hatten, und dem Leser wurde dann klar, wo die Methode aufgerufen werden konnte und wo sie nicht aufgerufen werden konnte.

Natürlich gibt es viele Möglichkeiten, wie gutes Methodendesign das von Ihnen identifizierte Problem lindert:

  • stellen Sie Methoden her, die für ihre Wirkung oder ihren Wert nützlich sind, aber nicht für beide
  • Methoden machen, die so "rein" wie möglich sind; Eine "reine" Methode erzeugt einen Wert, der nur von ihren Argumenten abhängt und keine Auswirkung hat. Dies sind die am einfachsten zu überlegenden Methoden, da der "Kontext", den sie benötigen, sehr lokalisiert ist.
  • Minimieren Sie das Ausmaß der Mutation, die im Programmstatus auftritt. Mutationen sind Punkte, an denen es schwieriger wird, über Code nachzudenken
Eric Lippert
quelle
+1 für die Antwort, die das Problem in Bezug auf Vorbedingungen / Nachbedingungen erklärt.
QuestionC
5
Ich würde hinzufügen, dass es oft möglich (und eine gute Idee!) Ist, die Prüfung vor und nach Bedingungen an das Typsystem zu delegieren. Wenn Sie eine Funktion haben, die eine übernimmt stringund diese in der Datenbank speichert, besteht das Risiko einer SQL-Injection, wenn Sie vergessen, diese zu bereinigen. Wenn Ihre Funktion dagegen a verwendet SanitisedStringund Sie nur SantisiedStringdurch Aufrufen a erhalten Sanitise, haben Sie SQL-Injection-Bugs konstruktionsbedingt ausgeschlossen. Ich bin zunehmend auf der Suche nach Möglichkeiten, wie der Compiler falschen Code ablehnen kann.
Benjamin Hodgson
+1 Eine wichtige Sache ist, dass die Aufteilung einer großen Methode in kleinere Teile mit Kosten verbunden ist: Sie ist in der Regel nur dann sinnvoll, wenn die Vor- und Nachbedingungen entspannter sind als ursprünglich, und Sie müssen dies möglicherweise tun Zahlen Sie die Kosten, indem Sie Schecks wiederholen, die Sie sonst schon gemacht hätten. Es ist kein völlig "kostenloser" Refactoring-Prozess.
Mehrdad
"Was ist das für ein Kontext?" Um das zu verdeutlichen, habe ich hauptsächlich den privaten Status des Objekts gemeint, auf das diese Methode angewendet wird. Ich denke, es ist in der zweiten Kategorie enthalten.
Max Yankov
Dies ist eine ausgezeichnete und zum Nachdenken anregende Antwort, danke. (Um nicht zu sagen, dass andere Antworten natürlich in irgendeiner Weise schlecht sind). Ich werde die Frage noch nicht als beantwortet markieren, da mir die Diskussion hier sehr gut gefällt (und sie tendenziell aufhört, wenn die Antwort als beantwortet markiert ist) und Zeit benötigt, um sie zu verarbeiten und darüber nachzudenken.
Max Yankov
13

Ich sehe das oft und stimme zu, dass es ein Problem ist. Normalerweise löse ich das Problem, indem ich ein Methodenobjekt erstelle : eine neue spezialisierte Klasse, deren Mitglieder die lokalen Variablen der ursprünglichen, zu großen Methode sind.

Die neue Klasse hat in der Regel einen Namen wie "Exporter" oder "Tabulation", und es werden alle erforderlichen Informationen aus dem größeren Kontext übergeben, um diese bestimmte Aufgabe auszuführen. Dann ist es kostenlos noch kleinen Helfer Code - Schnipsel zu definieren , die nicht in Gefahr sind für alles verwendet werden , aber Tabelliermaschine oder exportieren.

Kilian Foth
quelle
Ich mag diese Idee wirklich, je mehr ich darüber nachdenke. Es kann eine private Klasse innerhalb der öffentlichen oder internen Klasse sein. Sie überladen Ihren Namespace nicht mit Klassen, die Ihnen nur lokal wichtig sind, und dies ist eine Möglichkeit zu kennzeichnen, dass dies "Konstruktor-Helfer" oder "Parser-Helfer" oder was auch immer sind.
Mike unterstützt Monica am
Vor kurzem befand ich mich gerade in einer Situation, die aus architektonischer Sicht dafür ideal wäre. Ich habe einen Software-Renderer mit einer Renderer-Klasse und einer öffentlichen Render-Methode geschrieben, die eine Menge Kontext hatte, den sie zum Aufrufen anderer Methoden verwendete. Ich habe darüber nachgedacht, eine separate RenderContext-Klasse dafür zu erstellen. Es schien jedoch nur eine enorme Verschwendung zu sein, dieses Projekt jedem Frame zuzuweisen und die Zuordnung aufzuheben. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov
6

In vielen Sprachen können Sie Funktionen wie Haskell verschachteln. Java / C # / C ++ sind in dieser Hinsicht tatsächlich relative Ausreißer. Leider sind sie so beliebt, dass die Leute denken: "Es muss eine schlechte Idee sein, sonst würde meine Lieblingssprache das zulassen."

Java / C # / C ++ ist der Meinung, dass eine Klasse die einzige Gruppierung von Methoden sein sollte, die Sie jemals benötigen. Wenn Sie über so viele Methoden verfügen, dass Sie deren Kontext nicht bestimmen können, gibt es zwei allgemeine Ansätze: Sortieren nach Kontext oder Aufteilen nach Kontext.

Das Sortieren nach Kontext ist eine Empfehlung von Clean Code , bei der der Autor ein Muster von "TO-Absätzen" beschreibt. Das bedeutet, dass Sie Ihre Hilfsfunktionen direkt nach der Funktion platzieren, die sie aufruft, sodass Sie sie wie Absätze in einem Zeitungsartikel lesen können und je weiter Sie lesen, desto mehr Details erhalten Sie. Ich denke in seinen Videos hat er sie sogar eingerückt.

Der andere Ansatz besteht darin, Ihre Klassen aufzuteilen. Dies kann nicht sehr weit gediehen werden, da es ärgerlich ist, Objekte zu instanziieren, bevor Sie Methoden für sie aufrufen können, und Probleme bei der Entscheidung, welche von mehreren winzigen Klassen die einzelnen Daten besitzen soll. Wenn Sie jedoch bereits mehrere Methoden identifiziert haben, die wirklich nur in einen Kontext passen, sind sie wahrscheinlich ein guter Kandidat für die Einstufung in eine eigene Klasse. Beispielsweise kann eine komplexe Initialisierung in einem Erstellungsmuster wie dem Builder erfolgen.

Karl Bielefeldt
quelle
Verschachtelungsfunktionen ... erreichen Lambda-Funktionen nicht in C # (und Java 8)?
Arturo Torres Sánchez
Ich dachte eher an einen Abschluss, der mit einem Namen definiert wurde, wie diese Python-Beispiele . Lambdas sind nicht der klarste Weg, so etwas zu tun. Sie sind eher für kurze Ausdrücke wie ein Filterprädikat.
Karl Bielefeldt
Diese Python-Beispiele sind in C # durchaus möglich. Zum Beispiel die Fakultät . Sie mögen ausführlicher sein, sind aber zu 100% möglich.
Arturo Torres Sánchez
2
Niemand sagte, es sei nicht möglich. Das OP erwähnte sogar die Verwendung von Lambdas in seiner Frage. Es ist nur so, dass wenn Sie eine Methode aus Gründen der Lesbarkeit extrahieren, es schön wäre, wenn sie besser lesbar wäre.
Karl Bielefeldt
Dein erster Absatz scheint zu implizieren, dass es nicht möglich ist, besonders mit deinem Zitat: "Es muss eine schlechte Idee sein, sonst würde es meine Lieblingssprache" Mainstream "erlauben."
Arturo Torres Sánchez
4

Ich denke, die Antwort ist in den meisten Fällen der Kontext. Als Entwickler, der Code schreibt, sollten Sie davon ausgehen, dass sich Ihr Code in Zukunft ändern wird. Eine Klasse kann in eine andere Klasse integriert werden, den internen Algorithmus ersetzen oder in mehrere Klassen aufgeteilt werden, um eine Abstraktion zu erstellen. Dies sind Dinge, die Anfängerentwickler normalerweise nicht berücksichtigen, was zu unübersichtlichen Umgehungen oder späteren Überholungen führen kann.

Extraktionsmethoden sind gut, aber bis zu einem gewissen Grad. Ich versuche immer, mir diese Fragen zu stellen, wenn ich den Code prüfe oder bevor ich ihn schreibe:

  • Wird dieser Code nur von dieser Klasse / Funktion verwendet? Bleibt es auch in Zukunft so?
  • Wenn ich einen Teil der konkreten Implementierung austauschen muss, kann ich das problemlos tun?
  • Können andere Entwickler in meinem Team verstehen, was in dieser Funktion gemacht wird?
  • Wird derselbe Code in dieser Klasse an einer anderen Stelle verwendet? Sie sollten in fast allen Fällen Doppelarbeit vermeiden.

Denken Sie auf jeden Fall immer nur an eine Verantwortung. Eine Klasse sollte eine Verantwortung haben, ihre Funktionen sollten einem einzigen konstanten Dienst dienen, und wenn sie eine Reihe von Aktionen ausführen, sollten diese Aktionen ihre eigenen Funktionen haben, damit sie später leicht unterschieden oder geändert werden können.

Tomer Blu
quelle
1

Es wird schwieriger, über diese kleinen Methoden nachzudenken, als wenn sie nur Codeblöcke in der großen waren, da ich beim Extrahieren viele zugrunde liegende Annahmen verliere, die aus dem Kontext des Aufrufers stammen.

Ich wusste nicht, wie groß das Problem war, bis ich ein ECS einführte, das größere, schleifenförmige Systemfunktionen (wobei nur Systeme Funktionen haben) und Abhängigkeiten zu Rohdaten und nicht zu Abstraktionen ermutigte .

Dies führte zu meiner Überraschung zu einer Codebasis, die im Vergleich zu den Codebasen, in denen ich in der Vergangenheit gearbeitet habe, viel einfacher zu überlegen und zu pflegen war. Während des Debuggens musste man alle möglichen kleinen Funktionen nachverfolgen, häufig durch abstrakte Funktionsaufrufe Reine Schnittstellen, die dazu führen, wer weiß, wohin, bis Sie hineinspüren, nur um eine Kaskade von Ereignissen auszulösen, die zu Stellen führen, an denen Sie nie gedacht hätten, dass der Code jemals führen sollte.

Im Gegensatz zu John Carmack bestand mein größtes Problem mit diesen Codebasen nicht in der Leistung, da ich nie die extrem hohe Latenz der AAA-Spiele-Engines hatte und die meisten unserer Leistungsprobleme eher mit dem Durchsatz zu tun hatten. Natürlich können Sie es auch immer schwieriger machen, Hotspots zu optimieren, wenn Sie in immer engeren Grenzen von Funktionen und Klassen für Jugendliche arbeiten, ohne dass diese Struktur im Weg steht (und Sie alle diese kleinen Teile wieder zusammenführen müssen) zu etwas Größerem, bevor man überhaupt anfangen kann, es effektiv in Angriff zu nehmen).

Das größte Problem für mich war jedoch, dass ich trotz aller bestandenen Tests nicht sicher über die allgemeine Korrektheit des Systems nachdenken konnte. Es gab zu viel für mein Gehirn und für mein Verständnis, weil diese Art von System es Ihnen nicht erlaubte, darüber nachzudenken, ohne all diese winzigen Details und endlosen Wechselwirkungen zwischen winzigen Funktionen und Objekten zu berücksichtigen, die sich überall abspielten. Es gab zu viele "Was wäre wenn?", Zu viele Dinge, die zur richtigen Zeit aufgerufen werden mussten, zu viele Fragen darüber, was passieren würde, wenn sie zur falschen Zeit aufgerufen würden (die sich zu einem Punkt der Paranoia entwickeln, wenn Sie Lassen Sie ein Ereignis ein anderes auslösen, das ein anderes auslöst und Sie zu allen möglichen unvorhersehbaren Orten führt.

Jetzt mag ich meine 80-Zeilen-Funktionen mit großem Arsch hier und da, solange sie noch eine singuläre und klare Verantwortung erfüllen und keine 8 Ebenen verschachtelter Blöcke haben. Sie führen zu dem Gefühl, dass weniger Dinge im System zu testen und zu verstehen sind, auch wenn die kleineren, in Stücke geschnittenen Versionen dieser größeren Funktionen nur private Implementierungsdetails waren, die von niemand anderem aufgerufen werden konnten ... Es hat die Tendenz, dass im gesamten System weniger Interaktionen stattfinden. Ich mag sogar eine sehr bescheidene Code-Vervielfältigung, solange es keine komplexe Logik ist (sagen wir nur 2-3 Codezeilen), wenn es weniger Funktionen bedeutet. Ich mag Carmacks Argumentation über Inlining, die es unmöglich macht, diese Funktionalität an anderer Stelle in der Quelldatei aufzurufen. Dort'

Die Einfachheit reduziert nicht immer die Komplexität auf der Gesamtbildebene, wenn die Option zwischen einer fleischigen Funktion und zwölf einfachen Funktionen besteht, die sich mit einem komplexen Abhängigkeitsdiagramm gegenseitig aufrufen. Letztendlich muss man oft überlegen, was über eine Funktion hinausgeht, was diese Funktionen letztendlich bewirken, und es kann schwieriger sein, das Gesamtbild zu erkennen, wenn man es aus dem ableiten muss kleinste Puzzleteile.

Natürlich kann ein sehr allgemein verwendbarer Bibliothekstypcode, der gut getestet wurde, von dieser Regel ausgenommen werden, da ein solcher allgemein verwendbarer Code häufig funktioniert und für sich allein steht. Außerdem ist es im Vergleich zu dem Code, der der Domäne Ihrer Anwendung etwas näher kommt (Tausende von Codezeilen, nicht Millionen), in der Regel sehr klein und so weit verbreitet, dass es Teil des täglichen Vokabulars wird. Aber mit etwas Spezifischerem für Ihre Anwendung, bei dem die systemweiten Invarianten, die Sie pflegen müssen, weit über eine einzelne Funktion oder Klasse hinausgehen, ist es meiner Ansicht nach aus irgendeinem Grund hilfreich, fleischigere Funktionen zu haben. Ich finde es viel einfacher, mit größeren Puzzleteilen zu arbeiten, um herauszufinden, was mit dem Gesamtbild los ist.


quelle
0

Ich denke nicht, dass es ein großes Problem ist, aber ich stimme zu, dass es problematisch ist. Normalerweise platziere ich den Helfer direkt nach dem Begünstigten und füge ein "Helfer" -Suffix hinzu. Das plus der privateZugriffsspezifizierer sollte seine Rolle klar machen. Wenn es eine Invariante gibt, die nicht gilt, wenn der Helfer aufgerufen wird, füge ich dem Helfer einen Kommentar hinzu.

Diese Lösung hat den unglücklichen Nachteil, dass der Umfang der unterstützten Funktion nicht erfasst wird. Idealerweise sind Ihre Funktionen klein, sodass hoffentlich nicht zu viele Parameter erforderlich sind. Normalerweise lösen Sie dies, indem Sie neue Strukturen oder Klassen definieren, um die Parameter zu bündeln. Die dafür erforderliche Menge an Boilerplate kann jedoch länger sein als der Helfer selbst. Dann sind Sie wieder da, wo Sie mit keiner offensichtlichen Art der Zuordnung begonnen haben die Struktur mit der Funktion.

Sie haben bereits die andere Lösung erwähnt - definieren Sie den Helfer innerhalb der Hauptfunktion. Es mag in einigen Sprachen eine etwas ungewöhnliche Redewendung sein, aber ich glaube nicht, dass es verwirrend wäre (es sei denn, Ihre Kollegen werden von Lambdas im Allgemeinen verwirrt). Dies funktioniert jedoch nur, wenn Sie Funktionen oder funktionsähnliche Objekte einfach definieren können. Ich würde dies beispielsweise in Java 7 nicht versuchen, da eine anonyme Klasse die Einführung von 2 Verschachtelungsebenen für selbst die kleinste "Funktion" erfordert. Dies kommt einer letoder where-Klausel so nahe wie möglich. Sie können vor der Definition auf lokale Variablen verweisen, und der Helfer kann außerhalb dieses Bereichs nicht verwendet werden.

Doval
quelle