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 where
Klausel 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.
Antworten:
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:
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?
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.Assert
oder 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
assert
in 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:
quelle
string
und diese in der Datenbank speichert, besteht das Risiko einer SQL-Injection, wenn Sie vergessen, diese zu bereinigen. Wenn Ihre Funktion dagegen a verwendetSanitisedString
und Sie nurSantisiedString
durch Aufrufen a erhaltenSanitise
, haben Sie SQL-Injection-Bugs konstruktionsbedingt ausgeschlossen. Ich bin zunehmend auf der Suche nach Möglichkeiten, wie der Compiler falschen Code ablehnen kann.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.
quelle
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.
quelle
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:
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.
quelle
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
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
private
Zugriffsspezifizierer 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
let
oderwhere
-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.quelle