GDScript: Eine Einführung in dynamische Programmiersprachen

Über

Dieses Handbuch soll eine kurze Referenz sein um GDScript effektiver nutzen zu können. Es zeigt übliche Beispiele speziell dieser Sprache, enthält aber auch viele Informationen über dynamische Programmiersprachen.

Dies ist besonders nützlich für Programmierer die bisher nur wenig oder keine Erfahrung mit dynamischen Programmiersprachen haben.

Eigenschaften dynamischer Programmiersprachen

Vor- und Nachteile von dynamischer Typisierung

GDScript ist eine dynamische Programmiersprache und hat damit folgende Vorteile:

  • Die Sprache kann spielend einfach erlernt werden.

  • Der meiste Code kann schnell und problemlos geschrieben und geändert werden.

  • Weniger geschriebener Code bedeutet weniger mögliche Fehler .

  • Einfacher den Code zu lesen (weniger verwirrend).

  • Es ist keine kompilierung nötig um den Code zu testen.

  • Laufzeit ist gering.

  • Standardmäßig Duck-Typing und polymorph.

Während dies die Nachteile sind:

  • Weniger Leistung als statische Programmiersprachen.

  • Strukturverbesserungen sind schwieriger (Symbole können nicht verfolgt werden)

  • Einige Fehler, die normalerweise zur Kompilierungszeit in statisch typisierten Sprachen erkannt werden, erscheinen nur während der Ausführung des Codes (da die Analyse von Ausdrücken strenger ist).

  • Weniger flexibel zur Code-Vervollständigung (einige Variablentypen sind erst zur Laufzeit bekannt).

In der Realität bedeutet dies, dass Godot + GDScript eine Kombination ist, mit der Spiele schnell und effizient erstellt werden können. Für Spiele, die sehr rechenintensiv sind und nicht von den in der Engine integrierten Tools (wie den Vektortypen, der Physik-Engine, der Mathematikbibliothek usw.) profitieren können, besteht auch die Möglichkeit, C++ zu verwenden. Auf diese Weise können Sie den größten Teil des Spiels weiterhin in GDScript erstellen und in den Bereichen, in denen eine Leistungssteigerung erforderlich ist, kleine Teile von C++ hinzufügen.

Variablen & Zuweisungen

In dynamischen Programmiersprachen sind Variablen nicht Typen gebunden. Das bedeutet, dass der Typ nicht festgelegt ist, er wird nur durch Zuweisung von Werten geändert. Beispiel:

Statisch:

int a; // Value uninitialized.
a = 5; // This is valid.
a = "Hi!"; // This is invalid.

Dynamisch:

var a # 'null' by default.
a = 5 # Valid, 'a' becomes an integer.
a = "Hi!" # Valid, 'a' changed to a string.

Als Funktions Argumente:

Funktionen sind ebenfalls dynamisch, dass bedeutet sie können mit unterschiedlichen Argumenten aufgerufen werden, zum Beispiel:

Statisch:

void print_value(int value) {

    printf("value is %i\n", value);
}

[..]

print_value(55); // Valid.
print_value("Hello"); // Invalid.

Dynamisch:

func print_value(value):
    print(value)

[..]

print_value(55) # Valid.
print_value("Hello") # Valid.

Zeiger & Referenzen:

