GDScript : Une introduction aux langages dynamiques

À propos

Ce tutoriel a pour but d'être une référence rapide pour savoir comment utiliser GDScript plus efficacement. Il se concentre sur les cas communs spécifiques au langage, mais couvre aussi beaucoup d'informations sur les langues à typage dynamique.

Il est destiné à être particulièrement utile pour les programmeurs ayant peu ou pas d'expérience préalable avec les langages à typage dynamique.

Nature dynamique

Avantages et inconvénients du typage dynamique

GDScript est un langage à typage dynamique. En tant que tel, ses principaux avantages sont que :

  • Le langage est simple et facile à apprendre.

  • La plupart du code peut être écrit et modifié rapidement et sans tracas.

  • Moins de code écrit signifie moins d'erreurs à corriger.

  • Plus grande facilité à lire le code (moins d'encombrement).

  • Aucune compilation n'est nécessaire pour tester.

  • Le temps d'exécution est minuscule.

  • Typage ad hoc et polymorphisme par nature.

Alors que les principaux inconvénients sont :

  • Moindre performance que les langages statiquement typés.

  • Plus difficile à refactoriser (les symboles ne peuvent pas être tracés)

  • Certaines erreurs qui seraient typiquement détectées au moment de la compilation dans des langues typées statiquement n'apparaissent que lors de l'exécution du code (parce que l'analyse des expressions est plus stricte).

  • Moins de flexibilité pour la complétion de code (certains types de variables ne sont connus qu'au moment de l'exécution).

Ceci, traduit dans la réalité, signifie que Godot+GDScript sont une combinaison conçue pour créer des jeux très rapidement et efficacement. Pour les jeux qui sont très intensifs en calcul et qui ne peuvent pas bénéficier des outils intégrés au moteur (tels que les types vectoriels, le moteur physique, la librairie mathématique, etc), la possibilité d'utiliser C++ est également présente. Cela permet de créer le jeu entier en GDScript et d'ajouter des petits morceaux de C++ dans les zones qui ont besoin d'un gain de performance.

Variables et affectation

Toutes les variables d'un langage typé dynamiquement sont de type "variant". Cela signifie que leur type n'est pas fixe et n'est modifié que par l'affectation. Exemple :

Statique :

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

Dynamique :

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

En tant qu'arguments de fonctions :

Les fonctions sont également de nature dynamique, ce qui signifie qu'elles peuvent être appelées avec différents arguments, par exemple :

Statique :

void print_value(int value) {

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

[..]

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

Dynamique :

func print_value(value):
    print(value)

[..]

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

Pointeurs et référencement :

Dans les langages statiques tels que C ou C++ (et dans une certaine mesure Java et C#), il y a une distinction entre une variable et un pointeur/référence à une variable. Ce dernier permet à l'objet d'être modifié par d'autres fonctions en passant une référence de l'objet original.

En C# ou Java, tout ce qui n'est pas un type intégré (int, float, parfois String) est toujours un pointeur ou une référence. Les références sont également collectées par le ramasse-miette automatiquement, ce qui signifie qu'elles sont effacées lorsqu'elles ne sont plus utilisées. Les langages à typage dynamique ont aussi tendance à utiliser ce modèle de mémoire. Quelques exemples :

  • 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.

Dans GDScript, seuls les types de base (int, float, string et les types vectoriels) sont transmis par valeur aux fonctions (la valeur est copiée). Tout le reste (instances, tableaux, dictionnaires, etc.) est passé par référence. Les classes qui héritent de Reference (par défaut si rien n'est spécifié) seront libérées lorsqu'elles ne sont pas utilisées, mais la gestion manuelle de la mémoire est également autorisée si elles héritent manuellement de Object.

Note

Une valeur est passée par valeur quand elle est copiée chaque fois qu'elle est donnée dans un paramètre d'une fonction. Une conséquence de cela c'est que la fonction ne peut pas modifier ce paramètre de telle sorte que cette modification soit visible en-dehors de cette fonction:

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

Une valeur est passée par référence quand elle n'est pas copiée à chaque fois qu'elle est donnée dans un paramètre d'une fonction. Cela permet de modifier de modifier ce paramètre dans le corps de la fonction (et garder ces modifications en-dehors de la fonction). L'inconvénient c'est que ces données passées en paramètre ne sont plus garanties d'être immuables, ce qui peut créer des bogues difficiles à trouver si ça n'est pas avec soin:

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)

En comparaison avec le passage par valeur, passer par référence peut permettre de meilleures performances avec les grands objets puisque la copie de grands objets en mémoire peut être une tâche assez lente.

De plus, dans Godot, les types de base tels que String sont immuables. Cela signifie que leur modification retournera toujours une copie de leur valeur originale plutôt que de modifier la valeur sur place.

Les tableaux

Les tableaux en langages typées dynamiquement peuvent contenir de nombreux types de données mixtes différents à l'intérieur et sont toujours dynamiques (redimensionnables à tout moment). Comparez par exemple les tableaux dans des langages typées statiquement :

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.

Et en 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.

Dans les langages typées dynamiquement, les tableaux peuvent aussi servir comme autres types de données, comme les listes :

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

Ou des ensembles non ordonnés :

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

Les dictionnaires

Les dictionnaires sont un outil très puissant dans les langages dynamiquement typés. La plupart des programmeurs qui viennent de langages typés statiquement (comme C++ ou C#) ignorent leur existence et se rendent la vie inutilement plus difficile. Ce type de données n'est généralement pas présent dans ces langages (ou seulement sous une forme limitée).

Les dictionnaires peuvent associer n'importe quelle valeur à n'importe quelle autre valeur sans tenir compte du type de données utilisé comme clé ou valeur. Contrairement à la croyance populaire, ils sont très efficaces car ils peuvent être implémentés avec des tables de hachage. Ils sont, en fait, si efficaces que certains langages iront jusqu'à implémenter les tableaux comme des dictionnaires.

Exemple de dictionnaire :

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

Les dictionnaires sont également dynamiques, les clés peuvent être ajoutées ou supprimées à tout moment et à faible coût :

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

Dans la plupart des cas, les tableaux bidimensionnels peuvent souvent être implémentés plus facilement avec des dictionnaires. Voici un exemple de jeu de bataille navale simple :

# 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))

Les dictionnaires peuvent également être utilisés comme balises de données ou comme structures rapides. Bien que les dictionnaires GDScript ressemblent aux dictionnaires python, ils supportent également la syntaxe et l'indexation de style Lua, ce qui le rend très utile pour écrire des états initiaux et des structures rapides :

# 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.

Boucles for et while

L'itération dans certains langages typées statiquement peut être assez complexe :

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;
}

Ceci est généralement grandement simplifié dans les langages dynamiquement typés :

for s in strings:
    print(s)

Les types de données de conteneurs (tableaux et dictionnaires) sont itérables. Les dictionnaires permettent d'itérer les clés :

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

L'itération avec les indices est également possible :

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

La fonction range() peut prendre 3 arguments :

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.

Quelques exemples de langage de programmation typés statiquement :

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

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

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

Se traduit en :

for i in range(10):
    pass

for i in range(5, 10):
    pass

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

Et l'itération à rebours se fait à l'aide d'un compteur négatif :

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

Devient :

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

While

les boucles while() sont les mêmes partout :

var i = 0

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

Itérateurs personnalisés

Vous pouvez créer des itérateurs personnalisés dans le cas où ceux par défaut ne répondent pas tout à fait à vos besoins, en redéfinissant les fonctions de la classe Variant _iter_init, _iter_next, et _iter_get dans votre script. Voici un exemple d'implémentation d'un tel itérateur :

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

Et il peut être utilisé comme n'importe quel autre itérateur :

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

Assurez-vous de réinitialiser l'état de l'itérateur dans _iter_init, sinon les boucles imbriquées qui utilisent des itérateurs personnalisés ne fonctionneront pas comme prévu.

Le typage canard (duck typing)

L'un des concepts les plus difficiles à saisir lorsqu'on passe d'un langage typé statiquement à un langage dynamique est le duck typing. Ce typage ad hoc rend la conception globale du code beaucoup plus simple et directe à écrire, mais il n'est pas évident de savoir comment cela fonctionne.

Par exemple, imaginez une situation où un gros rocher tombe dans un tunnel, détruisant tout sur son passage. Le code pour le rocher, dans un langage typé statiquement, serait quelque chose comme :

void BigRollingRock::on_object_hit(Smashable *entity) {

    entity->smash();
}

De cette façon, tout ce qui peut être brisé par une pierre devrait hériter de Smashable. Si un personnage, un ennemi, un meuble, un meuble, une petite pierre étaient tous écrasables, ils auraient besoin d'hériter de la classe Smashable, ce qui pourrait nécessiter un héritage multiple. Si l'héritage multiple n'était pas désiré, alors ils devraient hériter d'une classe commune comme Entity. Pourtant, il ne serait pas très élégant d'ajouter une méthode virtuelle smash() à Entity seulement si quelques-uns d'entre eux peuvent être écrasés.

Avec les langages de typage dynamique, ce n'est pas un problème. Le typage canard permet de s'assurer que vous n'avez qu'à définir une fonction smash() là où c'est nécessaire et c'est tout. Pas besoin de considérer l'héritage, les classes de base, etc.

func _on_object_hit(object):
    object.smash()

Et c'est tout. Si l'objet qui a frappé le gros rocher a une méthode smash(), il sera appelé. Pas besoin d'héritage ou de polymorphisme. Les langues typées dynamiquement ne se soucient que de l'instance ayant la méthode ou le membre désiré, et non de ce dont elle hérite ou du type de classe. La définition du Duck Typing devrait rendre cela plus clair :

"Quand je vois un oiseau qui marche comme un canard et nage comme un canard et cancane comme un canard, j'appelle cet oiseau un canard."

Dans ce cas, cela se traduit par :

"Si l'objet peut être écrasé, peu importe ce que c'est, écrasez-le."

Oui, on devrait plutôt l'appeler le typage à la Hulk.

Il est possible que l'objet touché n'ait pas de fonction smash(). Certains langages à typage dynamique ignorent simplement un appel de méthode lorsqu'il n'existe pas, mais GDScript est plus strict, il est donc souhaitable de vérifier si la fonction existe :

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

Ensuite, il suffit de définir cette méthode et tout ce que la roche touche peut être écrasé.