GDScript: Введение в динамически типизированные языки

О Godot Engine

Это руководство призвано стать кратким руководством по более эффективному использованию GDScript. Он фокусируется на общих случаях, характерных для языка, но также охватывает много информации о динамически типизированных языках.

Она должна быть особенно полезна для программистов с небольшим опытом работы с динамически типизированными языками или вообще без него.

Динамический характер

Плюсы и минусы динамической типизации

GDScript-это динамически типизированный язык. Его главным преимуществом является то, что:

  • Язык прост и легок в изучении.

  • Большую часть кода можно написать и изменить быстро и без лишних хлопот.

  • Меньше написанного кода означает меньше ошибок и опечаток, которые нужно исправить.

  • Упрощает чтение кода (меньше беспорядка).

  • Для проверки не требуется компиляция.

  • Время выполнения очень мало.

  • Утиная типизация и полиморфизм в своём изначальном виде.

В то время как основными недостатками являются:

  • Менее высокая производительность по сравнению со статически типизированными языками.

  • Сложнее рефакторить (символы не прослеживаются)

  • Некоторые ошибки, которые обычно обнаруживаются во время компиляции на статически напечатанных языках, появляются только при запуске кода (поскольку происходит более строгая синтаксическая проверка).

  • Менее гибкое понимание в исполнении кода (некоторые типы переменных известны только во время выполнения).

Это означает, что Godot+GDScript - это комбинация, разработанная для быстрого и эффективного создания игр. Для игр, требующих большой вычислительной мощности и не использующих встроенные инструменты движка (такие как Vector type, Physics Engine, Math library и т.д.), также предусмотрена возможность использования языка Си++. Это позволяет вам создавать большую часть игры в GDScript и добавлять небольшие кусочки C++ в области, которые нуждаются в повышении производительности.

Переменные и присваивание

Все переменные в динамически типизированном языке являются "вариантными" типами. Это означает, что их тип не является фиксированным, а модифицируется только через присваивание. Пример:

Статическая типизация:

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

Динамическая типизация:

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

В качестве аргументов функции:

Функции носят динамичный характер, что означает, что их можно вызывать, например, с помощью различных аргументов:

Статическая типизация:

