Kapitel 1. Die Kunst des Softwaredesigns
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Was ist Software-Design? Und warum solltest du dich dafür interessieren? In diesem Kapitel lege ich den Grundstein für dieses Buch über Softwaredesign. Ich erkläre dir, warum Softwaredesign so wichtig für den Erfolg eines Projekts ist und warum du es unbedingt richtig machen solltest. Aber du wirst auch sehen, dass Softwaredesign kompliziert ist. Sehr kompliziert sogar, denn es ist der komplizierteste Teil der Softwareentwicklung. Deshalb erkläre ich dir auch einige Grundsätze des Softwaredesigns, die dir helfen werden, auf dem richtigen Weg zu bleiben.
In "Leitlinie 1: Verstehe die Bedeutung desSoftwaredesigns" werde ich mich auf das große Ganze konzentrieren und erklären, dass sich Software verändern wird. Daher sollte Software in der Lage sein, mit Veränderungen umzugehen. Das ist jedoch viel leichter gesagt als getan, denn in der Realität machen Kopplung und Abhängigkeiten unser Leben als Entwickler so viel schwieriger. Dieses Problem wird durch Softwaredesign gelöst. Ich werde Softwaredesign als die Kunst des Managements von Abhängigkeiten und Abstraktionen vorstellen - ein wesentlicher Bestandteil der Softwareentwicklung.
In "Leitfaden 2: Design for Change" gehe ich explizit auf Kopplung und Abhängigkeiten ein und helfe dir zu verstehen, wie du für Veränderungen konzipieren kannst und wie du Software anpassungsfähiger machst. Zu diesem Zweck stelle ich dir das Single-Responsibility-Prinzip (SRP) und das DRY-Prinzip (Don't Repeat Yourself) vor, die dir dabei helfen, dieses Ziel zu erreichen.
In "Leitlinie 3: Separate Interfaces to AvoidArtificial Coupling" (Schnittstellen trennen, umkünstliche Kopplungzu vermeiden) werde ich die Diskussion über Kopplung erweitern und speziell auf die Kopplung über Schnittstellen eingehen. Außerdem werde ich das Interface Segregation Principle (ISP) als Mittel zur Reduzierung der künstlichen Kopplung durch Schnittstellen vorstellen.
In "Leitfaden 4: Design for Testability" werde ich mich auf Probleme der Testbarkeit konzentrieren, die sich aus der künstlichen Kopplung ergeben. Insbesondere werde ich die Frage aufwerfen, wie man eine private Mitgliedsfunktion testet, und zeigen, dass die einzig wahre Lösung eine konsequente Anwendung der Trennung von Belangen ist.
In "Leitfaden 5: Design for Extension" gehe ich auf eine wichtige Art der Veränderung ein: Erweiterungen. Genauso wie der Code leicht zu ändern sein sollte, sollte er auch leicht zu erweitern sein. Ich gebe dir eine Idee, wie du dieses Ziel erreichen kannst, und zeige dir den Wert des Open-Closed-Prinzips (OCP).
Leitlinie 1: Verstehe die Bedeutung desSoftwaredesigns
Wenn ich dich fragen würde, welche Code-Eigenschaften für dich am wichtigsten sind, würdest du nach einigem Nachdenken wahrscheinlich Dinge wie Lesbarkeit, Testbarkeit, Wartbarkeit, Erweiterbarkeit, Wiederverwendbarkeit und Skalierbarkeit sagen. Und ich würde dir vollkommen zustimmen. Aber wenn ich dich jetzt fragen würde, wie man diese Ziele erreichen kann, würdest du mit großer Wahrscheinlichkeit anfangen, einige C++-Features aufzuzählen: RAII, Algorithmen, Lambdas, Module und so weiter.
Funktionen sind kein Software-Design
Ja, C++ bietet eine Menge Funktionen. Eine ganze Menge! Ungefähr die Hälfte der fast 2.000 Seiten des gedruckten C++-Standards ist der Erklärung von Sprachmechanismen und Funktionen gewidmet.1 Und seit der Veröffentlichung von C++11 gibt es das ausdrückliche Versprechen, dass es noch mehr davon geben wird: Alle drei Jahre segnet uns das C++-Standardisierungskomitee mit einem neuenC++-Standard, der mit zusätzlichen, brandneuen Funktionen ausgeliefert wird. Angesichts dieser Tatsache ist es keine große Überraschung, dass in der C++-Gemeinschaft der Schwerpunkt sehr stark auf Funktionen und Sprachmechanik liegt. Die meisten Bücher, Vorträge und Blogs konzentrieren sich auf Funktionen, neue Bibliotheken und Sprachdetails.2
Es fühlt sich fast so an, als wären Features das Wichtigste bei der Programmierung inC++ und entscheidend für den Erfolg eines C++-Projekts. Aber ehrlich gesagt, sind sie das nicht. Weder das Wissen über alle Features noch die Wahl des C++ Standards sind für den Erfolg eines Projekts verantwortlich. Nein, du solltest nicht erwarten, dass Features dein Projekt retten. Im Gegenteil: Ein Projekt kann auch dann sehr erfolgreich sein, wenn es einen älteren C++-Standard verwendet und nur eine Teilmenge der verfügbaren Funktionen genutzt wird. Abgesehen von den menschlichen Aspekten der Softwareentwicklung ist für die Frage nach Erfolg oder Misserfolg eines Projekts die Gesamtstruktur der Software viel wichtiger. Es ist die Struktur, die letztendlich für die Wartbarkeit verantwortlich ist: Wie einfach ist es, Code zu ändern, zu erweitern und zu testen? Wenn es nicht möglich ist, den Code leicht zu ändern, neue Funktionen hinzuzufügen und sich auf seine Korrektheit aufgrund von Tests zu verlassen, ist ein Projekt am Ende seines Lebenszyklus angelangt. Die Struktur ist auch für die Skalierbarkeit eines Projekts verantwortlich: Wie groß kann das Projekt werden, bevor es unter seinem eigenen Gewicht zusammenbricht? Wie viele Menschen können an der Verwirklichung der Vision des Projekts arbeiten, bevor sie sich gegenseitig auf die Füße treten?
Die Gesamtstruktur ist das Design eines Projekts. Das Design spielt eine viel zentralere Rolle für den Erfolg eines Projekts als jede Funktion es je könnte. Bei guter Software geht es nicht in erster Linie um die richtige Nutzung einer Funktion, sondern um eine solide Architektur und ein gutes Design. Ein gutes Softwaredesign kann einige schlechte Implementierungsentscheidungen verkraften, aber ein schlechtes Softwaredesign kann nicht allein durch den heldenhaften Einsatz von (alten oder neuen) Funktionen gerettet werden.
Software Design: Die Kunst, Abhängigkeitenund Abstraktionen zu managen
Warum ist das Softwaredesign so wichtig für die Qualität eines Projekts? Wenn alles perfekt funktioniert, solange sich nichts an deiner Software ändert und solange nichts hinzugefügt werden muss, ist alles in Ordnung. Dieser Zustand wird aber wahrscheinlich nicht lange anhalten. Es ist vernünftig zu erwarten, dass sich etwas ändern wird. Schließlich ist die einzige Konstante in der Softwareentwicklung die Veränderung. Veränderung ist die treibende Kraft hinter all unseren Problemen (und auch hinter den meisten unserer Lösungen). Deshalb heißt Software auch Software: weil sie im Vergleich zu Hardware weich und formbar ist. Ja, von Softwarewird erwartet, dass sie sich leicht an die sich ständig ändernden Anforderungen anpassen lässt. Aber wie du vielleicht weißt, trifft diese Erwartung in der Realität nicht immer zu.
Um diesen Punkt zu veranschaulichen, stellen wir uns vor, du wählst ein Problem aus deinem Problemverfolgungssystem aus, das das Team mit einem erwarteten Aufwand von 2 bewertet hat. Was auch immer eine 2 in deinem eigenen Projekt bedeutet, es hört sich ganz sicher nicht nach einer großen Aufgabe an, also bist du zuversichtlich, dass dies schnell erledigt sein wird.
In gutem Glauben nimmst du dir zunächst etwas Zeit, um zu verstehen, was erwartet wird, und fängst dann an, eine Änderung in einer Entität A
vorzunehmen. Aufgrund des sofortigen Feedbacks deiner Tests (du hast Glück, dass du Tests hast!), wirst du schnell daran erinnert, dass du auch das Problem in der Entität B
beheben musst. Das ist überraschend! Du hast nicht erwartet, dass B
überhaupt betroffen ist. Trotzdem machst du weiter und passt B
an. Unerwartet stellt der nächtliche Build jedoch fest, dass dadurch C
und D
nicht mehr funktionieren. Bevor du fortfährst, untersuchst du das Problem etwas genauer und stellst fest, dass sich die Wurzeln des Problems über einen großen Teil der Codebasis erstrecken. Die kleine, anfangs unschuldig aussehende Aufgabe hat sich zu einer großen, potenziell riskanten Codeänderung entwickelt.3 Deine Zuversicht, das Problem schnell zu lösen, ist dahin. Und deine Pläne für den Rest der Woche sind es auch.
Vielleicht kommt dir diese Geschichte bekannt vor. Vielleicht kannst du sogar ein paar eigene Kriegsgeschichten beisteuern. In der Tat machen die meisten Entwickler ähnliche Erfahrungen. Und die meisten dieser Erfahrungen haben die gleiche Problemquelle. Meist lässt sich das Problem auf ein einziges Wort reduzieren: Abhängigkeiten. Wie Kent Beck es in seinem Buch über testgetriebene Entwicklung ausgedrückt hat:4
Abhängigkeiten sind das Hauptproblem bei der Softwareentwicklung auf allen Ebenen.
Abhängigkeiten sind der Fluch eines jeden Softwareentwicklers. "Aber natürlich gibt es Abhängigkeiten", argumentierst du. "Es wird immer Abhängigkeiten geben. Wie sollten denn sonst verschiedene Teile des Codes zusammenarbeiten?" Und natürlich hast du Recht. Verschiedene Teile des Codes müssen zusammenarbeiten, und diese Interaktion führt immer zu einer gewissen Form der Kopplung. Doch neben den notwendigen, unvermeidbaren Abhängigkeiten gibt es auch künstliche Abhängigkeiten, die wir versehentlich einführen, weil wir das zugrunde liegende Problem nicht verstehen, keine klare Vorstellung vom Gesamtbild haben oder einfach nicht aufmerksam genug sind. Diese künstlichen Abhängigkeiten tun natürlich weh. Sie machen es schwieriger, unsere Software zu verstehen, sie zu ändern, neue Funktionen hinzuzufügen und Tests zu schreiben. Deshalb ist es eine der wichtigsten Aufgaben, wenn nicht sogar die wichtigste Aufgabe eines Softwareentwicklers, künstliche Abhängigkeiten auf ein Minimum zu reduzieren.
Diese Minimierung von Abhängigkeiten ist das Ziel von Softwarearchitektur und -design, um es mit den Worten von Robert C. Martin zu sagen:5
Das Ziel der Softwarearchitektur ist es, den Personalaufwand für die Erstellung und Wartung des gewünschten Systems zu minimieren.
Architektur und Design sind die Werkzeuge, die man braucht, um den Arbeitsaufwand in jedem Projekt zu minimieren. Sie befassen sich mit Abhängigkeiten und reduzieren die Komplexität durch Abstraktionen. Mit meinen eigenen Worten:6
Softwaredesign ist die Kunst, Abhängigkeiten zwischen Softwarekomponenten zu managen. Sie zielt darauf ab, künstliche (technische) Abhängigkeiten zu minimieren und die notwendigen Abstraktionen und Kompromisse einzuführen.
Ja, Softwaredesign ist eine Kunst. Es ist keine Wissenschaft, und es gibt keine einfachen und klaren Antworten.7 Allzu oft entgeht uns das Gesamtbild des Designs und wir sind überwältigt von den komplexen Abhängigkeiten zwischen den Softwareeinheiten. Aber wir versuchen, diese Komplexität zu bewältigen und zu reduzieren, indem wir die richtige Art von Abstraktionen einführen. Auf diese Weise halten wir den Detailgrad auf einem vernünftigen Niveau. Zu oft haben jedoch einzelne Entwickler im Team eine andere Vorstellung von der Architektur und dem Design. Es kann sein, dass wir nicht in der Lage sind, unsere eigene Vorstellung von einem Design umzusetzen und gezwungen sind, Kompromisse einzugehen, um voranzukommen.
Tipp
Der Begriff Abstraktion wird in verschiedenen Kontexten verwendet. Er wird für die Organisation von Funktionen und Datenelementen in Datentypen und Funktionen verwendet. Er wird aber auch verwendet, um die Modellierung von allgemeinem Verhalten und die Darstellung einer Reihe von Anforderungen und Erwartungen zu beschreiben. In diesem Buch über Softwaredesign werde ich den Begriff hauptsächlich für Letzteres verwenden (siehe insbesondere Kapitel 2).
Beachte, dass die Worte Architektur und Design in den vorangegangenen Zitaten ausgetauscht werden können, da sie sehr ähnlich sind und die gleichen Ziele verfolgen. Die Ähnlichkeiten, aber auch die Unterschiede, werden deutlich, wenn du dir die drei Ebenen der Softwareentwicklung ansiehst.
Die drei Ebenen der Softwareentwicklung
Softwarearchitektur und Softwaredesign sind nur zwei der drei Ebenen der Softwareentwicklung. Ergänzt werden sie durch die Ebene der Implementierungsdetails. Abbildung 1-1 gibt einen Überblick über diese drei Ebenen.
Um dir ein Gefühl für diese drei Ebenen zu geben, beginnen wir mit einem Beispiel aus der Praxis, das die Beziehung zwischen Architektur, Design und Implementierungsdetails verdeutlicht. Stell dir vor, du wärst in der Rolle eines Architekten. Und nein, stell dir bitte nicht vor, dass du in einem bequemen Sessel vor dem Computer sitzt und einen heißen Kaffee neben dir hast, sondern stell dir vor, dass du draußen auf einer Baustelle bist. Ja, ich spreche von einem Architekten oder einer Architektin für Gebäude.8 Als ein solcher Architekt wärst du für alle wichtigen Eigenschaften eines Hauses zuständig: seine Integration in die Nachbarschaft, seine strukturelle Integrität, die Anordnung der Räume, die Sanitäranlagen usw. Du würdest dich auch um ein ansprechendes Aussehen und funktionale Qualitäten kümmern - zum Beispiel ein großes Wohnzimmer, einen einfachen Zugang zwischen Küche und Esszimmer und so weiter. Mit anderen Worten: Du kümmerst dich um dieGesamtarchitektur, also um die Dinge, die später nur schwer zu ändern sind, aber du kümmerst dich auch um die kleineren Designaspekte des Gebäudes. Es ist jedoch schwer, den Unterschied zwischen den beiden zu erkennen: Die Grenze zwischen Architektur und Design scheint fließend zu sein und ist nicht klar getrennt.
Diese Entscheidungen wären jedoch das Ende deiner Verantwortung. Als Architekt würdest du dir keine Gedanken darüber machen, wo der Kühlschrank, der Fernseher oder andere Möbel stehen sollen. Du würdest dich nicht mit all den raffinierten Details befassen, wo Bilder und andere Dekorationsstücke platziert werden sollen. Mit anderen Worten: Du würdest dich nicht um die Details kümmern, sondern nur dafür sorgen, dass der Hausbesitzer die nötige Struktur hat, um gut zu leben.
Die Möbel und andere "raffinierte Details" in dieser Metapher entsprechen der untersten und konkretesten Ebene der Softwareentwicklung, den Implementierungsdetails. Auf dieser Ebene geht es darum, wie eine Lösung implementiert wird. Du wählst den notwendigen (und verfügbaren)C++-Standard oder eine Teilmenge davon aus, wählst die geeigneten Funktionen, Schlüsselwörter und Sprachspezifika und kümmerst dich um Aspekte wie Speichererfassung, Ausnahmesicherheit, Leistung usw. Dies ist auch die Ebene der Implementierungsmuster, wie z.B. std::make_unique()
als eine Fabrikfunktion,std::enable_if
als wiederkehrende Lösung, um explizit von SFINAE zu profitieren, usw.9
Beim Softwaredesign fängst du an, dich auf das große Ganze zu konzentrieren. Fragen zur Wartbarkeit, Änderbarkeit, Erweiterbarkeit, Testbarkeit und Skalierbarkeit sind auf dieser Ebene stärker ausgeprägt. Beim Softwaredesign geht es vor allem um das Zusammenspiel von Softwareeinheiten, die in der vorherigen Metapher durch die Anordnung von Räumen, Türen, Rohren und Kabeln dargestellt werden. Auf dieser Ebene kümmerst du dich um die physischen und logischen Abhängigkeiten der Komponenten (Klassen, Funktionen usw.).10 Es ist die Ebene der Entwurfsmuster wie Visitor, Strategy und Decorator, die eine Abhängigkeitsstruktur zwischen Softwareeinheiten definieren, wie in Kapitel 3 erläutert. Diese Muster, die in der Regel von Sprache zu Sprache übertragbar sind, helfen dir, komplexe Dinge in verdauliche Teile zu zerlegen.
Softwarearchitektur ist die unschärfste der drei Ebenen, die sich am schwersten in Worte fassen lässt. Das liegt daran, dass es keine allgemeingültige, allgemein akzeptierte Definition von Softwarearchitektur gibt. Auch wenn es viele verschiedene Ansichten darüber gibt, was genau eine Architektur ist, scheinen sich alle auf einen Aspekt zu einigen: Architektur beinhaltet in der Regel die großen Entscheidungen, die Aspekte deiner Software, die zu den am schwersten zu ändernden Dingen in der Zukunft gehören:
In der Architektur geht es um Entscheidungen, von denen du dir wünschst, dass du sie zu Beginn eines Projekts richtig triffst, aber die Wahrscheinlichkeit, dass du sie richtig triffst, ist nicht unbedingt größer als bei anderen.11
Ralph Johnson
In der Softwarearchitektur verwendest du Architekturmuster wie Client-Server-Architektur, Microservices und so weiter.12 Diese Muster befassen sich auch mit der Frage, wie man Systeme entwirft, bei denen man einen Teil ändern kann, ohne dass sich dies auf andere Teile der Software auswirkt. Ähnlich wie bei den Software-Entwurfsmustern werden auch hier die Struktur und die Abhängigkeiten zwischen den Softwareeinheiten definiert und behandelt. Im Gegensatz zu den Entwurfsmustern befassen sie sich jedoch in der Regel mit den Hauptakteuren, den großen Einheiten deiner Software (z. B. Module und Komponenten anstelle von Klassen und Funktionen).
Aus dieser Perspektive stellt die Softwarearchitektur die Gesamtstrategie deines Softwarekonzepts dar, während das Softwaredesign die Taktik ist, mit der diese Strategie umgesetzt wird. Das Problem bei diesem Bild ist, dass es keine Definition von "groß" gibt. Besonders mit dem Aufkommen von Microservices wird es immer schwieriger, eine klare Grenze zwischen kleinen und großen Einheiten zu ziehen.13
Daher wird die Architektur oft als das beschrieben, was erfahrene Entwickler in einem Projekt als die wichtigsten Entscheidungen ansehen.
Was die Trennung zwischen Architektur, Design und Details etwas erschwert, ist das Konzept des Idioms. Ein Idiom ist eine häufig verwendete, aber sprachspezifische Lösung für ein wiederkehrendes Problem. Als solches stellt ein Idiom auch ein Muster dar, aber es kann entweder ein Implementierungsmuster oder ein Entwurfsmuster sein.14 Grob gesagt sind C++-Idiome die bewährten Methoden der C++-Gemeinschaft für das Design oder die Implementierung. In C++ fallen die meisten Idiome in die Kategorie der Implementierungsdetails. So gibt es zum Beispiel dasCopy-and-Swap-Idiom, das du vielleicht von der Implementierung eines Kopierzuweisungsoperators kennst, und dasRAII-Idiom(Resource Acquisition Is Initialization - damit solltest du auf jeden Fall vertraut sein; falls nicht, schaue bitte in deinem zweitliebsten C++-Buch15). Keines dieser Idiome führt eine Abstraktion ein, und keines von ihnen hilft bei der Entkopplung. Dennoch sind sie unverzichtbar, um gutenC++-Code zu implementieren.
Ich höre dich fragen: "Könntest du bitte etwas genauer sein? Bietet RAII nicht auch eine Form der Entkopplung? Entkoppelt es nicht die Ressourcenverwaltung von der Geschäftslogik?" Du hast Recht: RAII trennt die Ressourcenverwaltung von der Geschäftslogik. Allerdings wird dies nicht durch Entkopplung, d.h. Abstraktion, sondern durch Kapselung erreicht. Sowohl die Abstraktion als auch die Kapselung helfen dir, komplexe Systeme leichter zu verstehen und zu verändern, aber während die Abstraktion die Probleme und Fragen löst, die auf der Ebene des Softwaredesigns entstehen, löst die Kapselung die Probleme und Fragen, die auf der Ebene der Implementierungsdetails entstehen. UmWikipedia zu zitieren:
Die Vorteile von RAII als Technik zur Ressourcenverwaltung liegen in der Kapselung, der Ausnahmesicherheit [...] und der Lokalität [...]. Die Kapselung wird dadurch erreicht, dass die Logik der Ressourcenverwaltung nur einmal in der Klasse und nicht an jeder Aufrufstelle definiert wird.
Während die meisten Idiome in die Kategorie der Implementierungsdetails fallen, gibt es auch Idiome, die in die Kategorie des Softwaredesigns fallen. Zwei Beispiele sind dasNon-Virtual Interface (NVI) Idiom und das Pimpl Idiom. Diese beiden Idiome basieren auf zwei klassischen Entwurfsmustern: dem Template Method Entwurfsmuster bzw. dem Bridge Entwurfsmuster.16 Sie führen eine Abstraktion ein und helfen dabei, Änderungen und Erweiterungen zu entkoppeln und zu entwerfen.
Der Fokus auf Merkmale
Wenn Softwarearchitektur und Softwaredesign so wichtig sind, warum konzentrieren wir uns in der C++-Gemeinschaft dann so stark auf Features? Warum erwecken wir die Illusion, dass C++-Standards, Sprachmechanik und Funktionen für ein Projekt entscheidend sind? Ich denke, dafür gibt es drei wichtige Gründe. Erstens: Weil es so viele Funktionen mit manchmal komplexen Details gibt, müssen wir viel Zeit darauf verwenden, darüber zu sprechen, wie man sie alle richtig einsetzt. Wir müssen ein gemeinsames Verständnis dafür schaffen, welche Nutzung gut und welche schlecht ist. Wir als Gemeinschaft müssen einen Sinn für idiomatisches C++ entwickeln.
Der zweite Grund ist, dass wir vielleicht die falschen Erwartungen an die Funktionen stellen. Nehmen wir als Beispiel die C++20 Module. Ohne ins Detail zu gehen, kann diese Funktion tatsächlich als die größte technische Revolution seit den Anfängen von C++ angesehen werden. Module können der fragwürdigen und umständlichen Praxis, Header-Dateien in Quelldateien einzubinden, endlich ein Ende setzen.
Aufgrund dieses Potenzials sind die Erwartungen an diese Funktion enorm. Manche Leute erwarten sogar, dass Module ihr Projekt retten, indem sie ihre strukturellen Probleme beheben. Leider werden es Module schwer haben, diese Erwartungen zu erfüllen: Module verbessern nicht die Struktur oder das Design deines Codes, sondern können lediglich die aktuelle Struktur und das Design darstellen. Module reparieren deine Designprobleme nicht, aber sie können die Fehler vielleicht sichtbar machen. Module können dein Projekt also nicht retten. Es kann also tatsächlich sein, dass wir zu viele oder die falschen Erwartungen in Features setzen.
Und nicht zuletzt ist der dritte Grund, dass trotz der riesigen Menge an Funktionen und ihrer Komplexität die Komplexität von C++-Funktionen im Vergleich zur Komplexität des Softwaredesigns gering ist. Es ist viel einfacher, ein bestimmtes Regelwerk für Features zu erklären, unabhängig davon, wie viele Sonderfälle es enthält, als den besten Weg zu erklären, wie man Software-Entitäten entkoppelt.
Obwohl es normalerweise eine gute Antwort auf alle funktionsbezogenen Fragen gibt, lautet die gängige Antwort beim Softwaredesign "Es kommt darauf an". Diese Antwort zeugt vielleicht nicht einmal von Unerfahrenheit, sondern von der Erkenntnis, dass der beste Weg, Code wartbarer, veränderbar, erweiterbar, testbar und skalierbar zu machen, von vielen projektspezifischen Faktoren abhängt. Die Entkopplung des komplexen Zusammenspiels vieler Einheiten ist vielleicht tatsächlich eine der größten Herausforderungen, die die Menschheit je bewältigt hat:
Entwerfen und Programmieren sind menschliche Tätigkeiten; vergiss das und alles ist verloren.17
Für mich ist eine Kombination aus diesen drei Gründen der Grund, warum wir uns so sehr auf die Funktionen konzentrieren. Aber bitte versteh mich nicht falsch. Das soll nicht heißen, dass Funktionen nicht wichtig sind. Ganz im Gegenteil, Funktionen sind wichtig. Und ja, es ist notwendig, über Features zu sprechen und zu lernen, wie man sie richtig einsetzt, aber noch einmal: Sie allein retten dein Projekt nicht.
Der Fokus auf Softwaredesign und Designprinzipien
Obwohl Funktionen wichtig sind und es natürlich gut ist, über sie zu sprechen, ist das Softwaredesign wichtiger. Ich würde sogar behaupten, dass das Softwaredesign die Grundlage für den Erfolg unserer Projekte ist. Deshalb werde ich in diesem Buch versuchen, mich wirklich auf das Softwaredesign und die Designprinzipien zu konzentrieren, anstatt auf die Funktionen. Natürlich werde ich immer noch guten und aktuellen C++-Code zeigen, aber ich werde nicht die Verwendung der neuesten und besten Erweiterungen der Sprache erzwingen.18 Ich werde einige neue Funktionen nutzen, wenn es sinnvoll und vorteilhaft ist, wie z. B. C++20-Konzepte, aber ich werde nicht auf noexcept
achten oder constexpr
überall verwenden.19 Stattdessen werde ich versuchen, die schwierigen Aspekte von Software anzugehen. Ich werde mich größtenteils auf das Softwaredesign, die Gründe für Designentscheidungen, Designprinzipien, das Management von Abhängigkeiten und den Umgang mit Abstraktionen konzentrieren.
Zusammenfassend lässt sich sagen, dass das Softwaredesign der entscheidende Teil beim Schreiben von Software ist. Softwareentwickler sollten ein gutes Verständnis von Softwaredesign haben, um gute, wartbare Software zu schreiben. Denn schließlich ist gute Software kostengünstig und schlechte Software teuer.
Leitlinie 2: Design für den Wandel
Eine der wesentlichen Erwartungen an gute Software ist ihre Fähigkeit, sich leicht zu ändern. Diese Erwartung ist sogar Teil des Wortes Software. Von Softwarewird im Gegensatz zuHardwareerwartet, dass sie sich leicht an veränderte Anforderungen anpassen lässt (siehe auch"Leitfaden 1: Die Bedeutung desSoftwaredesignsverstehen"). Aus eigener Erfahrung kannst du aber vielleicht sagen, dass es oft nicht einfach ist, den Code zu ändern. Im Gegenteil, manchmal entpuppt sich eine scheinbar einfache Änderung als ein wochenlanges Unterfangen.
Trennung der Belange
Eine der besten und bewährten Lösungen, um künstliche Abhängigkeiten zu reduzieren und Änderungen zu vereinfachen, ist die Trennung von Bereichen. Der Kern der Idee besteht darin, Teile der Funktionalität aufzuspalten, abzutrennen oder zu extrahieren:20
Systeme, die in kleine, gut benannte und verständliche Teile aufgeteilt sind, ermöglichen ein schnelleres Arbeiten.
Die Absicht hinter der Trennung von Belangen ist es, die Komplexität besser zu verstehen und zu bewältigen und somit modularere Software zu entwickeln. Diese Idee ist wahrscheinlich so alt wie die Software selbst und hat daher viele verschiedene Namen erhalten. Die Pragmatischen Programmierer zum Beispiel nennen sie orthogonality.21 Sie empfehlen, orthogonale Aspekte von Software zu trennen. Tom DeMarco nennt esKohäsion:22
Die Kohäsion ist ein Maß für die Stärke der Verknüpfung der Elemente innerhalb eines Moduls. Ein Modul mit hoher Kohäsion ist eine Sammlung von Anweisungen und Datenelementen, die als Ganzes behandelt werden sollten, weil sie so eng miteinander verbunden sind. Jeder Versuch, sie aufzuteilen, würde nur zu einer stärkeren Kopplung und schlechteren Lesbarkeit führen.
In den SOLID-Prinzipien,23 einem der bekanntesten Gestaltungsprinzipien, ist diese Idee als Single-Responsibility-Prinzip (SRP) bekannt:
Eine Klasse sollte nur einen Grund haben, zu wechseln.24
Obwohl das Konzept alt und unter vielen Namen bekannt ist, werfen viele Versuche, die Trennung der Belange zu erklären, mehr Fragen als Antworten auf. Das gilt insbesondere für die SRP. Schon der Name dieses Gestaltungsprinzips wirft Fragen auf: Was ist eine Verantwortung? Und was ist eine einzelneVerantwortung? Ein gängiger Versuch, die Unklarheiten über SRP zu klären, ist der folgende:
Alles sollte nur eine Sache tun.
Leider ist diese Erklärung an Unbestimmtheit kaum zu überbieten. So wie das Wort Verantwortung nicht viel Aussagekraft hat, hilft auch eine Sachenicht weiter.
Egal, wie man es nennt, die Idee ist immer dieselbe: Gruppiere nur die Dinge, die wirklich zusammengehören, und trenne alles, was nicht unbedingt dazugehört. Oder anders gesagt: Trenne die Dinge, die sich aus unterschiedlichen Gründen ändern. Auf diese Weise reduzierst du künstliche Kopplung zwischen verschiedenen Aspekten deines Codes und machst deine Software anpassungsfähiger an Veränderungen. Im besten Fall kannst du einen bestimmten Aspekt deiner Software an genau einer Stelle ändern.
Ein Beispiel für künstliche Kopplung
Auf kannst du anhand eines Code-Beispiels etwas mehr über die Trennung von Belangen erfahren. Und ich habe tatsächlich ein tolles Beispiel: Ich präsentiere dir die abstrakte Klasse Document
:
//#include <some_json_library.h> // Potential physical dependency
class
Document
{
public
:
// ...
virtual
~
Document
(
)
=
default
;
virtual
void
exportToJSON
(
/*...*/
)
const
=
0
;
virtual
void
serialize
(
ByteStream
&
,
/*...*/
)
const
=
0
;
// ...
}
;
Das klingt nach einer sehr nützlichen Basisklasse für alle Arten von Dokumenten, nicht wahr? Zunächst gibt es die Funktion exportToJSON()
(Alle abgeleiteten Klassen müssen die Funktion exportToJSON()
implementieren, um aus dem Dokument eine JSON-Datei zu erzeugen. Das wird sich als ziemlich nützlich erweisen: Ohne eine bestimmte Art von Dokument kennen zu müssen (und wir können uns vorstellen, dass wir irgendwann PDF-Dokumente, Word-Dokumente und viele andere haben werden), können wir immer im JSON-Format exportieren. Toll! Zweitens gibt es eine serialize()
Funktion (). Mit dieser Funktion kannst du ein Document
über ein ByteStream
in Bytes umwandeln. Diese Bytes kannst du in einem persistenten System speichern, z. B. in einer Datei oder einer Datenbank. Und natürlich können wir davon ausgehen, dass es noch viele andere, nützliche Funktionen gibt, mit denen wir dieses Dokument für so ziemlichalles verwenden können.
Aber ich kann das Stirnrunzeln auf deinem Gesicht sehen. Nein, du siehst nicht besonders überzeugt davon aus, dass dies gutes Softwaredesign ist. Das kann daran liegen, dass du diesem Beispiel einfach sehr misstrauisch gegenüberstehst (es sieht einfach zu gut aus, um wahr zu sein). Oder es kann daran liegen, dass du auf die harte Tour gelernt hast, dass diese Art von Design letztendlich zu Problemen führt. Du hast vielleicht die Erfahrung gemacht, dass die Verwendung des allgemeinen objektorientierten Designprinzips zur Bündelung der Daten und der Funktionen, die auf sie einwirken, leicht zu einer unglücklichen Kopplung führen kann. Und ich stimme dir zu: Obwohl diese Basisklasse wie ein großartiges Gesamtpaket aussieht und sogar so aussieht, als hätte sie alles, was wir jemals brauchen könnten, wird dieses Design bald zu Problemen führen.
Dieses ist ein schlechtes Design, weil es viele Abhängigkeiten enthält. Natürlich gibt es die offensichtlichen, direkten Abhängigkeiten, wie zum Beispiel die Abhängigkeit von der Klasse ByteStream
. Aber dieses Design begünstigt auch die Einführung künstlicher Abhängigkeiten, die spätere Änderungen erschweren. In diesem Fall gibt es drei Arten von künstlichen Abhängigkeiten. Zwei davon werden durch die Funktion exportToJSON()
und eine durch die Funktion serialize()
eingeführt.
Zunächst muss exportToJSON()
in den abgeleiteten Klassen implementiert werden. Und ja, es gibt keine andere Wahl, denn es handelt sich um einerein virtuelle Funktion(gekennzeichnet durch die Sequenz = 0
, den sogenannten Pure Specifier). Da abgeleitete Klassen höchstwahrscheinlich nicht die Last auf sich nehmen wollen, JSON-Exporte manuell zu implementieren, werden sie sich auf eine externe JSON-Bibliothek eines Drittanbieters verlassen:json,rapidjson odersimdjson. Welche Bibliothek du auch immer für diesen Zweck wählst, aufgrund der Memberfunktion exportToJSON()
würden die abgeleiteten Dokumente plötzlich von dieser Bibliothek abhängen. Und sehr wahrscheinlich würden alle abgeleiteten Klassen von derselben Bibliothek abhängen, schon allein aus Gründen der Konsistenz. Die abgeleiteten Klassen sind also nicht wirklich unabhängig, sondern künstlich an eine bestimmte Designentscheidung gekoppelt.25 Außerdem würde die Abhängigkeit von einer bestimmten JSON-Bibliothek die Wiederverwendbarkeit der Hierarchie definitiv einschränken, da sie nicht mehr leichtgewichtig wäre. Und der Wechsel zu einer anderen Bibliothek würde eine große Veränderung bedeuten, weil alle abgeleiteten Klassen angepasst werden müssten.26
Natürlich wird die gleiche Art von künstlicher Abhängigkeit durch die Funktion serialize()
eingeführt. Es ist wahrscheinlich, dass serialize()
auch in Form einer Bibliothek eines Drittanbieters, wie protobuf oder Boost.serialization, implementiert wird. Dadurch wird die Abhängigkeitssituation erheblich verschlechtert, da eine Kopplung zwischen zwei orthogonalen, nicht miteinander verbundenen Designaspekten (d. h. JSON-Export und Serialisierung) eingeführt wird. Eine Änderung an einem Aspekt kann zu Änderungen am anderen Aspekt führen.
Im schlimmsten Fall könnte die Funktion exportToJSON()
eine zweite Abhängigkeit einführen. Die Argumente, die im Aufruf von exportToJSON()
erwartet werden, könnten versehentlich einige der Implementierungsdetails der gewählten JSON-Bibliothek widerspiegeln. In diesem Fall könnte ein Wechsel zu einer anderen Bibliothek zu einer Änderung der Signatur der Funktion exportToJSON()
führen, was wiederum Änderungen bei allen Aufrufern zur Folge hätte. So könnte die Abhängigkeit von der gewählten JSON-Bibliothek versehentlich viel weiter verbreitet sein als beabsichtigt.
Die dritte Art der Abhängigkeit wird durch die Funktion serialize()
eingeführt. Dank dieser Funktion sind die von Document
abgeleiteten Klassen von globalen Entscheidungen darüber abhängig, wie Dokumente serialisiert werden. Welches Format verwenden wir? Verwenden wir Little Endian oder Big Endian? Müssen wir die Information hinzufügen, dass die Bytes eine PDF-Datei oder eine Word-Datei darstellen? Wenn ja (und ich nehme an, dass das sehr wahrscheinlich ist), wie stellen wir ein solches Dokument dar? Mit Hilfe eines ganzzahligen Werts? Wir könnten zum Beispiel eine Aufzählung für diesen Zweck verwenden:27
enum
class
DocumentType
{
,
word
,
// ... Potentially many more document types
};
Dieser Ansatz ist bei der Serialisierung sehr verbreitet. Wenn diese Low-Level-Darstellung von Dokumenten jedoch in den Implementierungen derDocument
Klassen verwendet wird, würden wir versehentlich alle verschiedenen Arten von Dokumenten miteinander verbinden. Jede abgeleitete Klasse würde implizit über alle anderenDocument
Typen Bescheid wissen. Das Hinzufügen einer neuen Art von Dokument würde sich also direkt auf alle bestehenden Dokumenttypen auswirken. Das wäre ein schwerwiegender Konstruktionsfehler, denn es würde wiederum Änderungen erschweren.
Leider fördert die Klasse Document
viele verschiedene Arten der Kopplung. Nein, die Klasse Document
ist kein gutes Beispiel für gutes Klassendesign, da sie nicht einfach zu ändern ist. Im Gegenteil, sie ist schwer zu ändern und damit ein gutes Beispiel für einen Verstoß gegen die SRP: Die von Document
abgeleiteten Klassen und die Benutzer der Klasse Document
ändern sich aus vielen Gründen, weil wir eine starke Kopplung zwischen mehreren orthogonalen, nicht miteinander verbundenen Aspekten geschaffen haben. Zusammenfassend lässt sich sagen, dass sich Ableitungsklassen und Benutzer von Dokumenten aus einem der folgenden Gründe ändern können:
-
Die Implementierungsdetails der Funktion
exportToJSON()
ändern sich aufgrund einer direkten Abhängigkeit von der verwendeten JSON-Bibliothek -
Die Signatur der Funktion
exportToJSON()
ändert sich, weil sich die zugrunde liegende Implementierung ändert -
Die Klasse
Document
und die Funktionserialize()
ändern sich aufgrund einer direkten Abhängigkeit von der KlasseByteStream
-
Die Implementierungsdetails der Funktion
serialize()
ändern sich aufgrund einer direkten Abhängigkeit von den Implementierungsdetails -
Alle Arten von Dokumenten ändern sich aufgrund der direkten Abhängigkeit von der Aufzählung
DocumentType
Es liegt auf der Hand, dass dieses Design mehr Änderungen fördert, und jede einzelne Änderung wäre schwieriger. Und natürlich besteht im allgemeinen Fall die Gefahr, dass zusätzliche orthogonale Aspekte innerhalb von Dokumenten künstlich gekoppelt werden, was die Komplexität einer Änderung weiter erhöhen würde. Darüber hinaus sind einige dieser Änderungen definitiv nicht auf eine einzige Stelle in der Codebasis beschränkt. Insbesondere Änderungen an den Implementierungsdetails von exportToJSON()
undserialize()
würden sich nicht nur auf eine Klasse beschränken, sondern wahrscheinlich auf alle Arten von Dokumenten (PDF, Word usw.). Daher würde eine Änderung eine große Anzahl von Stellen in der gesamten Codebasis betreffen, was ein Wartungsrisiko darstellt.
Logische versus physische Kopplung
Die Kopplung ist nicht auf die logische Kopplung beschränkt, sondern erstreckt sich auch auf die physische Kopplung. Abbildung 1-2 veranschaulicht diese Kopplung. Nehmen wir an, dass es eine Klasse User
auf der unteren Ebene unserer Architektur gibt, die Dokumente verwenden muss, die sich auf einer höheren Ebene der Architektur befinden. Natürlich hängt die Klasse User
direkt von der Klasse Document
ab, was eine notwendige Abhängigkeit ist - eine inhärente Abhängigkeit des gegebenen Problems. Daher sollte sie für uns kein Problem darstellen. Die (potenzielle) physische Abhängigkeit von Document
von der ausgewählten JSON-Bibliothek und die direkte Abhängigkeit von der Klasse ByteStream
führen jedoch zu einer indirekten, transitiven Abhängigkeit von User
von der JSON-Bibliothek und ByteStream
, die sich auf der höchsten Ebene unserer Architektur befinden. Im schlimmsten Fall bedeutet das, dass Änderungen an der JSON-Bibliothek oder der Klasse ByteStream
Auswirkungen auf User
haben. Es ist hoffentlich leicht zu erkennen, dass es sich hier um eine künstliche und nicht um eine beabsichtigte Abhängigkeit handelt: User
sollte nicht von JSON oder Serialisierung abhängig sein.
Hinweis
Ich sollte ausdrücklich darauf hinweisen, dass es eine potenzielle physische Abhängigkeit von Document
von der JSON-Bibliothek select gibt. Wenn die Header-Datei <Document.h>
einen Header der JSON-Bibliothek der Wahl enthält (wie im Codeschnipsel am Anfang von "Ein Beispiel für künstliche Kopplung" angegeben ), zum Beispiel weil die Funktion exportToJSON()
einige Argumente erwartet, die auf dieser Bibliothek basieren, dann besteht eine klare Abhängigkeit von dieser Bibliothek. Wenn die Schnittstelle jedoch von diesen Details abstrahieren kann und der<Document.h>
Header nichts von der JSON-Bibliothek enthält, kann die physische Abhängigkeit vermieden werden. Es kommt also darauf an, wie gut die Abhängigkeiten abstrahiert werden können (und werden).
"High Level, Low Level - jetzt bin ich verwirrt", beschwerst du dich. Ja, ich weiß, dass diese beiden Begriffe oft für Verwirrung sorgen. Bevor wir also weitermachen, sollten wir uns auf die Terminologie für High Level und Low Level einigen. Der Ursprung dieser beiden Begriffe liegt in der Art und Weise, wie wir Diagramme in derUnified Modeling Language (UML) zeichnen: Funktionalität, die wir als stabil betrachten, erscheint oben, auf der hohen Ebene. Funktionalität, die sich häufiger ändert und daher als flüchtig oder formbar gilt, erscheint unten, auf der niedrigen Ebene. Leider versuchen wir beim Zeichnen von Architekturen oft zu zeigen, wie die Dinge aufeinander aufbauen, so dass die stabilsten Teile unten in einer Architektur erscheinen. Das führt natürlich zu einiger Verwirrung. Unabhängig davon, wie die Dinge gezeichnet werden, solltest du dich an diese Begriffe erinnern: Diehohe Ebene bezieht sich auf die stabilen Teile deiner Architektur, während die niedrige Ebene die Aspekte bezeichnet, die sich häufiger ändern oder bei denen es wahrscheinlicher ist, dass sie sich ändern.
Zurück zum Problem: Das SRP rät, dass wir Anliegen und Dinge, die nicht wirklich zusammengehören, d.h. die nicht kohäsiven (klebenden) Dinge, trennen sollten. Mit anderen Worten: rät uns, die Dinge, die sich aus unterschiedlichen Gründen ändern, in Variationspunkte aufzuteilen.Abbildung 1-3 zeigt die Kopplungssituation, wenn wir die JSON- und Serialisierungsaspekte in getrennten Anliegen isolieren.
Auf der Grundlage dieses Hinweises wird die Klasse Document
wie folgt umgestaltet:
class
Document
{
public
:
// ...
virtual
~
Document
()
=
default
;
// No more 'exportToJSON()' and 'serialize()' functions.
// Only the very basic document operations, that do not
// cause strong coupling, remain.
// ...
};
Die JSON- und Serialisierungsaspekte gehören einfach nicht zu den grundlegenden Funktionen einer Document
Klasse. Die Klasse Document
sollte lediglich die grundlegenden Operationen der verschiedenen Arten von Dokumenten darstellen. Alle orthogonalen Aspekte sollten getrennt werden. Das macht Änderungen wesentlich einfacher. Wenn zum Beispiel der JSON-Aspekt in einem separaten Variationspunkt und in der neuen Komponente JSON
isoliert wird, wirkt sich der Wechsel von einer JSON-Bibliothek zu einer anderen nur auf diese eine Komponente aus. Die Änderung könnte an genau einer Stelle vorgenommen werden und würde isoliert von allen anderen, orthogonalen Aspekten erfolgen. Es wäre auch einfacher, das JSON-Format mit Hilfe mehrerer JSON-Bibliotheken zu unterstützen. Außerdem würde sich jede Änderung an der Serialisierung von Dokumenten nur auf eine Komponente des Codes auswirken: die neue Komponente Serialization
. Außerdem würde Serialization
als Variationspunkt fungieren, der isolierte, einfache Änderungen ermöglicht. Das wäre die optimale Situation.
Nach deiner anfänglichen Enttäuschung über das Document
Beispiel, sehe ich, dass du wieder fröhlicher aussiehst. Vielleicht hast du sogar ein "Ich wusste es!"-Lächeln im Gesicht. Trotzdem bist du noch nicht ganz zufrieden: "Ja, ich bin mit der allgemeinen Idee der Trennung von Belangen einverstanden. Aber wie muss ich meine Software strukturieren, um Bedenken zu trennen? Was muss ich tun, damit es funktioniert?" Das ist eine ausgezeichnete Frage, auf die es viele Antworten gibt, die ich in den nächsten Kapiteln behandeln werde. Der erste und wichtigste Punkt ist jedoch die Identifizierung eines Variationspunkts, d. h. eines Aspekts in deinem Code, an dem Änderungen zu erwarten sind. Diese Variationspunkte sollten extrahiert, isoliert und verpackt werden, so dass es keine Abhängigkeiten mehr von diesen Variationen gibt. Das macht Änderungen einfacher.
"Aber das sind doch nur oberflächliche Ratschläge!" höre ich dich sagen. Und du hast Recht. Leider gibt es nicht nur eine Antwort und auch keine einfache. Es kommt darauf an. Aber ich verspreche dir, dass ich in den nächsten Kapiteln viele konkrete Antworten geben werde, wie du deine Anliegen trennen kannst. Schließlich ist dies ein Buch über Softwaredesign, also ein Buch über das Management von Abhängigkeiten. Als kleinen Vorgeschmack werde ich in Kapitel 3einen allgemeinen und praktischen Ansatz für dieses Problem vorstellen: Entwurfsmuster. Ausgehend von dieser allgemeinen Idee zeige ich dir, wie du mit verschiedenen Entwurfsmustern Anliegen trennen kannst. Da fallen mir zum Beispiel die EntwurfsmusterVisitor, Strategy und External Polymorphism ein. Alle diese Muster haben unterschiedliche Stärken und Schwächen, aber sie haben die Eigenschaft, eine Art von Abstraktion einzuführen, die dir hilft, Abhängigkeiten zu reduzieren. Außerdem verspreche ich dir, einen genauen Blick darauf zu werfen, wie diese Entwurfsmuster in modernem C++ umgesetzt werden können.
Tipp
Ich werde das Visitor-Entwurfsmuster in"Leitlinie 16: Use Visitor to Extend Operations" und das Strategy-Entwurfsmuster in"Leitlinie 19: Use Strategy to Isolate How Things Are Done" vorstellen. Das External Polymorphism-Entwurfsmuster wird das Thema von"Leitlinie 31" sein: Externe Polymorphie für nicht-intrusive Laufzeit-Polymorphie verwenden".
Wiederholen Sie sich nicht
Es gibt noch einen zweiten, wichtigen Aspekt der Veränderbarkeit. Um diesen Aspekt zu erklären, stelle ich ein weiteres Beispiel vor: eine Hierarchie von Elementen. Abbildung 1-4vermittelt einen Eindruck von dieser Hierarchie.
An der Spitze dieser Hierarchie steht die Basisklasse Item
:
//---- <Money.h> ----------------
class
Money
{
/*...*/
};
Money
operator
*
(
Money
money
,
double
factor
);
Money
operator
+
(
Money
lhs
,
Money
rhs
);
//---- <Item.h> ----------------
#include
<Money.h>
class
Item
{
public
:
virtual
~
Item
()
=
default
;
virtual
Money
price
()
const
=
0
;
};
Die Basisklasse Item
stellt eine Abstraktion für jede Art von Artikel dar, der ein Preisschild hat (dargestellt durch die Klasse Money
). Über die Funktion price()
kannst du diesen Preis abfragen. Natürlich gibt es viele mögliche Artikel, aber zur Veranschaulichung beschränken wir uns auf CppBook
und ConferenceTicket
:
//---- <CppBook.h> ----------------
#
include
<Item.h>
#
include
<Money.h>
#
include
<string>
class
CppBook
:
public
Item
{
public
:
explicit
CppBook
(
std
:
:
string
title
,
std
:
:
string
author
,
Money
price
)
:
title_
(
std
:
:
move
(
title
)
)
,
author_
(
std
:
:
move
(
author
)
)
,
priceWithTax_
(
price
*
1.15
)
// 15% tax rate
{
}
std
:
:
string
const
&
title
(
)
const
{
return
title_
;
}
std
:
:
string
const
&
author
(
)
const
{
return
author_
;
}
Money
price
(
)
const
override
{
return
priceWithTax_
;
}
private
:
std
:
:
string
title_
;
std
:
:
string
author_
;
Money
priceWithTax_
;
}
;
Der Konstruktor der Klasse CppBook
erwartet einen Titel und Autor in Form von Strings und einen Preis in Form von Money
().28
Ansonsten kannst du nur mit den Funktionen title()
, author()
und price()
auf den Titel, den Autor und den Preis zugreifen (,, und). Die Funktion price()
ist jedoch etwas Besonderes: Auf Bücher werden natürlich Steuern erhoben. Deshalb muss der ursprüngliche Preis des Buches an einen bestimmten Steuersatz angepasst werden. In diesem Beispiel gehe ich von einem fiktiven Steuersatz von 15% aus.
Die Klasse ConferenceTicket
ist das zweite Beispiel für eine Item
:
//---- <ConferenceTicket.h> ----------------
#
include
<Item.h>
#
include
<Money.h>
#
include
<string>
class
ConferenceTicket
:
public
Item
{
public
:
explicit
ConferenceTicket
(
std
:
:
string
name
,
Money
price
)
:
name_
(
std
:
:
move
(
name
)
)
,
priceWithTax_
(
price
*
1.15
)
// 15% tax rate
{
}
std
:
:
string
const
&
name
(
)
const
{
return
name_
;
}
Money
price
(
)
const
override
{
return
priceWithTax_
;
}
private
:
std
:
:
string
name_
;
Money
priceWithTax_
;
}
;
ConferenceTicket
ist der Klasse CppBook
sehr ähnlich, erwartet aber nur den Namen der Konferenz und den Preis im Konstruktor (Natürlich kannst du den Namen und den Preis auch mit den Funktionenname()
bzw. price()
abfragen. Das Wichtigste ist jedoch, dass der Preis für eine C++ Konferenz auch Steuern enthält. Deshalb passen wir den ursprünglichen Preis wieder an den fiktiven Steuersatz von 15 % an.
Mit dieser Funktionalität können wir ein paarItem
in der Funktion main()
erstellen:
#include
<CppBook.h>
#include
<ConferenceTicket.h>
#include
<algorithm>
#include
<cstdlib>
#include
<memory>
#include
<vector>
int
main
()
{
std
::
vector
<
std
::
unique_ptr
<
Item
>>
items
{};
items
.
emplace_back
(
std
::
make_unique
<
CppBook
>
(
"Effective C++"
,
"Meyers"
,
19.99
)
);
items
.
emplace_back
(
std
::
make_unique
<
CppBook
>
(
"C++ Templates"
,
"Josuttis"
,
49.99
)
);
items
.
emplace_back
(
std
::
make_unique
<
ConferenceTicket
>
(
"CppCon"
,
999.0
)
);
items
.
emplace_back
(
std
::
make_unique
<
ConferenceTicket
>
(
"Meeting C++"
,
699.0
)
);
items
.
emplace_back
(
std
::
make_unique
<
ConferenceTicket
>
(
"C++ on Sea"
,
499.0
)
);
Money
const
total_price
=
std
::
accumulate
(
begin
(
items
),
end
(
items
),
Money
{},
[](
Money
accu
,
auto
const
&
item
){
return
accu
+
item
->
price
();
}
);
// ...
return
EXIT_SUCCESS
;
}
In main()
erstellen wir eine Reihe von Artikeln (zwei Bücher und drei Konferenzen) und berechnen den Gesamtpreis aller Artikel.29 Im Gesamtpreis ist natürlich der fiktive Steuersatz von 15 % enthalten.
Das klingt nach einem guten Entwurf. Wir haben die einzelnen Arten von Artikeln getrennt und können die Art und Weise, wie der Preis jedes einzelnen Artikels berechnet wird, individuell ändern. Es scheint, als hätten wir die SRP erfüllt und die Variationspunkte extrahiert und isoliert. Und natürlich gibt es noch mehr Artikel. Viele mehr. Und alle werden dafür sorgen, dass der geltende Steuersatz richtig berücksichtigt wird. Klasse! Nun, während uns diese Item
Hierarchie eine Zeit lang glücklich machen wird, hat das Design leider einen entscheidenden Fehler. Wir merken es vielleicht heute noch nicht, aber es gibt immer einen drohenden Schatten in der Ferne, die Nemesis aller Probleme in Software: Veränderung.
Was passiert, wenn sich der Steuersatz aus irgendeinem Grund ändert? Was ist, wenn der Steuersatz von 15 % auf 12 % gesenkt wird? Oder auf 16% erhöht wird? Ich höre immer noch die Argumente von dem Tag, an dem der ursprüngliche Entwurf in die Codebasis aufgenommen wurde: "Nein, das wird nie passieren!" Nun, auch das Unerwartete kann passieren. In Deutschland zum Beispiel wurde der Steuersatz im Jahr 2021 für ein halbes Jahr von 19% auf 16% gesenkt. Das würde natürlich bedeuten, dass wir den Steuersatz in unserer Codebasis ändern müssen. Wo sollen wir die Änderung vornehmen? In der aktuellen Situation würde die Änderung so ziemlich jede Klasse betreffen, die von der Klasse Item
abgeleitet ist. Die Änderung wäre in der gesamten Codebasis zu finden!
Genauso wie die SRP dazu rät, Variationspunkte zu trennen, sollten wir darauf achten, Informationen nicht in der gesamten Codebasis zu duplizieren. So wie alles eine einzige Verantwortung haben sollte (einen einzigen Grund für eine Änderung), sollte jede Verantwortung nur einmal im System vorhanden sein. Diese Idee wird gemeinhin als DRY-Prinzip ( Don't Repeat Yourself) bezeichnet. Dieses Prinzip rät uns, einige wichtige Informationen nicht an vielen Stellen zu duplizieren, sondern das System so zu gestalten, dass wir die Änderung nur an einer Stelle vornehmen können. Im optimalen Fall sollten die Steuersätze an genau einer Stelle dargestellt werden, damit du sie leicht ändern kannst.
Normalerweise arbeiten die SRP- und die DRY-Prinzipien sehr gut zusammen. Die Einhaltung der SRP führt oft auch zur Einhaltung von DRY und umgekehrt. Manchmal erfordert die Einhaltung beider Prinzipien jedoch einige zusätzliche Schritte. Ich weiß, dass du unbedingt wissen willst, was diese zusätzlichen Schritte sind und wie man das Problem löst, aber an dieser Stelle reicht es aus, auf die allgemeine Idee von SRP und DRY hinzuweisen. Ich verspreche dir, auf dieses Problem zurückzukommen und dir zu zeigen, wie du es lösen kannst (siehe"Leitfaden 35: Dekoratoren verwenden, um Anpassungen hierarchisch hinzuzufügen").
Vermeide eine vorzeitige Trennung der Anliegen
bis hierhin habe ich dich hoffentlich davon überzeugt, dass die Einhaltung von SRP und DRY eine sehr vernünftige Idee ist. Vielleicht bist du sogar so überzeugt, dass du alles - alle Klassen und Funktionen - in kleinste Funktionseinheiten aufteilen willst. Das ist ja schließlich das Ziel, oder? Wenn du das gerade denkst, dann hör bitte auf! Atme tief ein. Und noch einen. Und dann höre dir bitte die Weisheit von Katerina Trajchevska genau an:30
Versuche nicht, SOLID zu erreichen, sondern nutze SOLID, um Wartbarkeit zu erreichen.
Sowohl SRP als auch DRY sind deine Werkzeuge, um eine bessere Wartbarkeit zu erreichen und Änderungen zu vereinfachen. Sie sind nicht deine Ziele. Obwohl beides auf lange Sicht von größter Bedeutung ist, kann es sehr kontraproduktiv sein, Entitäten voneinander zu trennen, ohne eine klare Vorstellung davon zu haben, welche Art von Veränderung dich betreffen wird. Die Planung von Veränderungen begünstigt in der Regel eine bestimmte Art von Veränderung, kann aber leider andere Arten von Veränderungen erschweren. Diese Philosophie ist Teil des allgemein bekanntenYAGNI-Prinzips(You Aren't Gonna Need It), das dich vor Overengineering warnt (siehe auch "Leitlinie 5: Design for Extension"). Wenn du einen klaren Plan hast, wenn du weißt, welche Art von Veränderung dich erwartet, dann wende SRP und DRY an, um diese Art von Veränderung einfach zu gestalten. Wenn du jedoch nicht weißt, welche Art von Veränderung dich erwartet, dannrate nicht- warteeinfach ab. Warte, bis du eine klare Vorstellung von der zu erwartenden Änderung hast und überarbeite sie dann so, dass sie so einfach wie möglich wird.
Tipp
Vergiss nur nicht, dass ein Aspekt der einfachen Änderung von Dingen darin besteht, Unit-Tests zu haben, die dir bestätigen, dass die Änderung das erwartete Verhalten nicht verändert hat.
Zusammenfassend lässt sich sagen, dass Veränderungen in der Softwareerwartet werden und es deshalb wichtig ist, für Veränderungen zu planen. Trenne die Bereiche und minimiere Doppelarbeit, damit du Dinge leicht ändern kannst, ohne Angst haben zu müssen, andere, orthogonale Aspekte zu verletzen.
Leitlinie 3: Getrennte Schnittstellen zur Vermeidung einerkünstlichen Kopplung
Lass uns das Beispiel Document
aus "Leitlinie 2" noch einmal anschauen : Design für den Wandel". Ich weiß, inzwischen hast du wahrscheinlich das Gefühl, dass du schon genug Dokumente gesehen hast, aber glaub mir, wir sind noch nicht fertig. Es gibt noch einen wichtigen Aspekt der Kopplung zu behandeln. Diesmal konzentrieren wir uns nicht auf die einzelnen Funktionen in der Klasse Document
, sondern auf die Schnittstelle als Ganzes:
class
Document
{
public
:
// ...
virtual
~
Document
()
=
default
;
virtual
void
exportToJSON
(
/*...*/
)
const
=
0
;
virtual
void
serialize
(
ByteStream
&
bs
,
/*...*/
)
const
=
0
;
// ...
};
Getrennte Schnittstellen zur Trennung von Interessen
Die Document
erfordert die Ableitung von Klassen, die sowohl den JSON-Export als auch die Serialisierung handhaben. Aus der Sicht eines Dokuments mag das zwar vernünftig erscheinen (schließlich sollten alle Dokumente in JSON exportierbar und serialisierbar sein), aber leider führt es zu einer anderen Art der Kopplung. Stell dir den folgenden Benutzercode vor:
void
exportDocument
(
Document
const
&
doc
)
{
// ...
doc
.
exportToJSON
(
/* pass necessary arguments */
);
// ...
}
Die Funktion exportDocument()
ist ausschließlich daran interessiert, ein bestimmtes Dokument in JSON zu exportieren. Mit anderen Worten: Die Funktion exportDocument()
kümmert sich wederum die Serialisierung eines Dokuments noch um irgendeinen anderen Aspekt, den Document
zu bieten hat. Dennoch ist die Funktion exportDocument()
aufgrund der Definition der Schnittstelle Document
, die viele orthogonale Aspekte miteinander koppelt, von viel mehr als nur dem JSON-Export abhängig. All diese Abhängigkeiten sind unnötig und künstlich. Wenn du eine dieser Abhängigkeiten änderst, z. B. die Klasse ByteStream
oder die Signatur der Funktion serialize()
, wirkt sich das auf alle Benutzer von Document
aus, auch auf diejenigen, die keine Serialisierung benötigen. Bei jeder Änderung müssten alle Benutzer, einschließlich der FunktionexportDocument()
, neu kompiliert, neu getestet und im schlimmsten Fall neu bereitgestellt werden (z. B. wenn sie in einer separaten Bibliothek ausgeliefert wird). Das Gleiche passiert, wenn die Klasse Document
durch eine andere Funktion erweitert wird, z. B. durch einen Export in einen anderen Dokumententyp. Das Problem wird umso größer, je mehr orthogonale Funktionen inDocument
gekoppelt sind: Jede Änderung birgt das Risiko, dass sie sich auf die gesamte Codebasis auswirkt. Das ist wirklich traurig, denn Schnittstellen sollten zur Entkopplung beitragen und keine künstliche Kopplung einführen.
Diese Kopplung wird durch eine Verletzung des Schnittstellentrennungsprinzips (ISP) verursacht, welches das I in der SOLID-Akronym ist:
Die Kunden sollten nicht gezwungen werden, sich auf Methoden zu verlassen, die sie nicht nutzen.31
Das ISP rät, Bedenken durch die Trennung von Schnittstellen (Entkopplung) auszuräumen. In unserem Fall sollte es zwei separate Schnittstellen geben, die die beiden orthogonalen Aspekte des JSON-Exports und der Serialisierung repräsentieren:
class
JSONExportable
{
public
:
// ...
virtual
~
JSONExportable
()
=
default
;
virtual
void
exportToJSON
(
/*...*/
)
const
=
0
;
// ...
};
class
Serializable
{
public
:
// ...
virtual
~
Serializable
()
=
default
;
virtual
void
serialize
(
ByteStream
&
bs
,
/*...*/
)
const
=
0
;
// ...
};
class
Document
:
public
JSONExportable
,
public
Serializable
{
public
:
// ...
};
Diese Trennung macht die Klasse Document
nicht überflüssig. Im Gegenteil: DieDocument
Klasse repräsentiert immer noch die Anforderungen, die an alle Dokumente gestellt werden. Diese Trennung ermöglicht es dir jedoch, die Abhängigkeiten auf die tatsächlich benötigten Funktionen zu beschränken:
void
exportDocument
(
JSONExportable
const
&
exportable
)
{
// ...
exportable
.
exportToJSON
(
/* pass necessary arguments */
);
// ...
}
In dieser Form hängt die Funktion exportDocument()
nicht mehr von der Serialisierungsfunktionalität und damit auch nicht mehr von der Klasse ByteStream
ab, da sie nur von der getrennten Schnittstelle JSONExportable
abhängig ist. Die Trennung der Schnittstellen hat also dazu beigetragen, die Kopplung zu verringern.
"Aber ist das nicht nur eine Trennung der Interessen?", fragst du. "Ist das nicht nur ein weiteres Beispiel für die SVB?" Ja, das ist es in der Tat. Ich stimme zu, dass wir im Wesentlichen zwei orthogonale Aspekte identifiziert und getrennt haben und somit die SRP auf die Schnittstelle Document
anwenden. Wir könnten also sagen, dass ISP und SRP dasselbe sind. Oder zumindest, dass die ISP ein Spezialfall der SRP ist, weil der Fokus der ISP auf Schnittstellen liegt. Diese Einstellung scheint die gängige Meinung in der Community zu sein, und ich stimme ihr zu. Dennoch halte ich es fürsinnvoll, über ISP zu sprechen. Auch wenn ISP nur ein Spezialfall ist, würde ich behaupten, dass es ein wichtiger Spezialfall ist. Leider ist es oft sehr verlockend, unverbundene, orthogonale Aspekte in einer Schnittstelle zusammenzufassen. Es kann dir sogar passieren, dass du einzelne Aspekte in einer Schnittstelle zusammenfasst. Natürlich würde ich dir niemals unterstellen, dass du das absichtlich tust, sondern unabsichtlich, aus Versehen. Wir schenken diesen Details oft nicht genug Aufmerksamkeit. Natürlich argumentierst du: "Das würde ich nie tun." Aber in "Leitlinie 19: Verwende eine Strategie, um zu isolieren, wie die Dinge getan werden", wirst du ein Beispiel sehen, das dich vielleicht davon überzeugt, wie leicht das passieren kann. Da es extrem schwierig sein kann, Schnittstellen später zu ändern, glaube ich, dass es sich lohnt, das Bewusstsein für dieses Problem mit Schnittstellen zu schärfen. Aus diesem Grund habe ich den ISP nicht fallen gelassen, sondern ihn als wichtigen und bemerkenswerten Fall der SRP aufgenommen.
Minimierung der Anforderungen an Templating-Argumente
Obwohl es so aussieht, als ob die ISP nur auf Basisklassen anwendbar ist, und obwohl die ISP meist mit Hilfe der objektorientierten Programmierung eingeführt wird, kann die allgemeine Idee, die durch Schnittstellen eingeführten Abhängigkeiten zu minimieren, auch auf Templates angewendet werden. Betrachte zum Beispiel die Funktion std::copy()
:
template
<
typename
InputIt
,
typename
OutputIt
>
OutputIt
copy
(
InputIt
first
,
InputIt
last
,
OutputIt
d_first
);
In C++20 können wir Konzepte anwenden, um die Anforderungen auszudrücken:
template
<
std
::
input_iterator
InputIt
,
std
::
output_iterator
OutputIt
>
OutputIt
copy
(
InputIt
first
,
InputIt
last
,
OutputIt
d_first
);
std::copy()
erwartet ein Paar Eingabe-Iteratoren als den Bereich, aus dem kopiert werden soll, und einen Ausgabe-Iterator für den Zielbereich. Sie verlangt explizit Eingabe- und Ausgabe-Iteratoren, da sie keine andere Operation benötigt. So werden die Anforderungen an die übergebenen Argumente minimiert.
Nehmen wir an, dass std::copy()
std::forward_iterator
anstelle von std::input_iterator
und std::output_iterator
benötigt:
template
<
std
::
forward_iterator
ForwardIt
>
ForwardIt
copy
(
ForwardIt
first
,
ForwardIt
last
,
ForwardIt
d_first
);
Das würde leider den Nutzen des std::copy()
Algorithmus einschränken. Wir wären nicht mehr in der Lage, von Eingabeströmen zu kopieren, da sie in der Regel nicht die Multipass-Garantie bieten und uns das Schreiben nicht ermöglichen. Das wäre bedauerlich. Wenn wir uns jedoch auf die Abhängigkeiten konzentrieren, würde std::copy()
jetzt von Operationen und Anforderungen abhängen, die es nicht braucht. Und Iteratoren, die an std::copy()
übergeben werden, wären gezwungen, zusätzliche Operationen bereitzustellen, sodass std::copy()
Abhängigkeiten erzwingen würde.
Dies ist nur ein hypothetisches Beispiel, aber es zeigt, wie wichtig die Trennung von Belangen bei Schnittstellen ist. Die Lösung ist natürlich die Erkenntnis, dass Eingabe- und Ausgabefähigkeiten getrennte Aspekte sind. Nach der Trennung der Belange und der Anwendung der ISP werden die Abhängigkeiten also deutlich reduziert.
Leitlinie 4: Design für Testbarkeit
Wie in "Leitlinie 1: Die Bedeutung desSoftwaredesignsverstehen" erläutert, ändert sich Software. Es wird erwartet, dass sie sich ändert. Aber jedes Mal, wenn du etwas an deiner Software änderst, läufst du Gefahr, etwas kaputt zu machen. Natürlich nicht absichtlich, sondern versehentlich, trotz deiner besten Bemühungen. Das Risiko besteht immer, aber als erfahrener Entwickler schläfst du deswegen nicht ein. Lass dasRisiko bestehen - es ist dir egal. Du hast etwas, das dich davor schützt, versehentlich etwas kaputt zu machen, etwas, das das Risiko auf ein Minimum reduziert: deine Tests.
Der Zweck von Tests ist es, sicherzustellen, dass alle Funktionen deiner Software auch dann noch funktionieren, wenn sich ständig etwas ändert. Tests sind also deine Schutzschicht, deine Rettungsweste. Tests sind unerlässlich! Um Tests schreiben und diese Schutzschicht aufbauen zu können, muss deine Software testbar sein: Deine Software muss so geschrieben sein, dass es möglich und im besten Fall sogar leicht möglich ist, Tests hinzuzufügen. Das bringt uns zum Kern dieses Leitfadens: Software sollte auf Testbarkeit ausgelegt sein.
Wie man eine private Mitgliedsfunktion testet
"Natürlich habe ich Tests", argumentierst du. "Jeder sollte Tests machen. Das ist doch allgemein bekannt, oder?" Ich stimme dir vollkommen zu. Und ich glaube dir, dass deine Codebasis mit einer vernünftigen Testsuite ausgestattet ist.32 Aber überraschenderweise wird nicht jedes Stück Software in diesem Bewusstsein geschrieben, obwohl alle die Notwendigkeit von Tests anerkennen.33 In der Tat ist eine Menge Code schwer zu testen. Und manchmal liegt das ganz einfach daran, dass der Code nicht dafür ausgelegt ist, getestet zu werden.
Um dir eine Idee zu geben, habe ich eine Herausforderung für dich. Sieh dir die folgende Klasse Widget
an. Widget
enthält eine Sammlung von Blob
Objekten, die von Zeit zu Zeit aktualisiert werden müssen. Zu diesem Zweck stellt Widget
die Mitgliedsfunktion updateCollection()
zur Verfügung, von der wir jetzt annehmen, dass sie so wichtig ist, dass wir einen Test für sie schreiben müssen. Und das ist meine Herausforderung: Wie würdest du die Mitgliedsfunktion updateCollection()
testen?
class
Widget
{
// ...
private
:
void
updateCollection
(
/* some arguments needed to update the collection */
);
std
::
vector
<
Blob
>
blobs_
;
/* Potentially other data members */
};
Ich gehe davon aus, dass du die eigentliche Herausforderung sofort erkennst: Die Mitgliedsfunktion updateCollection()
ist im privaten Bereich der Klasse deklariert. Das bedeutet, dass es keinen direkten Zugriff von außen gibt und somit auch keine direkte Möglichkeit, sie zu testen. Also nimm dir ein paar Sekunden Zeit, um darüber nachzudenken...
"Es ist privat, ja, aber das ist trotzdem keine große Herausforderung. Es gibt mehrere Möglichkeiten, wie ich das machen kann", sagst du. Ich stimme dir zu, es gibt viele Möglichkeiten, die du ausprobieren kannst. Also bitte, mach weiter. Du wägst deine Möglichkeiten ab und kommst dann auf deine erste Idee: "Nun, am einfachsten wäre es, die Funktion über eine andere öffentliche Mitgliedsfunktion zu testen, die intern die Funktion updateCollection()
aufruft." Das klingt nach einer interessanten ersten Idee. Nehmen wir an, dass die Sammlung aktualisiert werden muss, wenn eine neue Blob
hinzugefügt wird. Der Aufruf der Mitgliedsfunktion addBlob()
würde dieupdateCollection()
Funktion:
class
Widget
{
public
:
// ...
void
addBlob
(
Blob
const
&
blob
,
/*...*/
)
{
// ...
updateCollection
(
/*...*/
);
// ...
}
private
:
void
updateCollection
(
/* some arguments needed to update the collection */
);
std
::
vector
<
Blob
>
blobs_
;
/* Potentially other data members */
};
Das klingt zwar vernünftig, ist aber auch etwas, das du nach Möglichkeit vermeiden solltest. Was du vorschlägst, ist ein sogenannter White Box Test. Ein White-Box-Test kennt die internen Implementierungsdetails einer Funktion und testet auf der Grundlage dieses Wissens. Dies führt zu einer Abhängigkeit des Testcodes von den Implementierungsdetails deines Produktionscodes. Das Problem bei diesem Ansatz ist, dass sich Software verändert. Der Code ändert sich. Details ändern sich. Irgendwann in der Zukunft könnte zum Beispiel die Funktion addBlob()
umgeschrieben werden, sodass sie die Sammlung nicht mehr aktualisieren muss. Wenn das passiert, erfüllt dein Test nicht mehr die Aufgabe, für die er geschrieben wurde. Du würdest deinen updateCollection()
Test verlieren, möglicherweise sogar ohne es zu bemerken. Ein White-Box-Test birgt also ein Risiko. Genauso wie du Abhängigkeiten in deinem Produktionscode vermeiden und reduzieren solltest (siehe "Leitlinie 1: Verstehe die Bedeutung desSoftwaredesigns"), solltest du auch Abhängigkeiten zwischen deinen Tests und den Details deines Produktionscodes vermeiden.
Was wir wirklich brauchen, ist ein Blackbox-Test. Ein Blackbox-Test macht keine Annahmen über interne Implementierungsdetails, sondern testet nur das erwartete Verhalten. Natürlich kann ein solcher Test auch scheitern, wenn du etwas änderst, aber er sollte nicht scheitern, wenn sich einige Implementierungsdetails ändern - nur wenn sich das erwartete Verhalten ändert.
"Okay, ich verstehe, was du meinst", sagst du. "Aber du schlägst doch nicht vor, die Funktion updateCollection()
öffentlich zu machen, oder?" Nein, seien Sie versichert, das ist nicht, was ich vorschlage. Natürlich kann das manchmal ein vernünftiger Ansatz sein. Aber in unserem Fall bezweifle ich, dass dies ein kluger Schritt wäre. Die Funktion updateCollection()
sollte nicht einfach so zum Spaß aufgerufen werden. Sie sollte nur aus einem guten Grund aufgerufen werden, nur zum richtigen Zeitpunkt und wahrscheinlich, um eine Art von Invariante zu erhalten. Das ist etwas, das wir einem Benutzer nicht anvertrauen sollten. Also nein, ich glaube nicht, dass die Funktion ein guter Kandidat für den Bereich public
ist.
"OK, gut, nur zur Kontrolle. Dann machen wir den Test einfach zu einem friend
der Klasse Widget
. Auf diese Weise hätte er vollen Zugriff und könnte die Mitgliedsfunktion private
ungehindert aufrufen":
class
Widget
{
// ...
private
:
friend
class
TestWidget
;
void
updateCollection
(
/* some arguments needed to update the collection */
);
std
::
vector
<
Blob
>
blobs_
;
/* Potentially other data members */
};
Ja, wir könnten eine friend
hinzufügen. Nehmen wir an, es gibt die TestWidget
Testvorrichtung, die alle Tests für die Klasse Widget
enthält. Wir könnten dieses Testfixture zu einem friend
der Klasse Widget
machen. Obwohl das nach einem weiteren vernünftigen Ansatz klingt, muss ich leider wieder den Spielverderber spielen. Ja, technisch gesehen würde dies das Problem lösen, aber aus Sicht des Designs haben wir gerade wieder eine künstliche Abhängigkeit eingeführt. Indem wir den Produktionscode aktiv ändern, um die friend
Deklaration einzuführen, weiß der Produktionscode nun über den Testcode Bescheid. Und obwohl der Testcode natürlich über den Produktionscode Bescheid wissen sollte (das ist ja der Sinn des Testcodes), sollte der Produktionscode nicht über den Testcode Bescheid wissen müssen. Dies führt zu einer zyklischen Abhängigkeit, die unglücklich und künstlich ist.
"Du hörst dich an, als ob das das Schlimmste auf der Welt wäre. Ist es wirklich so schlimm?" Nun, manchmal kann das tatsächlich eine vernünftige Lösung sein. Es ist definitiv eine einfache und schnelle Lösung. Aber da wir jetzt die Zeit haben, alle unsere Optionen zu besprechen, muss es auf jeden Fall etwas Besseres geben, als eine friend
hinzuzufügen.
Hinweis
Ich will die Sache nicht noch schlimmer machen, aber in C++ haben wir nicht vielefriend
s. Ja, ich weiß, das klingt traurig und einsam, aber ich meine natürlich das Schlüsselwortfriend
: in C++ ist friend
nicht dein Freund. Der Grund dafür ist, dass friend
s eine Kopplung einführen, meist eine künstliche Kopplung, und die sollten wir vermeiden. Natürlich gibt es Ausnahmen für die guten friend
s, die, ohne die du nicht leben kannst, wie z. B.versteckte Freunde oder idiomatische Verwendungen von friend
, wie z. B. dasIdiom Passkey. Ein Test ist eher wie ein Freund in den sozialen Medien. Einen Test als friend
zu deklarieren, klingt also nicht nach einer guten Wahl.
"OK, dann lass uns von private
zu protected
wechseln und den Test vonder KlasseWidget
ableiten", schlägst du vor. "Auf diese Weise hätte der Test vollen Zugriff auf die updateCollection()
Funktion":
class
Widget
{
// ...
protected
:
void
updateCollection
(
/* some arguments needed to update the collection */
);
std
::
vector
<
Blob
>
blobs_
;
/* Potentially other data members */
};
class
TestWidget
:
private
Widget
{
// ...
};
Nun, ich muss zugeben, dass dieser Ansatz technisch gesehen funktionieren würde. Aber die Tatsache, dass du Vererbung vorschlägst, um dieses Problem zu lösen, zeigt mir, dass wir definitiv über die Bedeutung von Vererbung und ihre richtige Verwendung sprechen müssen. Um die beiden pragmatischen Programmierer zu zitieren:34
Vererbung ist selten die Antwort.
Da wir uns bald auf dieses Thema konzentrieren werden, möchte ich nur sagen, dass es sich so anfühlt, als würden wir die Vererbung nur deshalb missbrauchen, um Zugriff auf nicht-öffentlicheMitgliedsfunktionen zu erhalten. Ich bin mir ziemlich sicher, dass die Vererbung nicht aus diesem Grund erfunden wurde. Die Vererbung zu nutzen, um Zugriff auf den Bereich protected
einer Klasse zu erhalten, ist wie ein Bazooka-Ansatz für etwas, das eigentlich ganz einfach sein sollte. Schließlich ist es fast dasselbe, als wenn man die Funktion public
macht, weil jeder leicht Zugang dazu hat. Es scheint, dass wir die Klasse wirklich nicht so entworfen haben, dass sie leicht zu testen ist.
"Komm schon, was könnten wir sonst tun? Oder willst du wirklich, dass ich den Präprozessor benutze und alle private
Labels als public
definiere?":
#define private public
class
Widget
{
// ...
private
:
void
updateCollection
(
/* some arguments needed to update the collection */
);
std
::
vector
<
Blob
>
blobs_
;
/* Potentially other data members */
};
Okay, lass uns tief durchatmen. Auch wenn dieser letzte Ansatz lustig erscheinen mag, solltest du bedenken, dass wir jetzt den Bereich der vernünftigen Argumente verlassen haben.35
Wenn wir ernsthaft in Erwägung ziehen, den Präprozessor zu benutzen, um uns in den Bereichprivate
der Klasse Widget
einzuhacken, ist alles verloren.
Die wahre Lösung: Getrennte Belange
"OK dann, was soll ich tun, um die private
Mitgliedsfunktion zu testen? Du hast doch schon alle Optionen verworfen." Nein, nicht alle Möglichkeiten. Wir haben noch nicht über den einen Gestaltungsansatz gesprochen, den ich in "Leitlinie 2: Design for Change" hervorgehoben habe: die Trennung von Belangen. Mein Ansatz wäre es, die Memberfunktion private
aus der Klasse herauszulösen und sie zu einer separaten Einheit in unserer Codebasis zu machen. Meine bevorzugte Lösung in diesem Fall ist, die Mitgliedsfunktion als freie Funktion zu extrahieren:
void
updateCollection
(
std
::
vector
<
Blob
>&
blobs
,
/* some arguments needed to update the collection */
);
class
Widget
{
// ...
private
:
std
::
vector
<
Blob
>
blobs_
;
/* Potentially other data members */
};
Alle Aufrufe der vorherigen Mitgliedsfunktion könnten durch einen Aufruf der Funktion freeupdateCollection()
ersetzt werden, indem man einfach blobs_
als erstes Funktionsargument hinzufügt. Wenn die Funktion mit einem bestimmten Zustand verbunden ist, können wir diesen auch in Form einer anderen Klasse extrahieren. In jedem Fall gestalten wir den resultierenden Code so, dass er leicht, vielleicht sogar trivial, zu testen ist:
namespace
widgetDetails
{
class
BlobCollection
{
public
:
void
updateCollection
(
/* some arguments needed to update the collection */
);
private
:
std
::
vector
<
Blob
>
blobs_
;
};
}
// namespace widgetDetails
class
Widget
{
// ...
private
:
widgetDetails
::
BlobCollection
blobs_
;
/* Other data members */
};
"Das kann nicht dein Ernst sein!", rufst du aus. "Ist das nicht die schlechteste aller Möglichkeiten? Trennen wir damit nicht künstlich zwei Dinge, die zusammengehören? Und sagt uns die SVB nicht, dass wir die Dinge, die zusammengehören, nahe beieinander halten sollten?" Nun, das glaube ich nicht. Im Gegenteil, ich bin der festen Überzeugung, dass wir uns erst jetzt an die SVB halten: Die SVB besagt, dass wir die Dinge trennen sollten, die nicht zusammengehören, die Dinge, die sich aus verschiedenen Gründen ändern können. Zugegeben, auf den ersten Blick mag es so aussehen, als ob Widget
und updateCollection()
zusammengehören, denn schließlich muss das Datenmitglied blob_
hin und wieder aktualisiert werden. Die Tatsache, dass die Funktion updateCollection()
nicht richtig testbar ist, ist jedoch ein klares Indiz dafür, dass das Design noch nicht passt: Wenn etwas, das explizit getestet werden muss, nicht getestet werden kann, stimmt etwas nicht. Warum machen wir uns das Leben so schwer und verstecken die zu testende Funktion in demprivate
Abschnitt der Klasse Widget
? Da das Testen bei Veränderungen eine wichtige Rolle spielt, ist das Testen nur eine weitere Möglichkeit, um zu entscheiden, welche Dinge zusammengehören. Wenn die Funktion updateCollection()
so wichtig ist, dass wir sie isoliert testen wollen, dann ändert sie sich offensichtlich aus einem anderen Grund alsWidget
. Das bedeutet, dass Widget
undupdateCollection()
nicht zusammengehören. Auf der Grundlage des SRP sollte die Funktion updateCollection()
aus der Klasse herausgenommen werden.
"Aber ist das nicht gegen die Idee der Verkapselung?", fragst du. "Und wage es nicht, die Verkapselung wegzuwinken. Ich halte die Verkapselung für sehr wichtig!" Ich stimme dir zu, sie ist sehr wichtig, grundsätzlich ja! Allerdings ist die Verkapselungnur ein weiterer Grund, die Konzerne zu trennen. Wie Scott Meyers in seinem BuchEffective C++ behauptet, ist das Ausgliedern vonFunktionen aus einer Klasse ein Schritt zur Erhöhung der Kapselung. Laut Meyers solltest du generell Funktionen, die keine Mitglieder sind (friend
), den Mitgliedsfunktionen vorziehen.36 Das liegt daran, dass jede Member-Funktion vollen Zugriff auf alle Member einer Klasse hat, sogar auf die private
Member. In der extrahierten Form jedoch ist dieupdateCollection()
Funktion jedoch nur auf die Schnittstelle public
der Klasse Widget
beschränkt und kann nicht auf die Mitglieder von private
zugreifen. Daher werden diese private
Mitglieder ein wenig mehr gekapselt. Dasselbe Argument gilt für die Extraktion der Klasse BlobCollection
: Die Klasse BlobCollection
kann nicht auf die nicht-öffentlichen Mitglieder der Klasse Widget
zugreifen, so dass auch Widget
ein wenig mehr gekapselt wird.
Durch die Trennung der Anliegen und die Extraktion dieses Teils der Funktionalität erhältst du mehrere Vorteile. Erstens wird die Klasse Widget
, wie bereits erwähnt, stärker gekapselt. Weniger Mitglieder können auf die Mitglieder von private
zugreifen. Zweitens ist die extrahierteupdateCollection()
Funktion leicht, sogar trivial, testbar. Du brauchst dafür nicht einmal eine Widget
, sondern kannst entweder std::vector<Blob>
als erstes Argument übergeben (nicht das implizite erste Argument einer Mitgliedsfunktion, den this
Zeiger) oder die public
Mitgliedsfunktion aufrufen. Drittens musst du keinen anderen Aspekt der Klasse ändernWidget
Klasse ändern: Du übergibst einfach das Mitglied blobs_
an die Funktion updateCollection()
, wenn du die Sammlung aktualisieren musst. Es ist nicht nötig, einen weiteren public
getter hinzuzufügen. Und, was wahrscheinlich am wichtigsten ist, du kannst die Funktion jetzt isoliert ändern, ohne dich mit Widget
beschäftigen zu müssen. Das bedeutet, dass du die Abhängigkeiten reduziert hast. Während die Funktion updateCollection()
anfangs eng an die Klasse Widget
(ja, den Zeiger this
) gekoppelt war, haben wir diese Bindungen nun gelöst. DieupdateCollection()
Funktion ist jetzt ein eigener Dienst, der sogar wiederverwendet werden kann.
Ich kann sehen, dass du noch Fragen hast. Vielleicht bist du besorgt, dass das bedeutet, dass du keine Mitgliedsfunktionen mehr haben solltest. Nein, um das klarzustellen, ich habe nicht vorgeschlagen, dass du jede einzelne Mitgliedsfunktion aus deinen Klassen herausnehmen sollst. Ich habe lediglich vorgeschlagen, dass du dir die Funktionen genauer ansiehst, die getestet werden müssen, sich aber im private
Abschnitt deiner Klasse befinden. Außerdem fragst du dich vielleicht, wie das mit virtuellen Funktionen funktioniert, die nicht in Form einer freien Funktion extrahiert werden können. Nun, darauf gibt es keine schnelle Antwort, aber wir werden uns im Laufe dieses Buches auf viele verschiedene Arten damit beschäftigen. Mein Ziel ist es immer, die Kopplung zu reduzieren und die Testbarkeit zu erhöhen, auch durch die Trennung von virtuellen Funktionen.
Zusammenfassend lässt sich sagen, dass du dein Design und deine Testbarkeit nicht durch künstliche Kopplung und künstliche Grenzen behindern solltest. Entwirf für die Testbarkeit. Trenne Bedenken. Befreie deine Funktionen!
Leitlinie 5: Design für Erweiterung
gibt es einen wichtigen Aspekt beim Ändern von Software, den ich noch nicht hervorgehoben habe: die Erweiterbarkeit. Erweiterbarkeit sollte eines der Hauptziele deines Designs sein. Denn ehrlich gesagt, wenn du nicht mehr in der Lage bist, deinem Code neue Funktionen hinzuzufügen, hat dein Code das Ende seiner Lebensdauer erreicht. Daher ist das Hinzufügen neuer Funktionen - die Erweiterung der Codebasis - von grundlegendem Interesse. Aus diesem Grund sollte die Erweiterbarkeit eines deiner Hauptziele und ein entscheidender Faktor für gutes Softwaredesign sein.
Das Offen-Schließen-Prinzip
Design für Erweiterungen ist leider nichts, was dir einfach in den Schoß fällt oder sich auf magische Weise ergibt. Nein, du musst die Erweiterbarkeit bei der Entwicklung von Software ausdrücklich berücksichtigen. Ein Beispiel für einen naiven Ansatz zur Serialisierung von Dokumenten haben wir bereits in"Leitlinie 2: Design for Change" gesehen. In diesem Zusammenhang haben wir eine Basisklasse Document
mit einer rein virtuellen Funktion serialize()
verwendet:
class
Document
{
public
:
// ...
virtual
~
Document
()
=
default
;
virtual
void
serialize
(
ByteStream
&
bs
,
/*...*/
)
const
=
0
;
// ...
};
Da serialize()
eine rein virtuelle Funktion ist, muss sie von allen abgeleiteten Klassen implementiert werden, auch von der Klasse PDF
:
class
:
public
Document
{
public
:
// ...
void
serialize
(
ByteStream
&
bs
,
/*...*/
)
const
override
;
// ...
};
So weit, so gut. Die interessante Frage ist: Wie implementieren wir die Mitgliedsfunktionserialize()
? Eine Voraussetzung ist, dass wir die Bytes zu einem späteren Zeitpunkt wieder in eine PDF
Instanz umwandeln können (wir wollen die Bytes wieder in eine PDF-Datei deserialisieren). Dazu ist es wichtig, die Informationen zu speichern, die die Bytes darstellen. In"Leitfaden 2: Design for Change" haben wir dies mit einerAufzählung erreicht:
enum
class
DocumentType
{
,
word
,
// ... Potentially many more document types
};
Diese Aufzählung kann nun von allen abgeleiteten Klassen verwendet werden, um den Typ des Dokuments an den Anfang des Bytestroms zu setzen. Auf diese Weise lässt sich bei der Deserialisierung leicht feststellen, welche Art von Dokument gespeichert ist. Leider hat sich diese Designentscheidung als unglücklich erwiesen. Mit dieser Aufzählung haben wir versehentlich alle Arten von Dokumenten gekoppelt: Die Klasse PDF
kennt das Word-Format. Und natürlich kennt die entsprechende Klasse Word
auch das PDF-Format. Ja, du hast recht - sie wissen nichts über die Implementierungsdetails, aber sie wissen trotzdem voneinander.
Diese Kopplungssituation ist in Abbildung 1-5 dargestellt. Aus architektonischer Sicht befindet sich die Aufzählung DocumentType
auf derselben Ebene wie die Klassen PDF
und Word
. Beide Dokumenttypen verwenden dieAufzählung DocumentType
(und sind daher von ihr abhängig).
Das Problem dabei wird deutlich, wenn wir versuchen, die Funktionalität zu erweitern. Neben PDF und Word wollen wir nun auch ein einfaches XML-Format unterstützen. Im Idealfall müssten wir nur die Klasse XML
als Ableitung der Klasse Document
hinzufügen. Aber leider müssen wir auch die Aufzählung DocumentType
anpassen:
enum
class
DocumentType
{
,
word
,
xml
,
// The new type of document
// ... Potentially many more document types
};
Diese Änderung wird zumindest dazu führen, dass alle anderen Dokumenttypen (PDF, Word, etc.) neu kompiliert werden. Vielleicht zuckst du jetzt nur mit den Schultern und denkst: "Na ja! Es muss nur neu kompiliert werden." Nun, beachte, dass ich " zumindest" gesagt habe. Im schlimmsten Fall hat dieses Design die Möglichkeiten anderer, den Code zu erweitern - d.h. neue Arten von Dokumenten hinzuzufügen - erheblich eingeschränkt, weil nicht jeder in der Lage ist, die Aufzählung DocumentType
zu erweitern. Nein, diese Art der Kopplung fühlt sich einfach nicht richtig an: PDF
und Word
sollten das neue Format XML
gar nicht kennen. Sie sollten nichts sehen oder spüren, nicht einmal eine Neukompilierung.
Das Problem in diesem Beispiel kann als Verstoß gegen das Open-Closed-Prinzip (OCP) erklärt werden. Das OCP ist das zweite der SOLID-Prinzipien. Es rät uns, Software so zu gestalten, dass es einfach ist, die notwendigen Erweiterungen vorzunehmen:37
Software-Artefakte (Klassen, Module, Funktionen usw.) sollten offen für Erweiterungen, aber geschlossen für Änderungen sein.
Die OCP sagt uns, dass wir in der Lage sein sollten, unsere Software zu erweitern (offen für Erweiterungen). Allerdings sollte die Erweiterung einfach sein und im besten Fall nur durch Hinzufügen von neuem Code möglich sein. Mit anderen Worten: Wir sollten den bestehenden Code nicht ändern müssen (geschlossen für Änderungen).
Theoretisch sollte die Erweiterung einfach sein: Wir müssten nur die neue abgeleitete Klasse XML
hinzufügen. Diese neue Klasse allein würde keine Änderungen in einem anderen Teil des Codes erfordern. Leider koppelt die Funktionserialize()
die verschiedenen Arten von Dokumenten künstlich aneinander und erfordert eine Änderung der Aufzählung DocumentType
. Diese Änderung wirkt sich wiederum auf die anderen Arten von Document
aus, was genau das ist, wovon die OCP abrät.
Glücklicherweise haben wir bereits eine Lösung für dasDocument
Beispiel gesehen, wie man das erreichen kann. In diesem Fall ist es das Richtige, die Anliegen zu trennen (siehe Abbildung 1-6).
Durch die Trennung der Bereiche und die Gruppierung der Dinge, die wirklich zusammengehören, wird die zufällige Kopplung zwischen verschiedenen Arten von Dokumenten aufgehoben. Der gesamte Code, der sich mit der Serialisierung befasst, ist nun in der KomponenteSerialization
zusammengefasst, die logischerweise auf einer anderen Ebene der Architektur angesiedelt werden kann. Serialization
hängt von allen Dokumenttypen ab (PDF, Word, XML usw.), aber keiner der Dokumenttypen hängt vonSerialization
ab. Außerdem kennt keines der Dokumente eine andere Art von Dokument (wie es auch sein sollte).
"Moment mal!", sagst du. "Im Code für die Serialisierung brauchen wir doch immer noch die Aufzählung, oder nicht? Wie sollte ich sonst die Informationen darüber speichern, was die gespeicherten Bytes darstellen?" Ich bin froh, dass du diese Beobachtung gemacht hast. Ja, innerhalb derSerialization
Komponente werden wir (höchstwahrscheinlich) immer noch so etwas wie die DocumentType
Aufzählung brauchen. Durch die Trennung der Bereiche haben wir dieses Abhängigkeitsproblem jedoch gut gelöst. Keiner der verschiedenen Dokumenttypen hängt mehr von der Aufzählung DocumentType
ab. Alle Abhängigkeitspfeile gehen jetzt von der niedrigen Ebene (derSerialization
Komponente) zur hohen Ebene (PDF
und Word
). Und diese Eigenschaft ist für eine ordentliche, gute Architektur unerlässlich.
"Aber was ist mit dem Hinzufügen einer neuen Art von Dokument? Erfordert das nicht eine Änderung in der Komponente Serialization
?" Auch hier hast du absolut Recht. Dennoch ist dies kein Verstoß gegen die OCP, die uns rät, bestehenden Code auf derselben oder einer höheren Architekturebene nicht zu ändern. Es gibt jedoch keine Möglichkeit, Änderungen auf den unteren Ebenen zu kontrollieren oder zu verhindern. Serialization
muss auf alle Arten von Dokumenten angewiesen sein und muss daher für jede neue Art von Dokument angepasst werden. Aus diesem Grund muss Serialization
auf einer niedrigeren Ebene (man denke an die abhängige Ebene) unserer Architektur angesiedelt sein.
Wie auch in "Leitlinie 2: Design for Change" besprochen wurde, ist die Lösung in diesem Beispiel die Trennung der Anliegen. Es sieht also so aus, als ob die eigentliche Lösung darin besteht, an der SVB festzuhalten. Aus diesem Grund gibt es einige kritische Stimmen, die die OCP nicht als separates Prinzip betrachten, sondern als dasselbe wie die SRP. Ich gebe zu, dass ich diese Argumentation verstehe. Sehr oft führt die Trennung der Anliegen bereits zu der gewünschten Erweiterbarkeit. Das werden wir im Laufe dieses Buches immer wieder erleben,vor allem wenn wir über Entwurfsmuster sprechen. Es ist also naheliegend, dass SRP und OCP miteinander verwandt oder sogar dasselbe sind.
Andererseits haben wir in diesem Beispiel gesehen, dass es einige spezifische, architektonische Überlegungen zum OCP gibt, die wir bei der Diskussion über den SRP nicht berücksichtigt haben. Wie wir in "Leitfaden 15: Design für das Hinzufügen vonTypen oder Operationen" erfahren werden , müssen wir oft explizit entscheiden, was wir erweitern wollen und wie wir es erweitern wollen. Diese Entscheidung kann einen großen Einfluss darauf haben, wie wir die SRP anwenden und wie wir unsere Software gestalten. Daher scheint es bei der OCP mehr um das Bewusstsein für Erweiterungen und bewusste Entscheidungen über Erweiterungen zu gehen als bei der SRP. Als solche ist sie vielleicht ein bisschen mehr als nur ein Nebeneffekt der SRP. Oder vielleicht kommt es einfach darauf an.38
Wie auch immer, dieses Beispiel zeigt unbestreitbar, dass die Erweiterbarkeit bei der Softwareentwicklung explizit berücksichtigt werden sollte und dass der Wunsch, unsere Software auf eine bestimmte Art und Weise zu erweitern, ein hervorragendes Indiz für die Notwendigkeit ist, die Belange zu trennen. Es ist wichtig zu verstehen, wie die Software erweitert werden soll, solche Anpassungspunkte zu identifizieren und so zu gestalten, dass diese Art der Erweiterung einfach durchgeführt werden kann.
Erweiterbarkeit zur Kompilierzeit
Das Beispiel Document
könnte den Eindruck erwecken, dass alle diese Designüberlegungen für die Laufzeit-Polymorphie gelten. Nein, ganz und gar nicht: Die gleichen Überlegungen und Argumente gelten auch für Kompilierzeitprobleme. Um das zu veranschaulichen, greife ich jetzt zu ein paar Beispielen aus der Standardbibliothek. Natürlich ist es von größtem Interesse, dass du die Standard Library erweitern kannst. Ja, du sollst die Standardbibliothekverwenden, aber du sollst auch darauf aufbauen und eigene Funktionen hinzufügen. Aus diesem Grund ist die Standard Library auf Erweiterbarkeit ausgelegt. Interessanterweise nutzt sie dafür aber keine Basisklassen, sondern baut in erster Linie auf Funktionsüberladung, Templates und (Klassen-)Vorlagenspezialisierung.
Ein hervorragendes Beispiel für die Erweiterung durch Funktionsüberladung ist derstd::swap()
Algorithmus. Seit C++11 ist std::swap()
auf diese Weise definiert:
namespace
std
{
template
<
typename
T
>
void
swap
(
T
&
a
,
T
&
b
)
{
T
tmp
(
std
::
move
(
a
)
);
a
=
std
::
move
(
b
);
b
=
std
::
move
(
tmp
);
}
}
// namespace std
Da std::swap()
als Funktionsvorlage definiert ist, kannst du es für jeden Typ verwenden: für grundlegende Typen wie int
und double
, für Typen der Standardbibliothek wie std::string
und natürlich für deine eigenen Typen. Es kann jedoch einige Typen geben, die besonderer Aufmerksamkeit bedürfen, einige Typen, die nicht mit Hilfe von std::swap()
ausgetauscht werden können oder sollten (zum Beispiel, weil sie nicht effizient verschoben werden können), aber dennoch mit anderen Mitteln effizient ausgetauscht werden könnten. Dennoch wird erwartet, dass Wertetypen getauscht werden können, wie es auch in derCore Guideline C.83 zum Ausdruck kommt:39
Für wertähnliche Typen solltest du eine
noexcept
Swap-Funktion einrichten.
In einem solchen Fall kannst du std::swap()
für deinen eigenen Typ überladen:
namespace
custom
{
class
CustomType
{
/* Implementation that requires a special form of swap */
};
void
swap
(
CustomType
&
a
,
CustomType
&
b
)
{
/* Special implementation for swapping two instances of type 'CustomType' */
}
}
// namespace custom
Wenn swap()
richtig verwendet wird, führt diese benutzerdefinierte Funktion eine spezielle Art von Tauschoperation auf zwei Instanzen von CustomType
durch:40
template
<
typename
T
>
void
some_function
(
T
&
value
)
{
// ...
T
tmp
(
/*...*/
);
using
std
::
swap
;
// Enable the compiler to consider std::swap for the
// subsequent call
swap
(
tmp
,
value
);
// Swap the two values; thanks to the unqualified call
// and thanks to ADL this would call 'custom::swap()'
// ... // in case 'T' is 'CustomType'
}
Natürlich ist std::swap()
als Anpassungspunkt konzipiert, der es dir ermöglicht, neue benutzerdefinierte Typen und Verhaltensweisen hinzuzufügen. Das Gleiche gilt für alle Algorithmen in der Standardbibliothek. Betrachte zum Beispiel std::find()
und std::find_if()
:
template
<
typename
InputIt
,
typename
T
>
constexpr
InputIt
find
(
InputIt
first
,
InputIt
last
,
T
const
&
value
);
template
<
typename
InputIt
,
typename
UnaryPredicate
>
constexpr
InputIt
find_if
(
InputIt
first
,
InputIt
last
,
UnaryPredicate
p
);
Mit Hilfe der Templating-Parameter und implizit der entsprechenden Konzepte ermöglichenstd::find()
und std::find_if()
(genau wie alle anderen Algorithmen) die Verwendung eigener (Iterator-)Typen zur Durchführung einer Suche. Außerdem kannst du mitstd::find_if()
anpassen, wie der Vergleich von Elementen gehandhabt wird. Diese Funktionen sind also definitiv für Erweiterungen und Anpassungen gedacht.
Die letzte Art der Anpassung ist die Spezialisierung der Templates. Dieser Ansatz wird z. B. von der Klassenvorlage std::hash
verwendet. Ausgehend von der Vorlage CustomType
aus dem Beispiel std::swap()
können wirstd::hash
explizit spezialisieren:
template
<>
struct
std
::
hash
<
CustomType
>
{
std
::
size_t
operator
()(
CustomType
const
&
v
)
const
noexcept
{
return
/*...*/
;
}
};
Das Design von std::hash
versetzt dich in die Lage, das Verhalten für jeden benutzerdefinierten Typ anzupassen. Das Wichtigste dabei ist, dass du keinen bestehenden Code ändern musst; es reicht aus, diese separate Spezialisierung bereitzustellen, um sie an besondere Anforderungen anzupassen.
Fast die gesamte Standardbibliothek ist für Erweiterungen und Anpassungen ausgelegt. Das sollte allerdings nicht überraschen, denn die Standardbibliothek soll eine der höchsten Ebenen in deiner Architektur darstellen. Die Standard Library kann also nicht von irgendetwas in deinem Code abhängen, sondern du bist vollständig von der Standard Library abhängig.
Vermeide eine verfrühte Planung für die Erweiterung
Die C++ Standardbibliothek ist ein großartiges Beispiel für die Entwicklung von Erweiterungen. Hoffentlich bekommst du dadurch ein Gefühl dafür, wie wichtig Erweiterbarkeit wirklich ist. Aber auch wenn Erweiterbarkeit wichtig ist, heißt das nicht, dass du automatisch und unreflektiert zu Basisklassen oder Templates für jedes mögliche Implementierungsdetail greifen solltest, nur um die Erweiterbarkeit in der Zukunft zu garantieren. Genauso wie du Bedenken nicht voreilig trennen solltest, solltest du auch nicht voreilig für die Erweiterung entwerfen. Wenn du natürlich eine gute Vorstellung davon hast, wie sich dein Code entwickeln wird, dann solltest du ihn auf jeden Fall entsprechend gestalten. Erinnere dich jedoch an das YAGNI-Prinzip: Wenn du nicht weißt, wie sich der Code entwickeln wird, ist es vielleicht klüger, zu warten, anstatt eine Erweiterung vorwegzunehmen, die nie eintreten wird. Vielleicht gibt dir die nächste Erweiterung einen Hinweis auf zukünftige Erweiterungen, so dass du den Code so umgestalten kannst, dass spätere Erweiterungen einfach sind. Andernfalls könntest du auf das Problem stoßen, dass die Bevorzugung einer Art von Erweiterung andere Arten von Erweiterungen viel schwieriger macht (siehe z. B. "Richtlinie 15: Design für das Hinzufügen vonTypen oder Operationen"). Das solltest du nach Möglichkeit vermeiden.
Zusammenfassend lässt sich sagen, dass das Design für Erweiterungen ein wichtiger Teil des Designs für Veränderungen ist. Halte daher explizit Ausschau nach Funktionalitäten, von denen erwartet wird, dass sie erweitert werden, und gestalte den Code so, dass die Erweiterung einfach ist.
1 Aber natürlich würdest du nie versuchen, den aktuellen C++-Standard zu drucken. Du würdest entweder eine PDF-Datei des offiziellen C++-Standards oder den aktuellen Arbeitsentwurf verwenden. Für den Großteil deiner täglichen Arbeit solltest du jedoch auf die C++ Referenzseite zurückgreifen.
2 Leider kann ich keine Zahlen vorlegen, denn ich kann kaum behaupten, dass ich einen vollständigen Überblick über das riesige Gebiet von C++ habe. Im Gegenteil, ich habe vielleicht nicht einmal einen vollständigen Überblick über die Quellen, die mir bekannt sind! Betrachte dies also bitte als meinen persönlichen Eindruck und die Art und Weise, wie ich die C++-Gemeinschaft wahrnehme. Vielleicht hast du einen anderen Eindruck.
3 Ob die Code-Änderung riskant ist oder nicht, kann sehr stark von deiner Testabdeckung abhängen. Eine gute Testabdeckung kann einen Teil des Schadens auffangen, den ein schlechtes Softwaredesign verursachen kann.
4 Kent Beck, Test-Driven Development: By Example (Addison-Wesley, 2002).
5 Robert C. Martin, Clean Architecture (Addison-Wesley, 2017).
6 Dies sind meine eigenen Worte, denn es gibt keine einheitliche Definition von Softwaredesign. Du magst also deine eigene Definition von Softwaredesign haben, und das ist auch völlig in Ordnung. Beachte jedoch, dass dieses Buch, einschließlich der Diskussion über Entwurfsmuster, auf meiner Definition basiert.
7 Nur um das klarzustellen: Informatik ist eine Wissenschaft (das steht schon im Namen). Softwareentwicklung scheint eine Mischform aus Wissenschaft, Handwerk und Kunst zu sein. Und ein Aspekt des Letzteren ist das Softwaredesign.
8 Mit dieser Metapher will ich nicht andeuten, dass Architekten für Gebäude den ganzen Tag auf der Baustelle arbeiten. Sehr wahrscheinlich verbringt ein solcher Architekt genauso viel Zeit in einem bequemen Sessel und vor einem Computer wie Menschen wie du und ich. Aber ich denke, du verstehst, worauf ich hinaus will.
9 Substitution Failure Is Not An Error (SFINAE) ist ein grundlegender Templating-Mechanismus, der häufig als Ersatz für C++20-Konzepte zur Einschränkung von Templates verwendet wird. Eine Erklärung von SFINAE und insbesondere von std::enable_if
findest du in deinem Lieblingslehrbuch über C++-Vorlagen. Wenn du keins hast, ist die C++ Template Bibel eine gute Wahl: David Vandevoorde, Nicolai Josuttis und Douglas Gregors C++ Templates: The Complete Guide (Addison-Wesley).
10 Viele weitere Informationen zum physischen und logischen Abhängigkeitsmanagement findest du in John Lakos' Buch "Dam", Large-Scale C++ Software Development: Process and Architecture (Addison-Wesley).
11 Martin Fowler, "Wer braucht einen Architekten?" IEEE Software, 20, no. 5 (2003), 11-13, https://doi.org/10.1109/MS.2003.1231144.
12 Eine sehr gute Einführung in Microservices findet sich in Sam Newmans Buch Building Microservices: Designing Fine-Grained Systems, 2. Aufl. (O'Reilly).
13 Mark Richards und Neal Ford, Fundamentals of Software Architecture: An Engineering Approach (O'Reilly, 2020).
14 Der Begriff Implementierungsmuster wurde erstmals in Kent Becks Buch Implementation Patterns (Addison-Wesley) verwendet. In diesem Buch verwende ich diesen Begriff, um eine klare Abgrenzung zum Begriff Design Pattern zu schaffen, denn der Begriff Idiom kann sich auf ein Pattern auf der Ebene des Software-Designs oder auf der Ebene der Implementierungsdetails beziehen. Ich werde den Begriff durchgängig für häufig verwendete Lösungen auf der Ebene der Implementierungsdetails verwenden.
15 Zweitliebstes Buch nach diesem hier, natürlich. Wenn dies dein einziges Buch ist, dann solltest du dir den Klassiker Effective C++: 55 Specific Ways to Improve Your Programs and Designs, 3.
16 Die Template-Methode und die Bridge-Entwurfsmuster sind 2 der 23 klassischen Entwurfsmuster, die in dem sogenannten Gang of Four (GoF)-Buch von Erich Gamma et al. vorgestellt wurden, Design Patterns: Elements of Reusable Object-Oriented Software. Ich werde in diesem Buch nicht näher auf die Template-Methode eingehen, aber du findest gute Erklärungen in verschiedenen Lehrbüchern, auch im GoF-Buch selbst. Das Entwurfsmuster Bridge erkläre ich jedoch in "Leitfaden 28: Brücken bauen, umphysische Abhängigkeitenzu beseitigen".
17 Bjarne Stroustrup, The C++ Programming Language, 3. Aufl. (Addison-Wesley, 2000).
18 Hut ab vor John Lakos, der ähnlich argumentiert und C++98 in seinem Buch Large-Scale C++ Software Development verwendet : Process and Architecture (Addison-Wesley).
19 Ja, Ben und Jason, ihr habt richtig gelesen, ich werde nicht constexpr
ALLE Dinge. Siehe Ben Deane und Jason Turner, "constexpr ALL the things", CppCon 2017.
20 Michael Feathers, Working Effectively with Legacy Code (Addison-Wesley, 2013).
21 David Thomas und Andrew Hunt, The Pragmatic Programmer: Your Journey to Mastery, 20th Anniversary Edition (Addison-Wesley, 2019).
22 Tom DeMarco, Structured Analysis and System Specification (Prentice Hall, 1979).
23 SOLID ist ein Akronym von Akronymen, eine Abkürzung für die fünf Grundsätze, die in den folgenden Leitlinien beschrieben werden: SRP, OCP, LSP, ISP und DIP.
24 Das erste Buch über die SOLID-Prinzipien war Robert C. Martin's Agile Software Development: Principles, Patterns, and Practices (Pearson). Eine neuere und viel günstigere Alternative ist Clean Architecture, ebenfalls von Robert C. Martin (Addison-Wesley).
25 Vergiss nicht, dass sich die Designentscheidungen dieser externen Bibliothek auf dein eigenes Design auswirken können, was natürlich die Kopplung erhöhen würde.
26 Das gilt auch für die Klassen, die andere geschrieben haben, d.h. Klassen, die du nicht kontrollierst. Und nein, die anderen Leute werden über die Änderung nicht glücklich sein. Daher kann die Änderung wirklich schwierig sein.
27 Eine Aufzählung scheint die naheliegendste Wahl zu sein, aber es gibt natürlich auch andere Möglichkeiten. Letztendlich brauchen wir eine vereinbarte Menge von Werten, die die verschiedenen Dokumentenformate in der Byte-Darstellung repräsentieren.
28 Du wunderst dich vielleicht über die explizite Verwendung des Schlüsselworts explicit
für diesen Konstruktor. Dann weißt du vielleicht auch, dass die Core Guideline C.46 empfiehlt, explicit
standardmäßig für Konstruktoren mit einem Argument zu verwenden. Das ist ein wirklich guter und sehr empfehlenswerter Ratschlag, da er unbeabsichtigte und möglicherweise unerwünschte Konvertierungen verhindert. Der gleiche Ratschlag ist zwar nicht so wertvoll, aber auch für alle anderen Konstruktoren sinnvoll, außer für die Konstruktoren copy und move, die keine Konvertierung durchführen. Zumindest schadet es nicht.
29 Vielleicht hast du bemerkt, dass ich die Namen der drei Konferenzen gewählt habe, die ich regelmäßig besuche: CppCon, Meeting C++ und C++ on Sea. Es gibt aber noch viel mehr C++-Konferenzen. Um ein paar Beispiele zu nennen: ACCU, Core C++, pacific++, CppNorth, emBO++, und CPPP. Konferenzen sind eine tolle und unterhaltsame Möglichkeit, um in Sachen C++ auf dem Laufenden zu bleiben. Informiere dich auf der Homepage der Standard C++ Foundation über alle anstehenden Konferenzen.
30 Katerina Trajchevska, "Becoming a Better Developer by Using the SOLID Design Principles", Laracon EU, 30. und 31. August 2018.
31 Robert C. Martin, Agile Softwareentwicklung: Principles, Patterns, and Practices.
32 Wenn du keine Testsuite hast, hast du noch einiges zu tun. Ganz im Ernst. Eine sehr gute Referenz für den Einstieg ist Ben Saks' Vortrag über Unit Tests, "Back to Basics: Unit Tests", von der CppCon 2020. Ein zweites, sehr gutes Nachschlagewerk zum Thema Testen und testgetriebene Entwicklung ist Jeff Langr's Buch Modern C{plus}{plus} Programming with Test-Driven Development (O'Reilly).
33 Ich weiß, "alle sind sich einig" ist leider weit von der Realität entfernt. Wenn du einen Beweis dafür brauchst, dass die Ernsthaftigkeit von Tests noch nicht in jedem Projekt und bei jedem Entwickler angekommen ist, dann schau dir dieses Problem aus dem OpenFOAM Issue Tracker an.
34 David Thomas und Andrew Hunt, The Pragmatic Programmer: Deine Reise zur Meisterschaft.
35 Wir sind vielleicht sogar in den unheimlichen Bereich des undefinierten Verhaltens vorgedrungen.
36 Dieses überzeugende Argument findest du in Punkt 23 von Scott Meyers' Effective C++.
37 Bertrand Meyer, Object-Oriented Software Construction, 2nd ed. (Pearson, 2000).
38 Die Antwort "Es kommt darauf an!" wird natürlich auch die schärfsten Kritiker/innen der OCP zufriedenstellen.
39 Die C++ Core Guidelines sind ein Versuch der Gemeinschaft, eine Reihe von Richtlinien für das Schreiben von gutem C++-Code zu sammeln und sich darauf zu einigen. Sie repräsentieren am besten den gemeinsamen Sinn dessen, was idiomatisches C++ ist. Du kannst diese Richtlinien auf GitHub finden.
40 Die Abkürzung ADL steht für Argument Dependent Lookup. Eine Einführung findest du in der CppReference oder in meinem Vortrag auf der CppCon 2020.
Get C++ Software Design now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.