Як реалізовані дженерики в сучасному компіляторі?


15

Що я тут маю на увазі, як ми переходимо від якогось шаблону T add(T a, T b) ...до згенерованого коду? Я продумав декілька способів цього досягти, ми зберігаємо загальну функцію в AST як Function_Nodeі тоді, коли ми використовуємо її, ми зберігаємо в оригінальному вузлі функції копію себе з усіма типами, Tзаміненими на типи, які є використовується. Наприклад add<int>(5, 6)буде зберігати копію узагальненої функції для addі замінити всі типи T в копії з int.

Так би виглядало приблизно так:

struct Function_Node {
    std::string name; // etc.
    Type return_type;
    std::vector<std::pair<Type, std::string>> arguments;
    std::vector<Function_Node> copies;
};

Тоді ви можете генерувати код для них, і коли ви відвідуєте Function_Nodeсписок, де перелік копій copies.size() > 0, ви посилаєтесь visitFunctionна всі копії.

visitFunction(Function_Node& node) {
    if (node.copies.size() > 0) {
        for (auto& node : nodes.copies) {
            visitFunction(node);
        }
        // it's a generic function so we don't want
        // to emit code for this.
        return;
    }
}

Це добре вийшло? Як сучасні компілятори підходять до цієї проблеми? Я думаю, що, можливо, ще один спосіб зробити це - ви могли б ввести копії в AST, щоб вона проходила через усі семантичні фази. Я також подумав, що, можливо, ви можете їх генерувати у безпосередній формі, наприклад, MIR Руста або Swifts SIL, наприклад.

Мій код написаний на Java, приклади тут є C ++, оскільки це трохи менше деталізованих прикладів - але принцип в основному те саме. Хоча може бути кілька помилок, оскільки це просто виписано від руки в поле запитання.

Зауважте, що я маю на увазі сучасний компілятор, як найкращий спосіб підійти до цієї проблеми. І коли я кажу, що генерики я не маю на увазі, як Java generics, де вони використовують стирання типу.


У C ++ (в інших мовах програмування є дженерики, але кожен реалізує їх по-різному), це в основному гігантська макросистема компіляції. Фактичний код генерується за допомогою заміщеного типу.
Роберт Харві

Чому б не набрати стирання? Майте на увазі, що це робить не тільки Java, і це не погана техніка (залежно від ваших вимог).
Андрес Ф.

@AndresF. Я думаю, що з огляду на те, як працює моя мова, це не вийшло б добре.
Джон Флоу

2
Я думаю, вам слід уточнити, про які дженерики ви говорите. Наприклад, шаблони C ++, generic C # та generics Java дуже відрізняються один від одного. Ви кажете, що не маєте на увазі дженерики Java, але не говорите про те, що маєте на увазі.
svick

2
Це дійсно потрібно зосередити увагу на одній мовній системі, щоб уникнути занадто широкої
Daenyth

Відповіді:


14

Як реалізовані дженерики в сучасному компіляторі?

Я пропоную вам прочитати вихідний код сучасного компілятора, якщо ви хочете знати, як працює сучасний компілятор. Я б почав з проекту Roslyn, який реалізує компілятори C # і Visual Basic.

Зокрема, звертаю вашу увагу на код у компіляторі C #, який реалізує символи типу:

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Symbols

і ви також можете переглянути код правил конвертованості. Там багато що стосується алгебраїчної маніпуляції родових типів.

https://github.com/dotnet/roslyn/tree/master/src/Compilers/CSharp/Portable/Binder/Semantics/Conversions

Я дуже старався, щоб останнє було легко читати.

Я подумав про декілька способів цього досягти, ми зберігаємо загальну функцію в AST як Function_Node, а потім кожного разу, коли ми її використовуємо, ми зберігаємо в оригінальному вузлі функції копію себе з усіма типами T, заміненими типами які використовуються.

Ви описуєте шаблони , а не дженерики . C # і Visual Basic мають фактичні дженерики в системах свого типу.

Коротко кажучи, вони працюють так.

  • Почнемо з встановлення правил щодо того, що формально являє собою тип під час компіляції. Наприклад: intце тип, параметр типу T- це тип, для будь-якого типу X, тип масиву X[]- це також тип тощо.

  • Правила для дженериків передбачають підміну. Наприклад, class C with one type parameterне є типом. Це шаблон для виготовлення типів. class C with one type parameter called T, under substitution with int for T - тип.

  • Правила, що описують зв'язки між типами - сумісність при призначенні, як визначити тип виразу тощо, - розроблені та реалізовані в компіляторі.

  • Розроблена та реалізована мова байт-коду, яка підтримує загальні типи в її системі метаданих.

  • Під час виконання компілятор JIT перетворює байт-код у машинний код; вона відповідає за побудову відповідного машинного коду з урахуванням загальної спеціалізації.

