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

О движке

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

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

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

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

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

  • Язык прост и легок в изучении.
  • Большую часть кода можно написать и изменить быстро и без лишних хлопот.
  • Less code written means less errors & mistakes to fix.
  • Упрощает чтение кода (меньше беспорядка).
  • Для проверки не требуется компиляция.
  • Время выполнения очень мало.
  • Duck-typing and polymorphism by nature.

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

  • Менее высокая производительность по сравнению со статически типизированными языками.
  • More difficult to refactor (symbols can’t be traced)
  • Некоторые ошибки, которые обычно обнаруживаются во время компиляции на статически напечатанных языках, появляются только при запуске кода (поскольку происходит более строгая синтаксическая проверка).
  • Less flexibility for code-completion (some variable types are only known at run-time).

Это означает, что 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
}
  • 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

В GDScript только базовые типы (int, float, string и vector) передаются в аргументы в виде значений (данные копируются). Во всех других случаях (экземпляры объектов, массивы, словари и т.д.) передаются лишь ссылки. Объекты классов, которые наследуются от class_Reference (устанавливается по-умолчанию) будут автоматически удалены сборщиком мусора при отсутствии ссылок. Ручное управление памятью также возможно, если класс будет наследоваться от class_Object.

Массивы

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

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 pos
        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(). Некоторые динамически типизированные языки просто игнорируют вызов метода, когда он не существует (например, Objective C), но GDScript более строгий, поэтому желательно проверить, существует ли функция:

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

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