Bei Vererbung immer kompatibel zur Basisklasse bleiben?

Python und das Qt-Toolkit, erstellen von GUIs mittels des Qt-Designers.
Antworten
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

In Launchit benutze ich zum Rendern von Vervollständigungen eine von `QTextDocument` abgeleitete Klasse. `QTextDocument` selbst hat einen C++-typischen überladenen Konstruktor, der als Argument entweder eine Referenz auf das Elternobjekt oder alternativ eine Zeichenkette und das Elternelement erwartet, wobei die Zeichenkette ein Text sein sollte, den die Klasse sozusagen als ihren Startwert für die später mögliche Anzeige benutzt.

Jetzt ist meine abgeleitete Klasse namens `MarkedCompletionRenderer` eine Art spezialisierte Form dieses Renderers, die zusätzlich Funktionalität zum Hevorheben eines Fragments innerhalb einer Vervollständigung anbietet (Erzeugung des passenden Markups). Ich dachte mir, dass dieser Umstand durchaus eine Vererbung rechtfertigt, auch wenn bei der Verwendung in der Praxis sicherlich einiges von den zusätzlichen Methoden, die das Qt-Widget einem so bietet, eher irrelevant ist.

So, jetzt brauche ich aber in meiner `__init__`-Methode keinen Startwert für irgendwelchen Text. Das einzige, was konfiguriert werden kann, ist das Markup (bzw die Tags), welches das Fragment später umschließen soll, sowie das Fragment an sich (s. Quelltext).

Sollte die abgeleitete Klasse jetzt trotzdem die Zeichenkette für den Rohtext annehmen (macht sie zur Zeit nicht), damit Stellen, wo sich z.B. auf einen `isinstance()`-Test verlassen wird, nicht problematisch werden, weil der Konstruktor plötzlich ganz anders vorgeht, oder kann ich die jetzige Version so bestehen lassen, wie sie ist? Einerseits denke ich an die genannten möglich Probleme, andererseits finde ich es recht sinnfrei, auf dieses in diesem Kontext eher überflüssige Argument Rücksicht zu nehmen. Was ist also der beste Weg? Oder sollte ich hier komplett mein Design überdenken?
lunar

@snafu: Es spricht nichts dagegen, in einer Kindklasse einen anderen Konstruktor zu definieren als in der Elternklasse.

PS: An Weihnachten sollte man über sowas nicht nachdenken :)
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

@lunar: Ich "durfte" sogar heute arbeiten und morgen, am 2. Weihnachtstag, wieder. :)

Zum Thema: Also, eine so knappe Antwort hätte ich jetzt eigentlich nicht erwartet. Ich dachte, Vererbung bedeutet, dass die erbende Klasse mindestens die Funktionalität ihrer Elternklasse besitzen sollte. Wenn mein Konstruktor jetzt das Setzen des "Plain Textes" nicht mehr unterstützt, dann fehlt ihm doch eigentlich etwas. Daraus würde ja unweigerlich folgen, dass andere Funktionen sich eigentlich nur bei direkten `type()`-Checks, darauf verlassen können, dass das von ihnen erwartete Verhalten auch wirklich implementiert wird, oder etwa nicht?
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Hm, keine Kommentare? Lieg ich etwa falsch mit meiner Einschätzung? :o
webspider
User
Beiträge: 485
Registriert: Sonntag 19. Juni 2011, 13:41

Nein, du bist nur nicht geduldig genug. Wüsste ich es, würde ich es dir sagen, aber so muss wohl auf unsere handvoll Experten gewartet werden :mrgreen:
BlackJack

@snafu: Eigentlich würde ich Dir zustimmen, dass eine abgeleitete Klasse eine Basisklasse erweitern sollte, aber es gibt auch Fälle wo das nicht möglich ist, oder keinen Sinn macht. Andererseits könnte es doch Anwendungsfälle geben, wo man das gerne vorausgefüllt hätte, oder?
lunar

@snafu: In der beschriebenen Situation kann der Fall, dass ein Programmteil den Konstruktor Deiner Klasse unter der Annahme, es handle sich um die Basisklasse, aufruft, überhaupt nur dann auftreten, wenn Du Deine Klasse als Objekt an diesen Programmteil übergibst. im Allgemeinen ist das schon ungewöhnlich, wie oft reicht man den schon Klassenobjekte umher, in Qt im Speziellen tritt diese Situation nie auf, da Qt an keiner Stelle Klassenobjekte entgegen nimmt, um dann Exemplare aus dieser Klasse zu erstellen. Der Konstruktor wird also nur in Deinem eigenen Quelltext aufgerufen, und kann mithin auch diejenige Signatur tragen, die Du für sinnvoll erachtest.

