Ich grabe seit über zwei Jahren Tag und Nacht in OOP. Lesen Sie einen dicken Stapel Bücher, verbringen Sie Monate damit, Code von prozedural zu objektorientiert und wieder zurück zu überarbeiten. Ein Freund sagt, dass ich OOP des Gehirns verdient habe. Aber bin ich zuversichtlich, dass ich komplexe Probleme lösen und klaren Code schreiben kann?
Ich beneide Leute, die ihre wahnhafte Meinung zuversichtlich vertreten können. Besonders wenn es um Entwicklung geht, Architektur. Im Allgemeinen, was ich leidenschaftlich anstrebe, aber woran ich endlose Zweifel habe. Weil ich kein Genie und kein FP bin, habe ich keine Erfolgsgeschichte. Aber lassen Sie mich 5 Kopeken einsetzen.
Verkapselung, Polymorphismus, Objektdenken ...?
Gefällt es dir, wenn du mit Begriffen geladen bist? Ich habe genug gelesen, aber die obigen Worte sagen mir immer noch nichts Besonderes. Ich bin es gewohnt, Dinge in einer Sprache zu erklären, die ich verstehe. Eine Abstraktionsebene, wenn Sie möchten. Und ich wollte schon lange die Antwort auf eine einfache Frage wissen: "Was gibt OOP?" Am besten mit Codebeispielen. Und heute werde ich versuchen, es selbst zu beantworten. Aber zuerst eine kleine Abstraktion.
Komplexität der Aufgabe
Der Entwickler ist auf die eine oder andere Weise damit beschäftigt, Probleme zu lösen. Jede Aufgabe hat viele Details. Ausgehend von den Besonderheiten der API für die Interaktion mit dem Computer bis hin zu den Details der Geschäftslogik.
Neulich habe ich mit meiner Tochter ein Mosaik gesammelt. Früher haben wir große Puzzles gesammelt, buchstäblich aus 9 Teilen. Und jetzt kann sie kleine Mosaike für Kinder ab 3 Jahren verarbeiten. Das ist interessant! Wie das Gehirn seinen Platz unter den verstreuten Rätseln findet. Und was bestimmt die Komplexität?
Nach den Mosaiken für Kinder zu urteilen, wird die Komplexität hauptsächlich durch die Anzahl der Details bestimmt. Ich bin mir nicht sicher, ob die Puzzle-Analogie den gesamten Entwicklungsprozess abdeckt. Aber was können Sie noch mit der Geburt eines Algorithmus zum Zeitpunkt des Schreibens eines Funktionskörpers vergleichen? Und es scheint mir, dass die Reduzierung der Detailgenauigkeit eine der wichtigsten Vereinfachungen ist.
Um das Hauptmerkmal von OOP klarer darzustellen, sprechen wir über Aufgaben, deren Anzahl es uns nicht ermöglicht, ein Puzzle in angemessener Zeit zusammenzusetzen. In solchen Fällen brauchen wir eine Zersetzung.
Zersetzung
Wie Sie aus der Schule wissen, kann ein komplexes Problem in einfachere Probleme zerlegt werden, um sie separat zu lösen. Der Kern des Ansatzes besteht darin, die Anzahl der Teile zu begrenzen.
Es kommt einfach so vor, dass wir uns beim Programmierenlernen daran gewöhnen, mit einem prozeduralen Ansatz zu arbeiten. Wenn sich am Eingang ein Datenelement befindet, das wir transformieren, werfen wir es in Unterfunktionen und ordnen es dem Ergebnis zu. Letztendlich führen wir die Zerlegung während des Refactorings durch, wenn die Lösung bereits vorhanden ist.
Was ist das Problem bei der prozeduralen Zerlegung? Aus Gewohnheit benötigen wir Anfangsdaten und vorzugsweise mit einer endgültig gebildeten Struktur. Je größer die Aufgabe, desto komplexer die Struktur dieser Anfangsdaten, desto mehr Details müssen Sie berücksichtigen. Aber wie kann man sicher sein, dass genügend Anfangsdaten vorhanden sind, um Unteraufgaben zu lösen und gleichzeitig die Summe aller Details auf der obersten Ebene zu entfernen?
Schauen wir uns ein Beispiel an. Vor nicht allzu langer Zeit habe ich ein Skript geschrieben, das Baugruppen von Projekten erstellt und diese in die erforderlichen Ordner wirft.
interface BuildConfig {
id: string;
deployPath: string;
options: BuildOptions;
// ...
}
interface TestService {
runTests(buildConfigs: BuildConfig[]): Promise<void>;
}
interface DeployService {
publish(buildConfigs: BuildConfig[]): Promise<void>;
}
class Builder {
constructor(
private testService: TestService,
private deployService: DeployService
) // ...
{}
async build(buildConfigs: BuildConfig[]): Promise<void> {
await this.testService.runTests(buildConfigs);
await this.build(buildConfigs);
await this.deployService.publish(buildConfigs);
// ...
}
// ...
}
Es scheint, als hätte ich OOP in dieser Lösung angewendet. Sie können Service-Implementierungen ersetzen und sogar etwas testen. Tatsächlich ist dies jedoch ein Paradebeispiel für einen prozeduralen Ansatz.
Schauen Sie sich die BuildConfig-Oberfläche an. Dies ist eine Struktur, die ich zu Beginn des Schreibens des Codes erstellt habe. Ich erkannte im Voraus, dass ich nicht alle Parameter im Voraus vorhersehen konnte, und fügte dieser Struktur bei Bedarf einfach Felder hinzu. In der Mitte der Arbeit war die Konfiguration mit einer Reihe von Feldern überwachsen, die in verschiedenen Teilen des Systems verwendet wurden. Ich war verärgert über das Vorhandensein eines "Objekts", das bei jeder Änderung fertiggestellt werden muss. Es ist schwierig, darin zu navigieren, und es ist leicht, etwas zu beschädigen, indem man die Namen der Felder verwechselt. Dennoch hängen alle Teile des Build-Systems von BuildConfig ab. Da diese Aufgabe nicht so umfangreich und kritisch ist, gab es keine Katastrophe. Aber es ist klar, dass ich das Projekt vermasselt hätte, wenn das System komplizierter gewesen wäre.
Ein Objekt
Das Hauptproblem des prozeduralen Ansatzes sind Daten, ihre Struktur und Menge. Die komplexe Datenstruktur führt Details ein, die das Verständnis der Aufgabe erschweren. Jetzt pass auf deine Hände auf, hier gibt es keine Täuschung.
Denken wir daran, warum brauchen wir Daten? Operationen an ihnen ausführen und das Ergebnis erhalten. Oft wissen wir, welche Unteraufgaben gelöst werden müssen, verstehen aber nicht, welche Art von Daten dafür benötigt werden.
Beachtung! Wir können Operationen manipulieren, indem wir wissen, dass sie die Daten im Voraus besitzen, um sie auszuführen.
Mit dem Objekt können Sie ein Dataset durch eine Reihe von Operationen ersetzen. Und wenn es die Anzahl der Teile reduziert, vereinfacht es einen Teil der Aufgabe!
// , /
interface BuildConfig {
id: string;
deployPath: string;
options: BuildOptions;
// ...
}
// vs
// ,
interface Project {
test(): Promise<void>;
build(): Promise<void>;
publish(): Promise<void>;
}
Die Transformation ist sehr einfach: f (x) -> von (), wobei o kleiner als x ist . Die Sekundärseite versteckte sich im Objekt. Wie wirkt es sich aus, wenn der Code mit der Konfiguration von einem Ort an einen anderen übertragen wird? Diese Transformation hat jedoch weitreichende Auswirkungen. Wir können den gleichen Trick für den Rest des Programms machen.
// project.ts
// , Project .
class Project {
constructor(
private buildTester: BuildTester,
private builder: Builder,
private buildPublisher: BuildPublisher
) {}
async test(): Promise<void> {
await this.buildTester.runTests();
}
async build(): Promise<void> {
await this.builder.build();
}
async publish(): Promise<void> {
await this.buildPublisher.publish();
}
}
// builder.ts
export interface BuildOptions {
baseHref: string;
outputPath: string;
configuration?: string;
}
export class Builder {
constructor(private options: BuildOptions) {}
async build(): Promise<void> {
// ...
}
}
Jetzt empfängt der Builder genau wie andere Teile des Systems nur die Daten, die er benötigt. Gleichzeitig hängen die Klassen, die den Builder über den Konstruktor erhalten, nicht von den Parametern ab, die zum Initialisieren erforderlich sind. Wenn die Details vorhanden sind, ist es einfacher, das Programm zu verstehen. Es gibt aber auch eine Schwachstelle.
export interface ProjectParams {
id: string;
deployPath: Path | string;
configuration?: string;
buildRelevance?: BuildRelevance;
}
const distDir = new Directory(Path.fromRoot("dist"));
const buildRecordsDir = new Directory(Path.fromRoot("tmp/builds-manifest"));
export function createProject(params: ProjectParams): Project {
return new ProjectFactory(params).create();
}
class ProjectFactory {
private buildDir: Directory = distDir.getSubDir(this.params.id);
private deployDir: Directory = new Directory(
Path.from(this.params.deployPath)
);
constructor(private params: ProjectParams) {}
create(): Project {
const builder = this.createBuilder();
const buildPublisher = this.createPublisher();
return new Project(this.params.id, builder, buildPublisher);
}
private createBuilder(): NgBuilder {
return new NgBuilder({
baseHref: "/clientapp/",
outputPath: this.buildDir.path.toAbsolute(),
configuration: this.params.configuration,
});
}
private createPublisher(): BuildPublisher {
const buildHistory = this.getBuildsHistory();
return new BuildPublisher(this.buildDir, this.deployDir, buildHistory);
}
private getBuildsHistory(): BuildsHistory {
const buildRecordsFile = this.getBuildRecordsFile();
const buildRelevance = this.params.buildRelevance ?? BuildRelevance.Default;
return new BuildsHistory(buildRecordsFile, buildRelevance);
}
private getBuildRecordsFile(): BuildRecordsFile {
const buildRecordsPath = buildRecordsDir.path.join(
`${this.params.id}.json`
);
return new BuildRecordsFile(buildRecordsPath);
}
}
Alle Details, die mit der komplexen Struktur der ursprünglichen Konfiguration verbunden sind, wurden in den Prozess des Erstellens des Projektobjekts und seiner Abhängigkeiten einbezogen. Sie müssen für alles bezahlen. Aber manchmal ist dies ein lukratives Angebot - kleinere Teile im gesamten Modul loszuwerden und sie in einer Fabrik zu konzentrieren.
Somit ermöglicht OOP das Ausblenden von Details und das Verschieben dieser Details zum Zeitpunkt der Objekterstellung. Aus gestalterischer Sicht ist dies eine Supermacht - die Fähigkeit, unnötige Details loszuwerden. Dies ist sinnvoll, wenn die Summe der Details in der Objektschnittstelle geringer ist als in der Struktur, die es kapselt. Und wenn Sie die Erstellung des Objekts und seine Verwendung in den meisten Systemen trennen können.
FEST, Abstraktion, Kapselung ...
Es gibt Unmengen von Büchern über OOP. Sie führen eingehende Studien durch, die die Erfahrung des Schreibens objektorientierter Programme widerspiegeln. Meine Sicht der Entwicklung wurde jedoch durch die Erkenntnis auf den Kopf gestellt, dass OOP Code hauptsächlich durch Einschränkung von Details vereinfacht. Und ich werde polar sein ... aber wenn Sie Details mit Objekten nicht loswerden, verwenden Sie OOP nicht.
Sie können versuchen, SOLID einzuhalten, aber es macht wenig Sinn, wenn Sie keine kleinen Details versteckt haben. Es ist möglich, Schnittstellen wie Objekte in der realen Welt aussehen zu lassen, aber das macht wenig Sinn, wenn Sie die kleinen Details nicht versteckt haben. Sie können die Semantik verbessern, indem Sie Substantive in Ihrem Code verwenden, aber ... Sie haben die Idee.
Ich finde, dass SOLID, Muster und andere Richtlinien zum Schreiben von Objekten ausgezeichnete Richtlinien für das Refactoring sind. Nach Abschluss des Puzzles können Sie das gesamte Bild sehen und die einfacheren Teile hervorheben. Im Allgemeinen sind dies wichtige Tools und Metriken, die Aufmerksamkeit erfordern. Oft lernen Entwickler sie jedoch weiter und verwenden sie, bevor sie das Programm in die Objektform konvertieren.
Wenn du die Wahrheit kennst
OOP ist ein Werkzeug zur Lösung komplexer Probleme. Schwierige Aufgaben werden gewonnen, indem sie durch Einschränkung der Details in einfache Aufgaben unterteilt werden. Eine Möglichkeit, die Anzahl der Teile zu verringern, besteht darin, die Daten durch eine Reihe von Vorgängen zu ersetzen.
Versuchen Sie nun, da Sie die Wahrheit kennen, das Unnötige in Ihrem Projekt loszuwerden. Ordnen Sie die resultierenden Objekte SOLID zu. Versuchen Sie dann, sie zu Objekten in der realen Welt zu bringen. Nicht umgekehrt. Die Hauptsache liegt im Detail.
Kürzlich wurde eine VSCode-Erweiterung für das Refactoring der Extract-Klasse geschrieben . Ich denke, dies ist ein gutes Beispiel für objektorientierten Code. Das Beste was ich habe. Ich würde mich über Kommentare zur Implementierung oder Vorschläge zur Verbesserung des Codes / der Funktionalität freuen. Ich möchte in naher Zukunft eine PR in Abrakadabra herausgeben