GDScript: Вступ до мов динамічного типу

Відомості

Цей підручник має стати короткою довідкою з ефективнішого використання GDScript. Він зосереджений, в основному, на випадках, характерних для цієї мови, але також охоплює багато інформації про динамічні мови в загальному.

Ця довідка може бути особливо корисною для програмістів, у яких мало, або нема взагалі, досвіду роботи з динамічними мовами.

Динамічна природа

Плюси і мінуси динамічного типу

GDScript - це мова динамічного типу. Основними її перевагами є:

  • Мова проста і легка для вивчення.

  • Більшість коду можна записати і змінити швидко та без клопоту.

  • Менше коду означає менше помилок.

  • Простіше читати код (менше безладдя).

  • Не потрібна компіляція для тестування.

  • Час виконання крихітний.

  • Качина типізація і поліморфізм від природи.

Хоча основними недоліками є:

  • Менша продуктивність, ніж у статичних мов.

  • Складніше переробляти (символи не простежуються)

  • Деякі помилки, які, як правило, виявляються під час компіляції в статично набраних мовах, з’являються лише під час виконання коду (оскільки синтаксичний аналіз виразів є суворішим).

  • Менша гнучкість для заповнення коду (деякі типи змінних стають відомі лише під час виконання).

Це, в перекладі на реальність, означає, що Godot + GDScript - це комбінація, призначена для швидкого та ефективного створення ігор. Для ігор, які потребують інтенсивних обчислень і не можуть отримати користь від вбудованих в движок інструментів (таких як векторні типи, фізичний движок, бібліотека математики тощо), також є можливість використання C++. Це дозволяє все-таки створювати більшу частину гри в 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.

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

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.

Масиви

Масиви в динамічних мовах можуть містити багато різних змішаних типів даних всередині і завжди динамічні (їх можна змінити в будь-який час). Порівняйте, наприклад, масиви в статичних мовах:

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!")

Словники

Словники - це потужний інструмент у динамічних мовах. Більшість програмістів, які зі статистичних мов (наприклад 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(). Деякі динамічно набрані мови просто ігнорують виклик методу, коли його не існує (наприклад, Objective C), але GDScript суворіший, тому бажано перевірити, чи функція існує:

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

Потім просто визначте цей метод і все, до чого торкатиметься камінь, може бути розбито.