In statischen Sprachen wie C oder C++ (und einige weitere wie Java und C#), gibt es einen Unterschied zwischen einer Variablen und einem Zeiger bzw. Referenz auf eine Variable. Dies erlaubt dem Objekt durch andere Funktionen verändert zu werden, durch Übergabe einer Referenz auf das Original.

In C# oder Java ist alles was kein eingebauter Typ ist ((int, float, manchmal String) immer ein Zeiger oder eine Referenz. Referenzen werden auch automatisch speicherbereinigt, das bedeutet sie werden gelöscht wenn nicht mehr benötigt. Dynamische Sprachen bieten meistens ebenfalls dieses Model. Hier einige Beispiele:

  • C++:

void use_class(SomeClass *instance) {

    instance->use();
}

void do_something() {

    SomeClass *instance = new SomeClass; // Created as pointer.
    use_class(instance); // Passed as pointer.
    delete instance; // Otherwise it will leak memory.
}
  • Java:

@Override
public final void use_class(SomeClass instance) {

    instance.use();
}

public final void do_something() {

    SomeClass instance = new SomeClass(); // Created as reference.
    use_class(instance); // Passed as reference.
    // Garbage collector will get rid of it when not in
    // use and freeze your game randomly for a second.
}
  • GDScript:

func use_class(instance): # Does not care about class type
    instance.use() # Will work with any class that has a ".use()" method.

func do_something():
    var instance = SomeClass.new() # Created as reference.
    use_class(instance) # Passed as reference.
    # Will be unreferenced and deleted.

In GDScript, only base types (int, float, String and PoolArray types) are passed by value to functions (value is copied). Everything else (instances, Arrays, Dictionaries, etc) is passed as reference. Classes that inherit Reference (the default if nothing is specified) will be freed when not used, but manual memory management is allowed too if inheriting manually from Object.

Bemerkung

A value is passed by value when it is copied every time it's specified as a function parameter. One consequence of this is that the function cannot modify the parameter in a way that is visible from outside the function:

func greet(text):
    text = "Hello " + text

func _ready():
    # Create a String (passed by value and immutable).
    var example = "Godot"

    # Pass example as a parameter to `greet()`,
    # which modifies the parameter and does not return any value.
    greet(example)

    print(example)  #  Godot

A value is passed by reference when it is not copied every time it's specified as a function parameter. This allows modifying a function parameter within a function body (and having the modified value accessible outside the function). The downside is that the data passed as a function parameter is no longer guaranteed to be immutable, which can cause difficult-to-track bugs if not done carefully:

func greet(text):
    text.push_front("Hello")

func _ready():
    # Create an Array (passed by reference and mutable) containing a String,
    # instead of a String (passed by value and immutable).
    var example = ["Godot"]

    # Pass example as a parameter to `greet()`,
    # which modifies the parameter and does not return any value.
    greet(example)

    print(example)  #  [Hello, Godot] (Array with 2 String elements)

Compared to passing by value, passing by reference can perform better when using large objects since copying large objects in memory can be slow.

Additionally, in Godot, base types such as String are immutable. This means that modifying them will always return a copy of the original value, rather than modifying the value in-place.

Arrays (Felder)

Arrays in dynamischen Programmiersprachen können viele verschiedene Datentypen enthalten und sind immer dynamisch (können also jederzeit in der Größe verändert werden). Vergleichen wir hierzu Arrays in statischen Programmiersprachen:

int *array = new int[4]; // Create array.
array[0] = 10; // Initialize manually.
array[1] = 20; // Can't mix types.
array[2] = 40;
array[3] = 60;
// Can't resize.
use_array(array); // Passed as pointer.
delete[] array; // Must be freed.

// or

std::vector<int> array;
array.resize(4);
array[0] = 10; // Initialize manually.
array[1] = 20; // Can't mix types.
array[2] = 40;
array[3] = 60;
array.resize(3); // Can be resized.
use_array(array); // Passed reference or value.
// Freed when stack ends.

Und in GDScript:

var array = [10, "hello", 40, 60] # Simple, and can mix types.
array.resize(3) # Can be resized.
use_array(array) # Passed as reference.
# Freed when no longer in use.

In dynamisch typisierten Sprachen können Arrays auch als andere Datentypen wie Listen verwendet werden:

var array = []
array.append(4)
array.append(5)
array.pop_front()

Oder unsortierte Sets:

var a = 20
if a in [10, 20, 30]:
    print("We have a winner!")

Dictionaries (Wörterbücher)

Dictionaries (Wörterbücher oder auch assoziative Liste) sind ein leistungsstarkes Werkzeug in dynamisch typisierten Sprachen. Die meisten Programmierer, die aus statisch typisierten Sprachen (wie C++ oder C#) stammen, ignorieren ihre Existenz und erschweren ihr Leben unnötig. Dieser Datentyp ist in solchen Sprachen im Allgemeinen nicht (oder nur in begrenzter Form) vorhanden.

Dictionaries können jeden Wert jedem anderen Wert zuordnen, wobei es völlig egal ist, ob der Datentyp als Schlüssel oder Wert genutzt wird. Entgegen der landläufigen Meinung sind sie effizient, da sie mit Hash-Tabellen implementiert werden können. Sie sind in der Tat so effizient, dass einige Sprachen sogar Arrays als Dictionaries implementieren.

Beispiel für ein Dictionary:

var d = {"name": "John", "age": 22} # Simple syntax.
print("Name: ", d["name"], " Age: ", d["age"])

Dictionaries sind auch dynamisch, Schlüssel können jederzeit mit geringen Aufwand hinzugefügt oder entfernt werden:

d["mother"] = "Rebecca" # Addition.
d["age"] = 11 # Modification.
d.erase("name") # Removal.

In den meisten Fällen können zweidimensionale Arrays häufig einfacher mit Dictionaries implementiert werden. Hier ist ein einfaches Beispiel für ein "Schiffe versenken" Spiel:

# Battleship Game

const SHIP = 0
const SHIP_HIT = 1
const WATER_HIT = 2

var board = {}

func initialize():
    board[Vector2(1, 1)] = SHIP
    board[Vector2(1, 2)] = SHIP
    board[Vector2(1, 3)] = SHIP

func missile(pos):
    if pos in board: # Something at that position.
        if board[pos] == SHIP: # There was a ship! hit it.
            board[pos] = SHIP_HIT
        else:
            print("Already hit here!") # Hey dude you already hit here.
    else: # Nothing, mark as water.
        board[pos] = WATER_HIT

func game():
    initialize()
    missile(Vector2(1, 1))
    missile(Vector2(5, 8))
    missile(Vector2(2, 3))

Dictionaries können auch als Datenmarkup oder schnelle Strukturen verwendet werden. Während die Dictionaries von GDScript Python-Dictionaries ähneln, unterstützt es auch die Syntax und Indizierung im Lua-Stil, was es nützlich macht, Anfangszustände und schnelle Strukturen zu schreiben:

# Same example, lua-style support.
# This syntax is a lot more readable and usable.
# Like any GDScript identifier, keys written in this form cannot start
# with a digit.

var d = {
    name = "John",
    age = 22
}

print("Name: ", d.name, " Age: ", d.age) # Used "." based indexing.

# Indexing

d["mother"] = "Rebecca"
d.mother = "Caroline" # This would work too to create a new key.

For & while

Wiederholen in einigen statisch typisierten Sprachen kann ziemlich komplex sein:

const char* strings = new const char*[50];

[..]

for (int i = 0; i < 50; i++) {

    printf("Value: %s\n", i, strings[i]);
}

// Even in STL:

for (std::list<std::string>::const_iterator it = strings.begin(); it != strings.end(); it++) {

    std::cout << *it << std::endl;
}

Dies ist normalerweise stark vereinfacht in dynamisch typisierten Sprachen:

for s in strings:
    print(s)

Container Datentypen (Arrays und Dictionaries) sind iterierbar, können also auch die Werte der Reihe nach durchgehen. Dictionaries erlauben die Iteration des Schlüssels:

for key in dict:
    print(key, " -> ", dict[key])

Wiederholen mit Indexen ist ebenso möglich:

for i in range(strings.size()):
    print(strings[i])

Die range() Funktion nimmt bis zu 3 Argumente an:

range(n) # Will go from 0 to n-1.
range(b, n) # Will go from b to n-1.
range(b, n, s) # Will go from b to n-1, in steps of s.

Hier einige Beispiele statischer Programmiersprachen:

for (int i = 0; i < 10; i++) {}

for (int i = 5; i < 10; i++) {}

for (int i = 5; i < 10; i += 2) {}

Sind gleichbedeutend mit:

for i in range(10):
    pass

for i in range(5, 10):
    pass

for i in range(5, 10, 2):
    pass

Rückwärts durch die Schleife wiederholen kann man mit einem negativen Zähler:

for (int i = 10; i > 0; i--) {}

Wird zu:

for i in range(10, 0, -1):
    pass

Während

while() Schleifen sind überall gleich:

var i = 0

while i < strings.size():
    print(strings[i])
    i += 1

Benutzerdefinierte Iteratoren

Sie können benutzerdefinierte Iteratoren generieren, wenn die vorgegebenen nicht ganz Ihren Anforderungen entsprechen, indem Sie die _iter_init, _iter_next, und _iter_get Funktionen der Variant Klasse überschreiben. Hier ist ein Beispiel einer Implementierung für einen Vorwärts-Iterator:

class ForwardIterator:
    var start
    var current
    var end
    var increment

    func _init(start, stop, increment):
        self.start = start
        self.current = start
        self.end = stop
        self.increment = increment

    func should_continue():
        return (current < end)

    func _iter_init(arg):
        current = start
        return should_continue()

    func _iter_next(arg):
        current += increment
        return should_continue()

    func _iter_get(arg):
        return current

Und er kann wie jeder andere Iterator verwendet werden:

var itr = ForwardIterator.new(0, 6, 2)
for i in itr:
    print(i) # Will print 0, 2, and 4.

Stelle sicher, dass der Status des Iterators in _iter_init zurückgesetzt wird, sonst kann es passieren, dass sich verschachtelte for-loops nicht wie gewünscht verhalten.

Duck-Typing

Eines der am schwersten zu erfassenden Konzepte, wenn man von einer statisch typisierten zu einer dynamisch typisierten Sprache wechselt, ist Duck-Typing. Duck-Typing macht allgemeines Code-Design viel leichter und unkomplizierter zu schreiben, aber es ist vielleicht nicht gleich ersichtlich, wie es funktioniert.

Als Beispiel: Stellen Sie sich eine Situation vor in der ein Großer Stein einen Tunnel herunter fällt und alles in seinem Weg beiseite stößt. In einer statischen Programmiersprache würde der Code für den Stein ungefähr so aussehen:

void BigRollingRock::on_object_hit(Smashable *entity) {

    entity->smash();
}

Auf diese Weise müsste alles, was von einem Stein zerschlagen werden kann, Smashable erben. Wenn ein Charakter, ein Feind, ein Möbelstück oder ein kleiner Stein zerschmettert werden könnten, müssten sie von der Klasse Smashable erben, was möglicherweise eine Mehrfachvererbung erfordert. Wenn Mehrfachvererbung unerwünscht ist, müssten sie eine gemeinsame Klasse wie Entity erben. Es wäre jedoch nicht sehr elegant, Entity nur dann eine virtuelle Methode smash() hinzuzufügen, wenn einige von ihnen zerschlagen werden können.

Bei dynamischen Programmiersprachen ist dies kein Problem. Duck-Typing stellt sicher, dass man nur an benötigter Stelle eine smash()-Funktion definieren muss, und das war's. Vererbung, Basisklassen usw. müssen nicht berücksichtigt werden.

func _on_object_hit(object):
    object.smash()

Und das ist es auch schon. Falls das Objekt, dass den großen Felsen trifft, eine smash() Methode hat, wird diese aufgerufen. Vererbung oder Polymorphie ist nicht nötig. Dynamischen Programmiersprachen ist es nur wichtig, dass die Instanz die gewünschte Methode oder Member hat, nicht was sie vererbt oder deren Klassentyp. Die Definition von "Duck-Typing" (Konzept der objektorientierten Programmierung) macht das deutlicher:

"Wenn ein Vogel wie eine Ente geht, wie eine Ente schwimmt und wie eine Ente quakt, dann ist es eine Ente"

In diesem Fall lautet es übersetzt:

"Falls das Objekt zerstört werden kann, ist es egal was es ist, zerstöre es einfach"

Ja, vielleicht sollte man es nach Hulk benennen.

It's possible that the object being hit doesn't have a smash() function. Some dynamically typed languages simply ignore a method call when it doesn't exist, but GDScript is stricter, so checking if the function exists is desirable:

func _on_object_hit(object):
    if object.has_method("smash"):
        object.smash()

Dann wird diese Methode einfach definiert und alles was der Fels dann berührt wird zerstört.