GDScript: Uma introdução às linguagens dinâmicas

Sobre

Este tutorial pretende ser uma referência rápida de como usar GDScript de forma mais eficiente. Focando, assim, em casos mais comuns específicos da linguagem, mas também contém informações sobre linguagens dinâmicamente tipadas.

É destinado a ser especialmente útil para programadores com pouca ou nenhuma experiência com linguagens dinâmicamente tipadas.

Natureza dinâmica

Prós e contras da tipagem dinâmica

GDScript é uma linguagem Dinamicamente Tipada. Como tal, suas maiores vantagens são:

  • A linguagem é muito simples de aprender.

  • A maioria do código pode ser escrito e modificado rapidamente sem complicações.

  • Menos código escrito significa menos erros e enganos a serem corrigidos.

  • Mais fácil de ler o código (menos bagunça).

  • Nenhuma compilação é necessária para testar.

  • Tempo de execução é bem pequeno.

  • Polimorfismo e tipagem pato naturalmente.

Enquanto as maiores desvantagens são:

  • Menor performance que linguagens com tipagem estática.

  • Mais difícil de refatorar (símbolos não podem ser rastreados)

  • Alguns erros que, tipicamente, seriam detectados em tempo de compilação em linguagens tipadas estaticamente aparecem apenas rodando o código (porque a análise de expressões é mais estrita).

  • Menos flexibilidade para preenchimento de código (alguns tipos de variáveis só são reconhecidos na execução).

Isso, traduzido para a realidade, significa que Godot+GDScript é uma combinação projetada para criar jogos de forma rápida e eficiente. Para jogos que são muito intensivos em computação e não podem se beneficiar das ferramentas internas do mecanismo (como os tipos Vector, Engine de Física, biblioteca de Matemática, etc), a possibilidade de usar o C++ também está presente. Isso permite ainda criar o jogo inteiro em GDScript e adicionar pequenos pedaços de C++ nas áreas que precisam de um desempenho maior.

Variáveis & atribuição

Todas as variáveis em uma linguagem dinamicamente tipada são tipo-"variante". Isto significa que seus tipos não são fixos, e é modificado apenas na atribuição. Exemplos:

Estático:

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

Dinâmico:

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

Como argumentos da função:

Funções também têm natureza dinâmica, o que significa que elas podem ser invocadas com argumentos diferentes, por exemplo:

Estático:

void print_value(int value) {

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

[..]

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

Dinâmico:

func print_value(value):
    print(value)

[..]

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

Ponteiros & referência:

Em linguagens estáticas como C ou C++ (e de certa medida Java e C#), existe uma distinção entre uma variável e um ponteiro/referência a uma variável. Esse último deixa o objeto ser modificado por outras funções através da passagem de uma referência ao original.

Em C# ou Java, tudo que não é um tipo embutido (int, float ou, às vezes string) é sempre um ponteiro ou uma referência. Referências são coletadas pelo coletor de lixo automaticamente, o que significa que são apagadas quando não mais usadas. Linguagens tipadas dinamicamente tendem a usar esse modelo de memória também. Alguns exemplos:

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

Nota

Um valor é passado por valor quando é copiado toda vez que é especificado como um parâmetro de função. Uma consequência disso é que a função não pode modificar o parâmetro de forma visível de fora da função:

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

Arrays em linguagens dinamicamente tipadas podem conter muitos tipos de dados mistos diferentes e são sempre dinâmicos (podem ser redimensionados a qualquer momento). Compare, por exemplo, arrays em linguagens estaticamente tipadas:

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.

E no 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.

Em linguagens dinamicamente tipadas, os arrays também podem ser usados como outros tipos de dados, como listas:

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

Ou conjuntos não ordenados:

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

Dictionaries

Os dicionários são uma ferramenta poderosa em linguagens dinamicamente tipadas. A maioria dos programadores que vêm de linguagens estaticamente tipadas (como C++ ou C#) ignoram sua existência e tornam sua vida desnecessariamente mais difícil. Esse tipo de dados geralmente não está presente nessas linguagens (ou apenas em formato limitado).

Os dicionários podem mapear qualquer valor para qualquer outro valor com total desconsideração do tipo de dados usado como chave ou valor. Ao contrário da crença popular, eles são eficientes porque podem ser implementados com tabelas de hash. Eles são, de fato, tão eficientes que algumas linguagens vão tão longe quanto implementar matrizes como dicionários.

Exemplo de Dictionary:

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

Dictionaries também são dinâmicos, as chaves podem ser adicionadas ou removidas a qualquer momento a baixo custo:

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

Na maioria dos casos, arrays bidimensionais, muitas vezes podem ser implementados mais facilmente com dictionaries. Aqui está um exemplo de jogo de batalha naval simples:

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

Dicionários também podem ser usados como marcação de dados ou estruturas rápidas. Enquanto dicionários em GDScript lembram dicionários do Python, ele também suporta sintaxe e indexação no estilo Lua, o que o torna útil para escrever estados iniciais e estruturas rápidas:

# 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 e while

Iterar em algumas linguagens estaticamente tipadas pode ser bastante complexo:

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

Isto é geralmente bastante simplificado em linguagens tipificadas dinamicamente:

for s in strings:
    print(s)

Tipos de dados container (arrays e dictionaries) são iteráveis. Dictionaries permitem a iteração das chaves:

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

Também é possível iterar com índices:

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

A função range() pode ter 3 argumentos:

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.

Alguns exemplos em linguagens estaticamente tipadas:

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

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

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

Traduzir para:

for i in range(10):
    pass

for i in range(5, 10):
    pass

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

E a repetição inversa é feita através de um contador negativo:

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

Torna-se:

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

Enquanto

Loops while() são os mesmos em todos os lugares:

var i = 0

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

Iteradores personalizados

Você pode criar iteradores personalizados caso os existentes não satisfaçam suas necessidades, sobrescrevendo as funções _iter_init, _iter_next e _iter_get da classe Variant em seu script. Aqui está um exemplo de implementação de um iterador avançado:

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

E pode ser usado como qualquer outro iterador:

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

Certifique-se de redefinir o estado do iterador em _iter_init, caso contrário, loops for aninhados que usam iteradores personalizados não funcionarão como esperado.

Duck typing

Um dos conceitos mais difíceis de entender quando passa de uma linguagem estaticamente tipada para uma dinâmica é duck typing. Duck typing faz com que o design geral do código seja mais simples e direto de escrever, mas não é óbvio como ele funciona.

Como exemplo, imagine uma situação onde uma grande rocha está caindo de um túnel, esmagando tudo em seu caminho. O código para a rocha, em uma linguagem estaticamente tipada seria algo como:

void BigRollingRock::on_object_hit(Smashable *entity) {

    entity->smash();
}

Desta forma, tudo o que pode ser esmagado por uma rocha teria que herdar Smashable. Se um personagem, um inimigo, uma peça de mobília, uma pequena pedra fossem todos quebráveis, eles precisariam herdar da classe Smashable, possivelmente exigindo herança múltipla. Se herança múltipla fosse indesejada, eles teriam que herdar uma classe comum como Entity. No entanto, não seria muito elegante adicionar um método virtual smash() à Entity apenas se alguns deles puderem ser destruídos.

Com linguagens dinamicamente tipadas, isso não é um problema. Duck typing garante que você só precisa definir uma função smash() onde for necessário e pronto. Não há necessidade de considerar herança, classes base, etc.

func _on_object_hit(object):
    object.smash()

E é isso. Se o objeto que atingiu a pedra grande tiver um método smash (), ele será chamado. Não há necessidade de herança ou polimorfismo. Linguagens dinamicamente tipadas só se preocupam com a instância que possui o método ou membro desejado, não o que ele herda ou o tipo de classe. A definição de Duck Typing deve tornar isso mais claro:

"Quando eu vejo um pássaro que anda como um pato e nada como um pato e grasna como um pato, eu chamo esse pássaro de pato"

Neste caso, traduz para:

"Se o objeto pode ser esmagado, não importa o que seja, apenas esmague-o."

Sim, nós deveríamos chamar isso de tipo Hulk ao invés disso.

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

Então, simplesmente defina esse método e qualquer coisa que a rocha tocar pode ser destruída.