Modernes Scripting in LegacyClonk (mit #strict 3)

Da LegacyClonk seit Clonk Rage einige Neuerungen bezüglich der Scriptsprache bekommen hat, möchte ich hier eine kurze Übersicht und Tipps geben wie man mithilfe der Neuerungen sauberere Scripts schreiben kann. Dies ist ein Wikipost, kann also von jedem ergänzt werden.

Die meisten Neuerungen funktionieren nur mit #strict 3.
#strict 3 geht nochmal strenger mit Typkonvertierungen der Werts 0 und false um, weshalb es wichtig ist sich darüber im Klaren zu sein. Im Gegensatz zu vorher ist es nicht mehr erlaubt diese als Wert für „nichts“ beliebiger Typen zu verwenden.
Der neue Wert für „nichts“ ist nil.
Das erlaubt das saubere Unterscheiden zwischen 0, false und „nichts“.
Im Gegensatz zu 0 darf nil aber nicht in Berechnungen verwendet werden. Das führt zu Errors.

Konvertierungen zu bool

Es können weiterhin beliebige Typen zu bool konvertiert werden, wobei alle außer die folgenden Werte zu true werden: nil, 0, false, NONE.
Für den booleschen Negations-Operator ! gilt natürlich die umgekehrte Logik.

Prüfen auf nil, false und 0 und Vergleiche damit

Beispiele

Alle folgenden Ausdrücke ergeben true:

nil == nil;
nil != 0;
nil != false;

0 == 0;
0 != false;
false == false;

!false;
!0;
!nil;

Erklärung

Allgemein gelten Werte für == und != nur als identisch wenn sie auch den selben Typen haben.
Da nicht nur nil bei bool-Konvertierung zu false wird, kann if (!foo) nicht zur korrekten Erkennung auf nil verwendet werden.
Stattdessen muss ein direkter Vergleich mit nil stattfinden, also if (foo == nil) oder if (foo != nil).
Das ist aber natürlich nur notwendig, wenn nil getrennt von anderen false-artige Werten behandelt werden soll.

Aus diesem Grund müssen Vergleiche mit false oder 0 mit Vorsicht behandelt werden.
Speziell für false wird meistens das gewünschte Verhalten mit if (!foo) erreicht, während if (foo == false) für 0 und nil nicht zutrifft.

Verwendung von nil

Tipps für den Umgang mit und Einsatz von nil:

  • Soll nil in Berechnungen wie 0 behandelt werden, hilft der ??-Operator:
 // Foo() gibt int oder nil zurück;
var x = Foo() ?? 0;
var y = x * 42;
  • Default-Werte für Funktionsparameter
func Foo(int distance, bool stronger, object obj)
{
    distance ??= 100;
    stronger ??= true;
    obj ??= this;
    …
}

Foo(); // distance == 100, stronger == true, obj == this
Foo(0, false); // distance == 0, stronger == false, obj == this
Foo(nil, false); // distance == 100, stronger == false
  • Kommunizieren von Fehlern
func Price(id def)
{
    if (def == BEER) return 0; // free beer!
    else if (def == FLNT) return 2;

    return nil; // don’t know
}
Price(BEER); // returns 0, it’s free!
Price(SFLN); // returns nil, we don’t know!

Datentyp map

Siehe Dokumentation

Codevereinfachungen

Nachfolgend ein paar Codevereinfachungen die möglich sind:

alt => neu Hinweis
arr[GetLength(arr)] = 42 arr[] = 42
LocalN(obj, "foo") = 1337 obj.foo = 1337
var v = "foo"; LocalN(obj, v) = 1337; var v = "foo"; obj[v] = 1337; für den Fall, dass der Variablenname nicht konstant ist
if (obj) obj->RemoveObject(); obj?->RemoveObject(); es gibt auch arr?[index], map?.key, obj?.var, obj?->~Foo()
if (obj) x = obj->Foo()[0]; else x = "RIP"; x = obj?->Foo()[0] ?? "RIP";
Format("%s %s", "Hello", "World") "Hello" .. " " .. "World" es gibt auch ..=
Format("Foo %d", 1234) "Foo " .. 1234 geht für strings, IDs und ints; bools werden zu "0" bzw. "1"
Format("%c", GetChar("Hello", 3)) "Hello"[3] nur lesend; negativer Index zählt vom Ende rückwärts (nur bei strings!)
CastObjects(FLNT, 100, 100, AbsX(50), AbsY(50)) global->CastObjects(FLNT, 100, 100, 50, 50) Aufrufe mit global-> haben globalen Kontext, dadurch gelten Koordinaten immer global
ArrayConcatenate([42, 1337], ["hello world"]) [42, 1337] .. ["hello world"] ArrayConcatenate musste selbst definiert werden

Weitere Tipps

  • IntializeDef(string section):
    Diese Funktion wird in jeder Definition beim Laden des Szenarios und beim Laden einer Sektion aufgerufen. section ist der Sektionsname der aktuellen Sektion oder nil für die Hauptsektion.
    In diesem Callback können unter anderem zur Definition gehörende globale Variablen (mit static definiert) auf einen Wert gesetzt werden, oder ein Regelobjekt erstellt werden welches immer existieren soll.
    Der Aufruf erfolgt im Definitionskontext der jeweiligen Definition.

Interoperation zwischen #strict 3 und nicht-#strict 3

Kurzfassung in Beispielform

#strict 3

func Strict3(int anInt, bool aBool, dontKnow)
{
    return 0;
}

func TestInStrict3()
{
    // Strict3(false); // Error: false is not int
    Strict3(0, 0); // Ok: converts 0 to false

    Strict3(); // in Strict3(): anInt == nil, aBool == nil, dontKnow == nil
    Strict3(0, false, 0); // in Strict3(): anInt == 0, aBool == false, dontKnow == 0
    var x = Strict3(0, false, false); // in Strict3(): anInt == 0, aBool == false, dontKnow == false
        // x == 0 && x != false && x != nil

    x = NonStrict3(0, 0, 0); // in NonStrict3(): anInt == 0 && anInt == false, aBool == 0 && aBool == false, dontKnow == 0 && dontKnow == false
        // x == nil && x != 0 && x != false

    x = NonStrict3() ?? 0; // x == 0
    x = NonStrict3() ?? false; // x == false
}

#strict 2 // or #strict or non-#strict

func NonStrict3(int anInt, bool aBool, dontKnow)
{
    // all four variants below have the exact same result
   return;
   return 0;
   return false;
   // <no return>
}

func TestInNonStrict3()
{
    var x = Strict3(0, 0, 0); // in Strict3(): anInt == 0, aBool == false, dontKnow == nil
                                         // x == 0 && x == false

    Strict3(false, false, false); // in Strict3(): anInt == 0, aBool == false, dontKnow == nil
}

Erklärung

Um eine möglichst gute Integration von #strict 3 mit alten Scripten zu ermöglichen gibt es in einigen Fällen automatische Konvertierungen zwischen 0 bzw. false und nil.
Je nach Konvertierungsrichtung gelten unterschiedliche Regeln.

Hintergründe

Zur Besserung Nachvollziehbarkeit sei hier ein kleines Detail erwähnt:
Variablen in C4Script haben einen gewissen Typen. Zusätzlich zu den üblichen Typen int, bool, id, string, object, array und map gibt es den speziellen Typen any. In der Dokumentation und Typangaben von Funktionsargumenten steht any für beliebiger Typ.
Intern hat any drei unterschiedliche Bedeutungen:

  • Als Typ einer C4Script-Variable hat es eine Doppelbedeutung:
    • Bei Rohwert 0: Die Variable hat den Wert „nichts“ bzw. nil in #strict 3.
      0, false, nil und NONE haben alle Rohwert 0.
    • Bei Rohwert != 0: Typ ist unbekannt, in diesem Fall muss er „erraten“ werden.
  • Bei Typangabe für Funktionsargumente steht es für beliebige Typen.

Der Rohwert ist der Wert, der für die Variable tatsächlich im Arbeitsspeicher steht.
Ohne #strict 3 werden alle Variablen mit Rohwert 0 implizit zu any. Das führt dazu, dass 0 und false als Wert für „nichts“ verwendet werden können. Gleichzeitig ist es dadurch auch unmöglich diese Werte zu unterscheiden.
Mit #strict 3 behält eine Variable auch mit Rohwert 0 ihren Typen bei. Nur explizites Setzen auf nil ändert den Typen zu any.

Konvertierungsregeln

Prinzipiell gibt es, aufgrund oben erklärter Umstände, einen Typ-Verlust für 0 und false beim Übergang von #strict 3 zu nicht-#strict 3.
Diese Werte werden dabei zum Wert „nichts“ (äquivalent zu 0 und false ohne #strict 3, bzw. nil in #strict 3).
Dies passiert einerseits mit den Funktionsargumenten beim Aufruf einer nicht-#strict 3-Funktion aus einer #strict 3-Funktion und andererseits mit dem Rückgabewert von #strict 3-Funktionen die aus nicht-#strict 3-Funktionen aufgerufen werden.

Beim Übergang von nicht-#strict 3 zu #strict 3 gibt es unter gewissen Umständen eine automatische Konvertierung von „nichts“ zu 0 oder false.
Beim Aufruf einer #strict 3-Funktion aus einem nicht-#strict 3-Script wird „nichts“ als Funktionsargument zu 0 oder false konvertiert, wenn das Funktionsargument entsprechend als int oder bool definiert ist.
Bei Funktionsargumenten ohne Typangabe und bei Rückgabewerten von nicht-#strict 3-Funktionen die aus einem #strict 3-Script aufgerufen werden gibt es keine automatische Konvertierung. In solchen Fällen muss auf #strict 3-Seite unter Umständen ?? 0 oder ?? false verwendet werden.

Unter Beachtung dieser Umstände können Scripte mit unterschiedlichen #strict-Leveln mithilfe von #appendto und #include wie gehabt kombiniert werden.

Warum der ganze Aufwand mit #strict 3?

#strict 3 kann als ersten Eindruck aussehen als wäre es unnötiger Mehraufwand. Immerhin löst es in einigen Fällen Errors aus, die vorher oftmals tadellos funktionierten.
In der allgemeinen Scriptpraxis stellt sich aber heraus, dass zu lose Typisierung oft zu versteckten Fehlern führen kann.
JavaScript und vor allem PHP sind wahrscheinlich die bekanntesten Scriptsprachen die aufgrund sehr schwacher Typisierung für viele Überraschungen sorgen.

Durch strengere Typisierung wird zwar etwas mehr Disziplin beim Scripten gefordert, allerdings hilft sie dabei gewisse Fehler schneller aufzudecken und im Endeffekt robustere Scripte zu schreiben.
Außerdem erlaubt sie das Unterscheiden der Werte 0, false und „nichts“, was wie oben illustriert unter anderem für Default-Werte hilfreich ist. Hätte es diese Unterscheidung schon immer gegeben, müsste zum Beispiel bei gewissen Funktionen nicht die Spielernummer um 1 erhöht angegeben werden.

3 „Gefällt mir“

Welcher Syntax hätte denn die bessere peformance? Wenn ich mich recht erinnere ging es bei den vorherigen Syntax Updates um performance Einsparungen.

Ich fand es persönlich immer ganz nett dass 0, false und nichts das geiche ist ;)

1 „Gefällt mir“

Ich finde die Trennung gut. 0 ist für mich ein Integer. Ein Boolean hingegen kennt genau zwei Zustände und beansprucht auch nur ein Bit. Dadurch, dass im Wertebereich des Boolean nur zwei Werte verfügbar sind, ist es nicht möglich, dass eine Funktion warum auch immer TRSE zurückgibt. Bei schlechter Programmierung hingegen kann anstatt 0 oder 1 auch mal eine zwei rauspurzeln.

Ich trau mir da keine klare Aussage zu treffen, aber es würde mich wundern wenn es einen relevanten Unterschied zwischen den unterschiedlichen strict levels gibt.
Rein vom Rechenaufwand her dürfte jeder höhere Level etwas größeren Aufwand bringen, weil mehr Checks gemacht werden.

So oder so macht es aber keinen Sinn aus Performance-Gründen einen Level zu bevorzugen. Meine Erfahrung war oft, dass einige Engine-Funktionen sehr langsam sind, anstatt dem eigentlichen Script-Ausführen selbst.

Früher fand ich das auch irgendwie toll, und es kann auch ganz praktisch sein um schnell was zusammen zu kleistern. Aber auf längere Zeit habe ich immer öfter gemerkt dass es praktischer wäre wenn man unterscheiden kann.

In der Praxis werden aber bools meistens größer gespeichert. In C++ scheinen sie 1 Byte groß zu sein.
In C4Script sind sie gleich groß wie ints. Generell ist der reine Datenteil von C4Script-Variablen immer so groß wie ein Pointer (also 32 oder 64 Bit). Primitive Typen (int, bool, ID) verwenden davon 32 Bit, der Rest die volle Pointergröße + eventuell die Datenstruktur auf die gezeigt wird (Array bzw. Map und teilweise String, bei Objektvariablen liegt das Objekt sowieso im Speicher).

Oh okay, das ist mir neu. Aber gut dann formuliere ich das mal so: Ein Boolean würde auch mit einem Bit zurechtkommen, weil es ja nur zwei Zustände (1 oder 0) gibt. Was die Programmiersprache oder die Architektur dann draus macht ist natürlich eine andere Sache.