Ich glaube, Du machst Dir über solche Entwurfsfragen viel zu viele Gedanken, und siehst Probleme zu dogmatisch. In erster Linie geht es darum, Probleme zu lösen, und zwar mit möglichst wenig Quelltext. Vererbung ist a priori auch nur ein Mittel, unnötigen Quelltext zu vermeiden, besonders in Sprachen wie Python, in denen Schnittstelle und Typhierarchie weitestgehend unabhängig voneinander sind. Die Schlussfolgerung, dass man sich nur durch eine Prüfung auf einen speziellen Typen des erwarteten Verhaltens vergewissern kann, ist auch vollkommen richtig, nicht nur in Python, sondern im Wesentlichen auch in jeder anderen Sprache.
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Okay, ihr habt mich überzeugt. Ich kann jetzt nachvollziehen, dass von einer 100%igen Abwärtskompatibilität zur Basisklasse im Konstruktor durchaus abgesehen werden kann in Fällen, wo es Sinn macht, eine entsprechend modifizierte Signatur zu verwenden - und das macht es hier auf jeden Fall. Denn sofern wirklich das Klassenobjekt an sich irgendwo erwartet wird, dann geschieht dies ja in aller Regel in einem Kontext, wo dem Benutzer bewusst ist, was er da übergibt. Er könnte z.B. das Klassenobjekt an eine Funktion übergeben, die ein Exemplar von eben dieser Klasse erzeugt und irgendwelche weiteren Vorbereitungen auf dem Exemplar trifft. Falls das Erzeugen des Exemplars unerwartet "komisch" verläuft (Fehlinterpretation seitens des modifizierten Konstruktors von irgendwelchen Parametern), dann kann wohl mit gutem Gewissen argumentiert werden, dass die besagte Funktion eben grundsätzlich die Basisklasse X erwartet und sich darauf verlässt, dass ein Klassenobjekt entsprechend das Verhalten von dieser Klasse X zeigt - und dafür ist letzlich der Benutzer verantwortlich.

Was die besagten `isinstance()`-Checks wiederum angeht, so beziehen sich diese naturgemäß ja auf *Exemplare* von Klassen, die an dieser Stelle noch gar nicht zur Debatte stehen. Da hab ich jetzt schlichtweg aus dem falschen Blickwinkel gedacht. Danke für die "Erleuchtung". :)
Benutzeravatar
pillmuncher
User
Beiträge: 1484
Registriert: Samstag 21. März 2009, 22:59
Wohnort: Pfaffenwinkel

Es wundert mich ein wenig, dass noch keiner das Liskov Substitution Principle erwähnt hat. Demzufolge sollten abgeleitete Typen so konstruiert sein, dass deren Objekte jederzeit für Objekte ihres Basistyps eingesetzt werden können. Oder andersrum formuliert: Wenn ein System Objekte eines bestimmten Typs verwendet, dann sollten davon abgeleitete Typen so gebaut sein, das sie ebenfalls verwendet werden können, ohne dass das System zusätzlich agepasst werden muss. Ob die __init__()-Methode in diesem Sinn zur Signatur des Typs gehört, hängt folglich vom System ab und muss im Einzelfall beurteilt werden. Wenn solcherart Objekte innerhalb des Systems erzeugt werden müssen, dann könnte man diese Erzeugung immer noch hinter einem Funktionsaufruf verstecken, oder wie hier hinter closures + callbacks:

Code: Alles auswählen

class Base(object):
    def __init__(self, base_param):
        ...
    def foo(self):
        ....

class Derived(Base):
    def __init__(self, base_param, derived_param):
        ...
    def foo(self):
        ...

class SomeSystem(object):
    def bar(self, create):
        obj = create(42)
        obj.foo()

def create_base(base_param):
    return Base(base_param)

def derived_factory(get_derived_param):
    def create_derived(base_param):
        return Derived(base_param, get_derived_param())
    return create_derived

def get_jim():
    # hier könnten beliebig komplexe Berechnungen zur Ermittlung des richtigen Parameter-Wertes stehen.
    return 'jim' # der Einfachheit halber...

def get_joe():
    # hier auch
    return 'joe'

system = SomeSystem()
system.bar(create_base)
system.bar(derived_factory(get_jim))
system.bar(derived_factory(get_joe))
In specifications, Murphy's Law supersedes Ohm's.
lunar

@pillmuncher: In Python kann man dieses Prinzip letztlich beliebig auslegen, da die abstrakte Schnittstelle eines Typen von dessen Vererbungshierarchie letztlich vollkommen unabhängig ist. Mithin kann man beliebige Subtyprelationen definieren, und letztlich jeden Typen für jeden anderen substituieren, solange die Schnittmenge der Signatur für die spezifische Situation ausreichend groß ist. Mit der Antwort, dass es vom Einzelfall abhängt, ob "__init__()" zur Signatur gehört, ist snafu nicht geholfen. Schließlich sucht er eine Lösung für genau diesen Einzelfall, und keine dogmatische Herleitung irgendwelcher Entwurfsprinzipien.
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Aber beim Reimplementieren von Methoden sollten die Bestandteile der alten Methodensignatur auf jeden Fall übernommen werden, oder?