void print_value(int value) {

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

[..]

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

Динамическая типизация:

func print_value(value):
    print(value)

[..]

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

Указатели и ссылки:

В статически типизированных языках, таких как C или C++ (а также в некоторых расширениях Java и C#), различают переменные и указатели/ссылки на переменные. Последние позволяют модифицировать исходный объект по ссылке, передаваемой в другие функции.

В C# или Java все типы, кроме встроенных (int, float, частично String), всегда являются указателями или ссылками. Ссылки автоматически удаляются сборщиком мусора, когда они становятся больше ненужными. Динамически типизированные языки также стремятся использовать эту модель. Несколько примеров:

  • 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.
}
  • GDScriрt:

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.

Примечание

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

Значение передаётся по ссылке, когда оно, а не копируется каждый раз, когда оно указывается в качестве параметра функции. Это позволяет изменять параметр функции в теле функции (и иметь доступ к изменённому значению вне функции). Недостатком является то, что данные, передаваемые в качестве параметра функции, больше не гарантируют неизменяемость, что может привести к трудноотслеживаемым ошибкам, если не сделать это аккуратно:

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.

Массивы

Массивы в динамически типизированных языках могут содержать элементы разных типов данных; массивы всегда являются динамическими (т. е. их размер может быть изменён в любой момент). Сравните с примерами массивов в статически типизированных языках:

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.

И в 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.

В динамически типизированных языках массивы могут быть заменены другими типами, такими как списки:

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

Или неупорядоченные множества:

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

Словари (Dictionaries)

Словари являются мощным инструментом в динамически типизированных языках. Большинство программистов из статически типизированных языков (таких как C++ или C#) игнорируют существование словарей и усложняют этим себе жизнь. Этот тип данных обычно отсутствует в таких языках (или присутствует лишь в ограниченной форме).

Словари могут сопоставлять любое значение с любым другим значением, полностью игнорируя тип данных, используемый в качестве ключа или значения. Вопреки распространенному мнению, они эффективны, потому что могут быть реализованы с помощью хеш-таблиц. Они, фактически, настолько эффективны, что в некоторых языках массивы реализуют как словари.

Пример словаря:

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

Словари также являются динамическими, ключи могут добавляться или удаляться в любой момент при небольших затратах:

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

В большинстве случаев двумерные массивы могут быть проще реализованы с помощью словарей. Вот простой пример игры "морской бой":

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

Словари могут также использоваться как разметка данных или быстрые структуры. Хотя словари GDScript напоминают словари python, он также поддерживает синтаксис и индексацию в стиле Lua, что делает его полезным для написания начальных состояний и быстрых структур:

# 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

Циклы в некоторых статически типизированных языках могут быть довольно сложными:

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

В динамически типизированных языках это выглядит значительно проще:

for s in strings:
    print(s)

Контейнеры (массивы и словари) являются итерируемыми. Словари позволяют перебирать ключи:

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

Итерация с индексами также возможна:

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

Функция range() может принимать 3 аргумента:

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.

Примеры в статически типизированных языках:

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

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

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

Транслируется в:

for i in range(10):
    pass

for i in range(5, 10):
    pass

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

И обратный цикл выполняется через отрицательный счётчик цикла:

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

Становится:

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

Пока

Циклы while() везде одинаковые:

var i = 0

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

Пользовательские итераторы

Вы можете создавать собственные итераторы в случае, если стандартные по умолчанию не вполне соответствуют вашим потребностям, переопределив функции _iter_init, _iter_next и _iter_get класса Variant в вашем скрипте. Ниже приведен пример реализации итератора прямого действия:

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

И его можно использовать, как и любой другой итератор:

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

Обязательно сбросьте состояние итератора в _iter_init, иначе вложенные for-циклы, использующие пользовательские итераторы не будут работать, как ожидалось.

Утиная типизация

Одной из самых сложных концепций, которую нужно понять при переходе от языка со статической типизацией к динамической, является Утиная типизация. Утиная типизация делает общий дизайн кода намного проще и понятнее для написания, но для новичка это может быть не так очевидно.

В качестве примера представим ситуацию, когда большой камень падает по туннелю, разбивая все на своем пути. Код для камня на статически типизированном языке будет выглядеть примерно так:

void BigRollingRock::on_object_hit(Smashable *entity) {

    entity->smash();
}

Таким образом, все, что может быть разбито камнем, должно наследовать Smashable. Если персонаж, враг, предмет мебели, маленький камень - все это можно разбить, им будет необходимо наследовать от класса Smashable, возможно, требуя множественного наследования. Если множественное наследование нежелательно, им придется наследовать общий класс, такой как Entity. Тем не менее, было бы не очень элегантно добавить виртуальный метод smash() в Entity, только если некоторые из них могут быть разбиты.

С динамически типизированными языками это не проблема. Утиная типизация гарантирует, что вам нужно только определить функцию smash() там, где требуется, и все. Не нужно учитывать наследование, базовые классы и т.д.

func _on_object_hit(object):
    object.smash()

И это все. Если объект, столкнувшийся с камнем, имеет метод smash(), он будет вызван. Нет необходимости наследования или полиморфизма. Языки с динамической типизацией заботятся только о том, чтобы экземпляр имел нужный метод или член, а не о том, что он наследует, или о типе класса. Определение Утиной типизации должно прояснить это:

"Когда Я вижу птицу, которая ходит как утка и плавает как утка, и крякает как утка, Я называю эту птицу уткой"

В этом случае это переводится как:

"Если объект может быть разбит, все равно, что это, просто разбейте его."

Да, возможно нам следует называть это типизацией Халка.

Возможно, поражаемый объект не имеет функции smash(). Некоторые языки с динамической типизацией просто игнорируют вызов метода, если он не существует, но GDScript более строгий, поэтому желательно проверить, существует ли функция:

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

Затем просто определите этот метод, и все, к чему прикасается камень, может быть разбито.