Яка загальна процедура використовується, коли компілятори статично перевіряють "складні" вирази?


23

Примітка. Коли я використовував у заголовку "складний", я маю на увазі, що у виразі є багато операторів і операндів. Не те, що сам вираз складний.


Я нещодавно працював над простим компілятором для складання x86-64. Я закінчив головний передній кінець компілятора - лексер і аналізатор - і тепер можу створити абстрактне синтаксичне подання моєї програми. А оскільки моя мова буде статично набрана, я зараз роблю наступний етап: введіть перевірку вихідного коду. Однак я прийшов до проблеми, і не зміг самостійно її вирішити.

Розглянемо наступний приклад:

Аналізатор мого компілятора прочитав цей рядок коду:

int a = 1 + 2 - 3 * 4 - 5

І перетворив його в наступний AST:

       =
     /   \
  a(int)  \
           -
         /   \
        -     5
      /   \
     +     *
    / \   / \
   1   2 3   4

Тепер він повинен набрати перевірку AST. вона починається з першого типу перевірки =оператора. Він спочатку перевіряє ліву частину оператора. Він бачить, що змінна aоголошується як ціле число. Тепер він повинен переконатися, що вираз правої частини оцінюється на ціле число.

Я розумію, як це можна зробити, якби вираз був лише одним значенням, таким як 1або 'a'. Але як би це було зроблено для виразів із кількома значеннями та операндами - складним виразом - таким, як вище? Щоб правильно визначити значення виразу, видається, що перевіряючий тип фактично повинен був би виконати сам вираз і записати результат. Але це, очевидно, перемагає мету поділу фаз складання та виконання.

Єдиний інший спосіб, на який я думаю, це можна зробити, - це рекурсивно перевірити аркуш кожної підвираження в AST та перевірити, чи всі типи аркуша відповідають очікуваному типу оператора. Отже, починаючи з =оператора, перевіряючий тип буде потім сканувати всі ліві сторони AST і перевіряти, що в аркушах є цілі числа. Потім він повторить це для кожного оператора в піддекспресії.

Я спробував дослідити цю тему в своїй копії "Книги Драконів" , але, схоже, не надто детально, і просто повторює те, що вже знаю.

Який звичайний метод застосовується, коли компілятор перевіряє вирази з багатьма операторами та операндами? Чи застосовується якийсь із згаданих нами методів? Якщо ні, то які методи та як саме вони би працювали?


8
Існує очевидний і простий спосіб перевірити тип виразу. Ви краще скажіть нам, що змушує вас називати це "неприємним".
gnasher729

12
Звичайним методом є "другий метод": компілятор виводить тип складного вираження з типів його піддепресій. Це було головним моментом денотаційної семантики, і більшість типних систем, створених донині.
Joker_vD

5
Два підходи можуть спричинити різну поведінку: підхід зверху вниз double a = 7/2 намагатиметься інтерпретувати праву частину як подвійну, отже, спробує інтерпретувати чисельник та знаменник як подвійні та перетворити їх у разі потреби; в результаті a = 3.5. Знизу вгору буде виконуватися ціле поділ і перетворюватися лише на останньому кроці (призначенні), так a = 3.0.
Хаген фон Ейтцен

3
Зауважте, що картина вашого AST не відповідає вашому вираженню, int a = 1 + 2 - 3 * 4 - 5аint a = 5 - ((4*3) - (1+2))
Базиле Старинкевич

22
Ви можете "виконати" вираз на типах, а не на значеннях; наприклад int + intстає int.

Відповіді:


14

Рекурсія - це відповідь, але ви спускаєтесь до кожного піддірева, перш ніж обробляти операцію:

int a = 1 + 2 - 3 * 4 - 5

до форми дерева:

(assign (a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

Визначення типу трапляється спочатку ходити лівою стороною, потім правою, а потім керувати оператором, як тільки виводиться типи операндів:

(assign*(a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> спуститися в lhs

(assign (a*) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> висновок a. aяк відомо int. assignЗараз ми знову у вузлі:

(assign (int:a)*(sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> спуститися в rhs, потім в lhs внутрішніх операторів, поки ми не потрапимо в щось цікаве

(assign (int:a) (sub*(sub (add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub*(add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add*(1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add (1*) (2)) (mul (3) (4))) (5))

-> зробити висновок про тип 1, який є int, і повернутися до батьківського

(assign (int:a) (sub (sub (add (int:1)*(2)) (mul (3) (4))) (5))

-> перейти в резус

(assign (int:a) (sub (sub (add (int:1) (2*)) (mul (3) (4))) (5))

-> зробити висновок про тип 2, який є int, і повернутися до батьківського

(assign (int:a) (sub (sub (add (int:1) (int:2)*) (mul (3) (4))) (5))

-> зробити висновок про тип add(int, int), який є int, і повернутися до батьківського

(assign (int:a) (sub (sub (int:add (int:1) (int:2))*(mul (3) (4))) (5))

-> спуститися в резус

(assign (int:a) (sub (sub (int:add (int:1) (int:2)) (mul*(3) (4))) (5))

тощо, поки не закінчите

(assign (int:a) (int:sub (int:sub (int:add (int:1) (int:2)) (int:mul (int:3) (int:4))) (int:5))*

Чи буде саме призначення також виразом із типом, залежить від вашої мови.

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


43

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

Прочитайте вікі-сторінки про систему типу та умовиводи типу та про систему типу Hindley -Milner , яка використовує уніфікацію . Читайте також про денотаційну семантику та оперативну семантику .

Перевірка типу може бути простішою, якщо:

  • всі ваші змінні на зразок aявно оголошуються типом. Це як C або Pascal або C ++ 98, але не схоже на C ++ 11, який має певний тип висновку auto.
  • всі буквальні значення, такі як 1, 2або 'c'мають притаманний тип: int literal завжди має тип int, літеральний символ завжди має тип char,….
  • функції та оператори не перевантажуються, наприклад, +оператор завжди має тип (int, int) -> int. C має перевантаження для операторів ( +працює для підписаних і непідписаних цілочисельних типів і для парних), але немає перевантаження функцій.

За таких обмежень може бути достатньо рекурсивного алгоритму декорування типу AST (це стосується лише типів , а не конкретних значень, а також підходу до компіляції):

  • Для кожного діапазону ви зберігаєте таблицю типів усіх видимих ​​змінних (званих середовищем). Після декларації int aви додасте запис a: intдо таблиці.

  • Введення листя - це тривіальна база рекурсії: тип літералів на зразок 1уже відомий, а тип змінних, таких як, aможна шукати в оточенні.

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

Таким чином , у вашому прикладі, 4 * 3і 1 + 2набрані , intтому що 4& 3і 1& 2були раніше набрані intі ваші правила типізації кажуть , що сума або твір двох int-s є int, і так далі для (4 * 3) - (1 + 2).

Потім прочитайте книгу про типи та мови програмування Пірса . Рекомендую вивчити крихітку Ocaml та λ-числення

Для більш динамічно набраних мов (як Lisp) читайте також Lisp Queinnec in Small Pieces

Читайте також книгу « Прагматика мов програмування» Скотта

До речі, ви не можете мати код агностичного введення мови, оскільки система типів є важливою частиною семантики мови .


2
Як C ++ 11 autoне простіше? Без цього вам потрібно розібратися з типом праворуч, а потім побачити, чи є збіг або перетворення з типом з лівого боку. З autoвами просто з'ясуйте тип правого боку і все закінчено.
nwp

3
@nwp Загальна ідея визначень змінних C ++ auto, C # varі Go :=дуже проста: введіть прапорець у правій частині визначення. Отриманий тип - це тип змінної зліва. Але чорт у деталях. Наприклад, визначення C ++ може бути самореференційним, тому ви можете звернутися до змінної, оголошеної в rhs, наприклад int i = f(&i). Якщо тип iвиведений, вищевказаний алгоритм вийде з ладу: вам потрібно знати тип iдля висновку типу i. Натомість вам знадобиться повний висновок типу HM зі змінними типу.
амон

13

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

Отже, вираз можна переписати як:

int a{operator-(operator-(operator+(1,2),operator*(3,4)),5)};

Потім роздільна здатність перевантаження наступить і вирішить, що кожна функція має тип (int, int)або (const int&, const int&)тип.

Цей спосіб робить роздільну здатність типу легкою для розуміння та дотримання та (що ще важливіше) простою у виконанні. Інформація про типи тече лише одним способом (із внутрішніх виразів назовні).

Це є причиною, чому double x = 1/2;це призведе до, x == 0оскільки 1/2оцінюється як вираз int.


6
Практично справедливо для C, де +не обробляються як функціональні виклики (оскільки він має різні типи для doubleта для intоперандів)
Basile Starynkevitch

2
@BasileStarynkevitch: Це реалізується як ряд перевантажених функцій: operator+(int,int), operator+(double,double), operator+(char*,size_t)і т.д. Аналізатор просто повинен стежити за яких один обраний.
Mooing Duck

3
@aschepler Ніхто не припускав, що на рівні джерел і специфікацій C фактично перевантажує функції або функції оператора
кішка

1
Звичайно, ні. Тільки зазначивши, що у випадку з аналізатором C "виклик функції" - це щось інше, з чим вам потрібно було б мати справу, що насправді не має великого спільного з "операторами як виклики функцій", як описано тут. Насправді, в С з'ясувати тип цього типу f(a,b)дещо простіше, ніж з'ясувати тип a+b.
aschepler

2
Будь-який розумний компілятор C має кілька фаз. Біля передньої частини (після препроцесора) ви знаходите аналізатор, який створює AST. Тут цілком зрозуміло, що оператори не функціонують дзвінками. Але в генерації коду вам більше не важливо, яка мовна конструкція створила вузол AST. Властивості самого вузла визначають спосіб обробки вузла. Зокрема, + може бути викликом функції - це часто трапляється на платформах з імітацією математики з плаваючою комою. Рішення використовувати емуляцію математики FP відбувається при генерації коду; не потрібно попередньої різниці AST.
MSalters

6

Сфокусувавшись на своєму алгоритмі, спробуйте змінити його знизу вгору. Ви знаєте тип змінних і констант pf; позначте вузол, що містить оператор, з типом результату. Нехай аркуш визначає тип оператора, також протилежний вашій ідеї.


6

Насправді це досить просто, якщо ви вважаєте +, що це різні функції, а не окрема концепція.

    int operator=(int)
     /   \
  a(int)  \
        int operator-(int,int)
         /                  \
    int operator-(int,int)    5
         /              \
int operator+(int,int) int operator*(int,int)
    / \                      / \
   1   2                    3   4

Під час етапу розбору правого боку аналізатор отримує 1, знає, що це int, потім розбирає +, і зберігає це як "невирішене ім'я функції", потім він розбирає 2, знає, що це int, а потім повертає це в стек. Тепер +функціональний вузол знає обидва типи параметрів, тому може вирішувати +в int operator+(int, int), тому тепер він знає тип цього підвиразу, і аналізатор продовжує його веселим шляхом.

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

char* ptr = itoa(3);

Тут дерево:

    char* itoa(int)
     /           \
  ptr(char*)      3

4

Основою для перевірки типу є не те, що робить компілятор, це те, що визначає мова.

У мові С кожен операнд має тип. "abc" має тип "масив const char". 1 має тип "int". 1L має тип "довгий". Якщо x і y - вирази, то існують правила для типу x + y тощо. Тож компілятор, очевидно, повинен дотримуватися правил мови.

У сучасних мовах, таких як Swift, правила набагато складніші. Деякі випадки прості, як у C. В інших випадках компілятор бачить вираз, заздалегідь йому було сказано, який тип повинен мати вираз, і на основі цього визначає типи піддепресій. Якщо x і y є змінними різних типів і призначено однаковий вираз, це вираження може бути оцінено по-іншому. Наприклад, при призначенні 12 * (2/3) буде призначено 8,0 для подвійного і 0 для Int. І у вас є випадки, коли компілятор знає, що два типи пов’язані між собою і з'ясовує, на яких типах вони ґрунтуються.

Швидкий приклад:

var x: Double
var y: Int

x = 12 * (2 / 3)
y = 12 * (2 / 3)

print (x, y)

друкує "8,0, 0".

У призначенні x = 12 * (2/3): Ліва сторона має відомий тип Double, тому права частина повинна мати тип Double. Є лише одне перевантаження для оператора "*", який повертає Double, і це Double + Double -> Double. Тому 12 повинні мати тип Double, а також 2 / 3. 12 підтримує протокол "IntegerLiteralConvertible". У Double є ініціалізатор, що приймає аргумент типу "IntegerLiteralConvertible", тому 12 перетворюється в Double. 2/3 повинні мати тип Double. Є лише одне перевантаження для оператора "/", який повертає Double, і це Double / Double -> Double. 2 і 3 перетворюються на Double. Результат 2/3 - 0,6666666. Результат 12 * (2/3) - 8,0. 8,0 присвоюється х.

У призначенні y = 12 * (2/3), y в лівій частині має тип Int, тому права частина повинна мати тип Int, тому 12, 2, 3 перетворюються на Int з результатом 2/3 = 0, 12 * (2/3) = 0.

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