Ich plane nämlich gerade, die drawMarkup()-Methode meines Delegates in den Renderer zu überführen. Innerhalb dieser Methode wird der verwendete Painter so konfiguriert, dass er an der richtigen Stelle mit dem Zeichnen beginnt. Das gehört für mich nicht mehr zu den Aufgaben eines Delegates, da es hier mehr oder weniger um die interne Umsetzung von Zeichenoperationen geht.

Im Renderer möchte ich dann die Methode drawContents() der Basisklasse `QTextDocument` ersetzen. Sie hat im "Original" die Signatur `drawContents(painter[, rect=QRectF()])`, wobei `rect` nur für's Clipping relevant ist und in meinem Fall nicht benutzt wird. Zusätzlich soll diese Methode das Argument `startPos` erwarten, damit so vorgegangen werden kann, wie im o.g. `drawMarkup()`, was dann wie gesagt wegfallen würde.

Ist es jetzt guter Programmierstil, das `rect`-Argument weiterhin in der Signatur stehen zu haben oder kann man es unter den Tisch fallen lassen? Macht es, falls es stehen bleibt, einen negativen Unterschied, ob ich es an der zweiten Stelle in der Signatur belasse oder ob ich es ganz nach hinten schiebe? Ich würde auf beide Fragen mit Ja antworten wollen, so dass die Signatur am Ende IMHO so aussehen müsste: `drawContents(self, painter, rect=QRectF(), startPos=None)`, damit sie 100% abwärtskompatibel bleibt.

Ich weiß, das sind Kleinigkeiten, die aufhalten, aber irgendwo geht es ja auch um gutes Design, was am Ende durchaus von Vorteil sein kann. Zudem nutze ich Launchit als eine Art Lernprojekt für mich, um bestimmte Programmierparadigmen anwenden zu können und natürlich auch, um einschätzen zu können, wann gewisse Dinge sinnvoll sind und wann eben nicht. Lunar hatte das ja schon treffend angesprochen.

Wie würdet ihr also an dieser Stelle vorgehen? Mein Bauch sagt `drawContents(self, painter, startPos)`, aber "förmlicher" wäre ja eigentlich das, was ich zuvor gezeigt hatte. Oder gilt am Ende wieder das selbe wie für Konstruktoren, also dass diese Überlegung in der Praxis keine große Rolle spielt? Wie ihr merkt, bin ich hier gerade etwas verunsichert...
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

Sind meine hier getroffenen Überlegungen einfach zu banal, als dass jemand Lust hätte, darauf zu einzugehen? Schade, ich finde gerade sowas eigentlich recht spannend... :(
Benutzeravatar
pillmuncher
User
Beiträge: 1484
Registriert: Samstag 21. März 2009, 22:59
Wohnort: Pfaffenwinkel

snafu hat geschrieben:Aber beim Reimplementieren von Methoden sollten die Bestandteile der alten Methodensignatur auf jeden Fall übernommen werden, oder?
Angenommen, dies ist der Code:

Code: Alles auswählen

class Base(object):
    def foo(self, n, rect=Rect((1, 2), (3, 4))):
        ...

def bar(o):
    o.foo(7)

...

b = Base()
bar(b)

class Derived(Base):
    def foo(self, n):
        ...

d = Derived()
bar(d)
dann ist das solange in Ordnung, wie niemand auf die Idee kommt, bar zu ändern:

Code: Alles auswählen

def bar(o):
    o.foo(7, Rect((5, 6), (7, 8)))
In diesem Fall wäre der Code kaputt. Um Fälle wie diesen von vorneherein auszuschließen, sollten abgeleitete Typen immer kompatibel zu ihrem Basistyp bleiben. Kompatibel heißt hier, dass die Verwendbarkeit der Objekte abgeleiteter Typen nicht eingeschränkt ist ggü. der der Objekte des Basistyps. Wie lunar schon schrieb, ist das Konzept des Typs in Python etwas abstraktes, das nicht unbedingt der Vererbungshierarchie folgen muss.

Übrigens wäre eine Erweiterung der Verwendbarkeit in Ordnung:

Code: Alles auswählen

class Derived(Base):
    def foo(self, n, rect=Rect((1, 2), (3, 4)), another_rect=Tect((9, 8), (7, 6)):
        ...
Das wäre immer noch kompatibel.

Gruß,
Mick.
Zuletzt geändert von pillmuncher am Mittwoch 4. Januar 2012, 10:54, insgesamt 1-mal geändert.
In specifications, Murphy's Law supersedes Ohm's.
Benutzeravatar
snafu
User
Beiträge: 6740
Registriert: Donnerstag 21. Februar 2008, 17:31
Wohnort: Gelsenkirchen

pillmuncher hat geschrieben:Kompatibel heißt hier, dass die Verwendbarkeit der Objekte abgeleiteter Typen nicht eingeschränkt ist ggü. der der Objekte des Basistyps.
Eben genau das meine ich nämlich auch. Ich würde auch sagen, dass einzig Konstruktoren eine Ausnahme bilden können, wollte mich aber nochmal rückversichern, ob ich da richtig liege. Na, wenigstens hat sich zumindest einer mal drauf gemeldet... ^^
Antworten