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.

У GDScript лише базові типи (int, float, string та векторні типи) передаються функціям, як значення (значення копіюється). Все інше (екземпляри, масиви, словники тощо) передається як посилання. Класи, які успадковують Reference (за замовчуванням, якщо нічого не вказано), будуть звільнені, коли вони не використовуються, але можливе також керування пам'яттю вручну, якщо успадковувати вручну від 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!")

Словники

Словники - це потужний інструмент у динамічних мовах. Більшість програмістів, які зі статистичних мов (наприклад 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

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

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