@wrench139: Du hattest Deinen Beitrag schon abgeschickt als ich meinen noch geschrieben hatte, darum die ”Überschneidung”, dass ich noch mal beschrieben hatte das `ask()` selbst keine Callbackfunktion ist.
Ich weiss nicht so recht ob Deine Beschreibung so gut ist. Zum Beispiel das unterstrichene „oder“ verstehe ich nicht.
Die Funktion oder Methode die den Callback entgegen nimmt, erwartet in aller Regel eine bestimmte Signatur, denn irgendwann muss der Callback ja aufgerufen werden.
Letztlich läuft es wie pillmuncher schrieb auf Polymorphismus hinaus. Das ist eng verwandt mit Objektorientierung, denn im Grunde ist ein Objekt das man irgendwo übergibt ja auch eine Sammlung von Callbacks — den Methoden, die eine Funktion/Methode dann aufruft. Und letztlich ist eine Funktion/Methode ja auch ein Objekt das eine `__call__()`-Methode hat. Und umgekehrt kann man auch andere Objekte als Funktionen/Methoden dort übergeben wo ein Callback erwartet wird, solange die eine entsprechende `__call__()`-Methode implementieren. Den speziellen Fall, der ja ein „code smell“ ist, das man eine Klasse mit nur einer öffentlichen Methode neben der `__init__()` hat, kann man durch eine Funktion, und gegebenenfalls `functools.partial()` ohne Klasse abbilden.
Callbacks kann man wie (andere) Objekte überall dort einsetzen wo eine Funktion oder Methode nicht nur Daten erwartet, sondern auch Verhalten. Und sowohl in der objektorientierten Programmierung als auch in der funktionalen Programmierung sind ``if``/``elif``/``else`` oft ein Punkt wo man sich überlegt ob das Verhalten ist, welches von Bedingungen abhängig ist, und ob das deshalb nicht besser weniger starr als Methode oder Funktion ausgedrückt werden kann.
Mal ein weiteres Beispiel mit der ersten Aufgabe zum „Intcode“-Prozessor vom Advent of Code 2019. Die Aufgabe war das man ein Programm hat, das aus einer Folge von Zahlen besteht, mit Anfangs folgenden Bedeutungen 1 steht für Addition und 2 für Multiplikation, wobei jeweils die folgenden 3 Zahlen die Operanden und das Ziel für das Ergebnis beschreiben. Wobei die Zahlen jeweils für den Index stehen wo die Operanden und der Platz für das Ergebnis in der Folge von Zahlen stehen. Also in Maschinensprache klassische indirekte Adressierung. Die Zahl 99 steht für „Programmende“.
Wenn man das jetzt mit einem ”grossen” ``if``/``elif``/``else`` für die einzelnen Instruktionen implementiert, könnte das so aussehen:
Code: Alles auswählen
def run(memory):
instruction_pointer = 0
while instruction_pointer is not None:
opcode = memory[instruction_pointer]
if opcode == 1:
memory[memory[instruction_pointer + 3]] = (
memory[memory[instruction_pointer + 1]]
+ memory[memory[instruction_pointer + 2]]
)
instruction_pointer += 4
elif opcode == 2:
memory[memory[instruction_pointer + 3]] = (
memory[memory[instruction_pointer + 1]]
* memory[memory[instruction_pointer + 2]]
)
instruction_pointer += 4
elif opcode == 99:
instruction_pointer = None
else:
raise ValueError(
f"illegal opcode {opcode} at {instruction_pointer}"
)
Es fällt auf, dass der Code für die beiden Rechenoperationen nahezu gleich ist. Und wenn man sich jetzt überlegt, dass da vielleicht um Aufgabenteil 2 noch weitere Operationen nach dem Muster hinzu kommen, die man hier durch kopieren, einfügen, und leicht anpassen umsetzen würde, regt sich bei den meisten Programmieren der Wunsch das in eine Funktion heraus zu ziehen. Und meistens identifiziert man dann ja die Daten in denen sich die Kopien unterscheiden, und die man dann als Argumente an die herausgezogene Funktion übergibt. Nur ist der Unterschied hier kein ”passiver” Wert, sondern die Rechenoperation, also Verhalten. Netterweiser gibt es alle Operatoren die Python hat, im `operator`-Modul schon als Funktionen, so dass man das so lösen kann:
Code: Alles auswählen
from operator import add, mul as multiply
def execute_binary_operation(operation, memory, instruction_pointer):
memory[memory[instruction_pointer + 3]] = operation(
memory[memory[instruction_pointer + 1]],
memory[memory[instruction_pointer + 2]],
)
return instruction_pointer + 4
def run(memory):
instruction_pointer = 0
while instruction_pointer is not None:
opcode = memory[instruction_pointer]
if opcode == 1:
instruction_pointer = execute_binary_operation(
add, memory, instruction_pointer
)
elif opcode == 2:
instruction_pointer = execute_binary_operation(
multiply, memory, instruction_pointer
)
elif opcode == 99:
instruction_pointer = None
else:
raise ValueError(
f"illegal opcode {opcode} at {instruction_pointer}"
)
Im nächsten Schritt kann man sehen, dass die ``if``/``elif``/``else``-Zweige über ”Verhalten” entscheiden. Allgemein kann man sagen, jeder Zweig führt eine Operation aus die (potentiell) den Speicher und den Zeiger auf die aktuelle Instruktion verändert. Das kann man zum Vertrag für eine Funktion machen, dass man sagt, jede Operation ist eine Zahl (Opcode) die auf eine Funktion abgebildet werden kann, die Speicher und Instruktionszeiger bekommt, eventuell den Speicher verändert und den neuen Instruktionszeiger zurück gibt. Und schon kann man das aus der Funktion heraus ziehen, die sehr allgemein halten, und den Befehlssatz des Prozessors als Argument übergeben:
Code: Alles auswählen
from functools import partial
from operator import add, mul as multiply
def execute_binary_operation(operation, memory, instruction_pointer):
memory[memory[instruction_pointer + 3]] = operation(
memory[memory[instruction_pointer + 1]],
memory[memory[instruction_pointer + 2]],
)
return instruction_pointer + 4
def execute_halt(_memory, _instruction_pointer):
return None
def run(instruction_set, memory):
instruction_pointer = 0
while instruction_pointer is not None:
opcode = memory[instruction_pointer]
execute = instruction_set.get(opcode)
if not execute:
raise ValueError(
f"illegal opcode {opcode} at {instruction_pointer}"
)
instruction_pointer = execute(memory, instruction_pointer)
def main():
instruction_set = {
1: partial(execute_binary_operation, add),
2: partial(execute_binary_operation, multiply),
99: execute_halt,
}
memory = [1, 1, 1, 4, 99, 5, 6, 0, 99]
run(instruction_set, memory)
print(memory)
Wenn man sich jetzt vorstellt, dass der Befehlssatz um x zusätzliche Instruktionen erweitert wird, ist das so viel übersichtlicher, flexibler, und einfacher zu testen. Man kann jede Instruktion(sfamilie) separat testen und man braucht die `run()`-Funktion nicht anfassen wenn man Instruktionen hinzufügt (solange das Modell des Prozessors bei Speicher + Instruktionszeiger bleibt), und man kann die gleiche `run()`-Funktion für verschiedene Befehlssätze verwenden. Prozessorvarianten mit mehr oder weniger Befehlen oder Fehlern in der Umsetzung einzelner Befehle sind nicht unüblich. Es macht also Sinn einen Prozessoremulator an der Stelle flexibel zu halten.