GDScript: Introducción a los lenguajes dinámicos

Acerca de

Este tutorial pretende ser una referencia rápida para aprender a usar GDScript de forma más eficiente. Está enfocado a casos específicos del lenguaje, pero también cubre bastante información sobre lenguajes dinámicamente tipados.

Está pensada para ser especialmente útil para programadores con poca o ninguna experiencia con lenguajes de tipado dinámico.

Naturaleza dinámica

Pros y contras del tipado dinámico

GDScript es un lenguaje de tipado dinámico. Como tal, sus grandes ventajas son:

  • El idioma es simple y fácil de aprender.

  • La mayoría del código puede ser escrito y cambiado rápidamente sin complicaciones.

  • Escribir menos código significa tener menos errores y fallos que corregir.

  • Código más sencillo de leer (menos desorganizado).

  • No es necesario compilar para probarlo.

  • El código en ejecución es pequeño.

  • Duck-typing y polimorfismo por naturaleza.

Siendo las principales desventajas:

  • Menor rendimiento que los lenguajes de tipado estático.

  • Mayor dificultad para refactorizar (no se pueden trazar los símbolos)

  • Algunos errores que se detectarían en el tiempo de compilación en lenguajes de tipado estático, solo aparecen cuando se ejecuta el código (porque el análisis gramatical es más estricto).

  • Menos flexibilidad para el auto completado de código (los tipos de algunas variables solo se conocen en tiempo de ejecución).

Esto, si lo traducimos a la realidad, significa que Godot+GDScript son una combinación diseñada para crear juegos de forma rápida y eficiente. Para juegos que son muy exigentes desde el punto de vista computacional y que no pueden beneficiarse de las herramientas incorporadas en el motor (como los tipos de Vector, el Motor de Física, la biblioteca de Matemáticas, etc.), existe la posibilidad de usar C++. Esto permite crear todo el juego en GDScript y añadir pequeños trozos de C++ en las áreas que necesitan un aumento de rendimiento.

Variables y asignaciones

Todas las variables en lenguajes de tipado dinámico son del tipo "variant". Esto significa que su tipo no es fijo, y solo se puede modificar mediante la asignación. Ejemplo:

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 de funciones:

Las funciones también tienen una naturaleza dinámica, lo que significa que pueden ser invocadas con diferentes argumentos, por ejemplo:

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.

Punteros y referencia:

En lenguajes estáticos como C o C++ (y hasta cierto punto Java y C#), hay una distinción entre una variable y un puntero/referencia a una variable. Este último permite al objeto ser modificado por otras funciones pasando una referencia del original.

En C# o Java, todo lo que no es un tipo integrado (int, float, y algunas veces string) es siempre un puntero o una referencia. Las referencias son recogidas por el recolector de basura automáticamente, lo que significa que se borran cuando no se están usando. Los lenguajes dinámicos tienden a usar este modelo de memoria también. Algunos ejemplos:

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

En GDScript, solo los tipos básicos (int, float, string y los tipos vector) se pasan por valor a las funciones (el valor es copiado). Todo lo demás (instancias, arrays, diccionarios, etc) se pasa como referencia. Las clases que heredan Reference (Herencia por defecto si no se especifica otra) serán liberadas cuando no estén en uso, pero se permite el manejo manual de la memoria también si se hereda específicamente de Object.

Nota

Un valor es pasado por valor cuando se copia cada vez que se especifica como parámetro de una función. Una consecuencia de esto es que la función no puede modificar el parámetro de una manera que sea visible desde fuera de la función:

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

Un valor se pasa por referencia cuando no se copia cada vez que se especifica como parámetro de una función. Esto permite modificar un parámetro de función dentro del cuerpo de la función (y tener el valor modificado accesible fuera de la función). El inconveniente es que los datos pasados como parámetros de función ya no están garantizados como inmutables, lo que puede causar errores difíciles de rastrear si no se hace con cuidado:

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 comparación con el paso por valor, el paso por referencia puede tener un mejor rendimiento al usar objetos grandes, ya que copiar objetos grandes en memoria puede ser lento.

Además, en Godot, los tipos de datos base como String son inmutables. Esto significa que al modificarlos, siempre se devolverá una copia del valor original en lugar de modificar el valor en su lugar.

Arrays

Los arrays en lenguajes de tipado dinámico pueden contener mezclados muchos tipos diferentes de datos y son siempre dinámicos (pueden cambiar su tamaño en cualquier momento). Compara, por ejemplo, los arrays en lenguajes de tipado estático:

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.

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

En lenguajes de tipado dinámico, los arrays pueden replicar otros tipos de datos, como listas:

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

O conjuntos desordenados:

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

Diccionarios

Los diccionarios son una herramienta poderosa en lenguajes de tipado dinámico. La mayoría de los programadores que provienen de lenguajes de programación estáticos (como C++ o C#) ignoran su existencia y hacen su vida más difícil innecesariamente. Este tipo de datos generalmente no está presente en tales lenguajes (o sólo en forma limitada).

Los diccionarios pueden asignar cualquier valor a cualquier otro valor sin tener en cuenta el tipo de datos utilizado como clave o valor. En contra de la creencia popular, son eficientes porque se pueden implementar con tablas de hash. De hecho, son tan eficientes que algunos idiomas llegan a implementar arrays como diccionarios.

Ejemplo de Diccionario:

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

Los diccionarios también son dinámicos, las claves pueden ser agregadas y eliminadas en cualquier punto a muy bajo costo:

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

En la mayoría de los caso, los arrays bidimensionales pueden frecuentemente ser implementados más fácilmente como diccionarios. Aquí hay un juego simple de batalla naval de ejemplo:

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

Los diccionarios también se pueden utilizar como marcadores de datos o estructuras rápidas. Mientras que los diccionarios GDScript se asemejan a los diccionarios python, también soportan sintaxis e indexación estilo Lua, lo que lo hace útil para escribir estados iniciales y estructuras 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 y While

La iteración en lenguajes estáticamente tipados puede ser bastante compleja:

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

Esto es usualmente muy simplificado en lenguajes dinámicamente tipificados:

for s in strings:
    print(s)

Los tipos de datos de los containers (matrices y diccionarios) son iterables. Los diccionarios permiten iterar entre las claves:

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

Iterar con índices también es posible:

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

La función range() puede tomar 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.

Algunos ejemplos de lenguajes estáticamente tipados:

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

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

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

Traducir a:

for i in range(10):
    pass

for i in range(5, 10):
    pass

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

Y el loop invertido se realiza a través de un contador negativo:

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

Se vuelve:

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

While

Los bucles while() son los mismos en todas partes:

var i = 0

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

Itineradores personalizados

Puedes crear iteradores personalizados en caso de que los predeterminados no cumplan con tus necesidades anulando las funciones _iter_init, _iter_next, e _iter_get de la clase Variant en su script. A continuación se muestra un ejemplo de implementación de un iterador avanzado:

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

Y se puede utilizar como cualquier otro iterador:

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

Asegúrate de restablecer el estado del iterador en _iter_init, de lo contrario, los bucles for que utilizan iteradores personalizados no funcionarán como se espera.

Tipado dinámico (duck typing)

Uno de los conceptos más difíciles de entender a la hora de pasar de un lenguaje de tipado estático a otro dinámico es el duck typing. Duck typing hace que el diseño general del código sea mucho más simple y sencillo de escribir, pero no es obvio cómo funciona.

Por ejemplo, imagina una situación en la que una gran roca cae por un túnel, aplastando todo en su camino. El código para la roca, en un lenguaje de tipado estático sería algo así como:

void BigRollingRock::on_object_hit(Smashable *entity) {

    entity->smash();
}

De esta manera, todo lo que pueda ser aplastado por una roca tendrá que heredar Smashable. Si un personaje, un enemigo, un mueble o una pequeña roca se pudieran destruir, tendrían que heredar de la clase Smashable, lo que posiblemente requeriría una herencia múltiple. Si la herencia múltiple no era deseada, entonces tendrían que heredar una clase común como Entity. Sin embargo, no sería muy elegante añadir un método virtual smash() a Entity sólo si unos pocos de ellos pueden ser aplastados.

Con lenguajes de tipado dinámico, esto no es un problema. Duck typing asegura de que sólo tengas que definir una función smash() donde sea necesario y nada más. No hay necesidad de considerar la herencia, clases base, etc.

func _on_object_hit(object):
    object.smash()

Y eso es todo. Si el objeto que golpea a la roca grande tiene un método smash(), entonces será llamado. No se necesita herencia ni polimorfismo. Los lenguajes de tipado dinámico solo se preocupan de que la instancia tenga los métodos o miembros deseados, no que hereda ni el tipo de clase. La definición de Duck Typing debería hacer esto más claro:

"Cuando veo un ave que camina como un pato, nada como un pato y grazna como un pato, entonces llamo al ave pato"

En este caso, se traduce como:

"Si el objeto puede ser golpeado, no me importa lo que es, solo golpéalo."

Sí, en su lugar deberíamos llamarlo Hulk Typing.

Es posible que el objeto que recibe el impacto no tenga una función smash(). Algunos lenguajes de tipado dinámico simplemente ignoran una llamada a un método cuando no existe, pero GDScript es más estricto, por lo que es deseable comprobar si la función existe:

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

Entonces, simplemente define el método y todo lo que la roca toca puede ser aplastado.