Так, наприклад, в C #, коли ви говорите

class C<T> { public void X(T t) { Console.WriteLine(t); } }
...
var c = new C<int>(); 
c.X(123);

тоді компілятор перевіряє, що в C<int>, аргумент intє дійсною заміною T, і генерує метадані та байт-код відповідно. Під час виконання тремтіння виявляє, що а C<int>створюється вперше, і динамічно генерує відповідний машинний код.


9

Більшість реалізацій генеричних даних (а точніше: параметричний поліморфізм) використовують стирання типу. Це значно спрощує проблему компіляції загального коду, але працює лише для типів у вікні: оскільки кожен аргумент є фактично непрозорим покажчиком, для виконання операцій над аргументами нам потрібен VTable або подібний механізм диспетчеризації. На Java:

<T extends Addable> T add(T a, T b) { … }

можна складати, перевіряти тип і називати так само, як і

Addable add(Addable a, Addable b) { … }

за винятком того, що дженерики надають перевіряючому типу набагато більше інформації на сайті виклику. Ця додаткова інформація може оброблятися змінними типів , особливо коли робляться загальні типи. Під час перевірки типу кожен загальний тип можна замінити змінною, назвемо це $T1:

$T1 add($T1 a, $T1 b)

Потім змінна типу оновлюється більшою кількістю фактів, коли вони стають відомими, поки вона не може бути замінена конкретним типом. Алгоритм перевірки типу повинен бути написаний таким чином, що вміщує ці змінні типу, навіть якщо вони ще не вирішені до повного типу. У самій Java це зазвичай можна зробити легко, оскільки тип аргументів часто відомий до того, як потрібно знати тип виклику функції. Помітним винятком є ​​вираз лямбда як аргумент функції, який вимагає використання змінних такого типу.

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

VTable для загальних аргументів можна уникнути, якщо загальна функція не виконує жодних операцій над типом, а лише передає їх іншій функції. Наприклад, функція Haskell call :: (a -> b) -> a -> b; call f x = f xне повинна встановлювати xаргументи. Однак для цього потрібна умовна умова виклику, яка може передавати значення, не знаючи їх розміру, що по суті обмежує його покажчиками.


C ++ сильно відрізняється від більшості мов у цьому відношенні. Класифікований клас або функція (я тут обговорюватиму лише шаблонні функції) не може називатися сама по собі. Натомість шаблони слід розуміти як метафункцію часу компіляції, яка повертає фактичну функцію. На хвилину ігноруючи умовиводи аргументу шаблону, загальний підхід зводиться до цих кроків:

  1. Застосуйте шаблон до наданих аргументів шаблону. Наприклад, виклик template<class T> T add(T a, T b) { … }як add<int>(1, 2)би дав нам фактичну функцію int __add__T_int(int a, int b)(або будь-який підхід до керування іменами).

  2. Якщо код для цієї функції вже створений у поточному блоці компіляції, продовжуйте. В іншому випадку генеруйте код так, ніби функція int __add__T_int(int a, int b) { … }була записана у вихідному коді. Це передбачає заміну всіх випадків аргументу шаблону його значеннями. Ймовірно, це перетворення AST → AST. Потім виконайте перевірку типу на створеному AST.

  3. Складіть виклик так, як ніби був вихідний код __add__T_int(1, 2).

Зауважте, що шаблони C ++ мають складну взаємодію з механізмом вирішення перевантаження, який я не хочу тут описувати. Також зауважте, що для цього генерування коду неможливе використання шаблонного методу, який також є віртуальним - підхід на основі стирання не зазнає цього істотного обмеження.


Що це означає для вашого компілятора та / або мови? Ви повинні добре подумати про те, який тип дженериків ви хочете запропонувати. Стирання типу за відсутності висновку про тип - це найпростіший можливий підхід, якщо ви підтримуєте типи, що містяться в коробці. Спеціалізація шаблонів здається досить простою, але, як правило, передбачає керування іменами та (для декількох одиниць компіляції) суттєве дублювання у висновку, оскільки шаблони інстанціюються на сайті виклику, а не на сайті визначення.

Підхід, який ви показали, по суті є підходом до C ++. Однак ви зберігаєте спеціалізовані / інстанційні шаблони як "версії" головного шаблону. Це вводить в оману: вони концептуально не однакові, і різні екземпляри функції можуть мати диво різні типи. Це в подальшому ускладнить справи, якщо ви також дозволите перевантаження функцій. Натомість вам знадобиться поняття про набір перевантажень, який містить усі можливі функції та шаблони, які мають ім’я. За винятком вирішення перевантаження, ви можете вважати, що різні шаблони інстанції повністю відокремлені один від одного.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.