Как языки программирования определяют функции?


28

Как языки программирования определяют и сохраняют функции / методы? Я создаю интерпретируемый язык программирования в Ruby и пытаюсь понять, как реализовать объявление функций.

Моя первая идея - сохранить содержание декларации на карте. Например, если я сделал что-то вроде

def a() {
    callSomething();
    x += 5;
}

Тогда я бы добавил запись в мою карту:

{
    'a' => 'callSomething(); x += 5;'
}

Проблема в том, что он станет рекурсивным, потому что мне придется вызывать мой parseметод для строки, который затем будет вызывать parseснова, когда он встретится doSomething, и тогда мне в конечном итоге не хватит места в стеке.

Итак, как интерпретированные языки справляются с этим?


О, и это мой первый пост на Programmers.SE, поэтому, пожалуйста, сообщите мне, если я делаю что-то не так или это не по теме. :)
дверная ручка

В прошлом я сохранял их все встроенными в своих токенах, а вызовы функций - это просто переходы к определенному смещению (так же, как метки в Assembly). Вы токенизируете сценарий? Или разбор строк каждый раз?
Саймон Уайтхед

@SimonWhitehead Я разбил строку на токены, а затем проанализировал каждый токен отдельно.
Дверная ручка

3
Если вы новичок в разработке и реализации языка программирования, вы можете проверить некоторую литературу по этому вопросу. Самым популярным является «Книга Дракона»: en.wikipedia.org/wiki/… , но есть и другие, более краткие тексты, которые также очень хороши. Например, «Реализация языков программирования» Аарне Ранты можно получить бесплатно здесь: bit.ly/15CF6gC .
evilcandybag

1
@ddyer Спасибо! Я гуглил переводчика LISP на разных языках, и это действительно помогло. :)
дверная ручка

Ответы:


31

Буду ли я прав, если предположим, что ваша функция "parse" не только анализирует код, но и выполняет его одновременно? Если вы хотите сделать это таким образом, вместо сохранения содержимого функции на карте сохраните местоположение функции.

Но есть и лучший способ. Это требует немного больше усилий, но с ростом сложности дает гораздо лучшие результаты: используйте абстрактное синтаксическое дерево.

Основная идея заключается в том, что вы анализируете код только один раз, когда-либо. Затем у вас есть набор типов данных, представляющих операции и значения, и вы создаете их дерево, например:

def a() {
    callSomething();
    x += 5;
}

будет выглядеть так:

Function Definition: [
   Name: a
   ParamList: []
   Code:[
      Call Operation: [
         Routine: callSomething
         ParamList: []
      ]
      Increment Operation: [
         Operand: x
         Value: 5
      ]
   ]
]

(Это просто текстовое представление структуры гипотетического AST. Фактическое дерево, вероятно, не будет в текстовой форме.) В любом случае, вы анализируете свой код в AST, а затем либо запускаете свой интерпретатор через AST, или используйте второй проход («генерация кода»), чтобы превратить AST в некоторую форму вывода.

В случае с вашим языком, вероятно, вам нужно иметь карту, которая отображает имена функций в функции AST, а не имена функций в строки функций.


Хорошо, но проблема все еще здесь: она использует рекурсию. В конце концов, мне не хватит места в стеке, если я это сделаю.
Дверная ручка

3
@ Doorknob: Что конкретно использует рекурсию? Любой блочно-структурированный язык программирования (который является любым современным языком более высокого уровня, чем ASM) по своей природе основан на дереве и, следовательно, рекурсивен по своей природе. В каком конкретном аспекте вы беспокоитесь о переполнении стека?
Мейсон Уилер

1
@ Doorknob: Да, это присущее любому языку свойство, даже если оно скомпилировано в машинный код. (Стек вызовов является проявлением этого поведения.) Я на самом деле участвую в системе сценариев, которая работает так, как я описал. Присоединяйтесь ко мне в чате на chat.stackexchange.com/rooms/10470/… и я расскажу вам о некоторых методах эффективной интерпретации и минимизации влияния на размер стека. :)
Мейсон Уилер

2
@ Doorknob: здесь нет проблемы рекурсии, потому что вызов функции в AST ссылается на функцию по имени , ей не нужна ссылка на фактическую функцию . Если вы компилировали до машинного кода, то в конечном итоге вам понадобится адрес функции, поэтому большинство компиляторов делают несколько проходов. Если вы хотите иметь однопроходный компилятор, вам нужны «предварительные объявления» всех функций, чтобы компилятор мог назначать адреса заранее. Компиляторы байт-кода даже не беспокоятся об этом, джиттер обрабатывает поиск имени.
Aaronaught

5
@ Doorknob: это действительно рекурсивно. И да, если в вашем стеке всего 16 записей, вы не сможете проанализировать (((((((((((((((( x ))))))))))))))))). На самом деле стеки могут быть намного больше, а грамматическая сложность реального кода довольно ограничена. Конечно, если этот код должен быть читаемым человеком.
MSalters

4

Вы не должны вызывать parse при просмотре callSomething()(я полагаю, вы имели в виду, callSomethingа не doSomething). Разница между aи callSomethingзаключается в том, что один является определением метода, а другой - вызовом метода.

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

  • Проверьте, не существует ли функция с такой же подписью
  • Убедитесь, что объявление метода выполняется в надлежащей области (т. Е. Могут ли методы быть объявлены внутри других объявлений метода?)

Предполагая, что эти проверки пройдены, вы можете добавить их на свою карту и начать проверку содержимого этого метода.

Когда вы находите вызов метода как callSomething(), вы должны выполнить следующие проверки:

  • Существует ли callSomethingна вашей карте?
  • Правильно ли он вызывается (количество аргументов соответствует найденной вами подписи)?
  • Являются ли аргументы действительными (если имена переменных используются, они объявлены? Можно ли получить к ним доступ в этой области?)?
  • Можно ли вызвать что-то из того места, где вы находитесь (приватно, публично, защищено?)?

Если вы обнаружите, что callSomething()это нормально, то в данный момент то, что вы хотели бы сделать, зависит от того, как вы хотите к нему подойти. Строго говоря, если вы знаете, что такой вызов в данный момент подходит, вы можете только сохранить имя метода и аргументы, не вдаваясь в подробности. Когда вы запустите свою программу, вы вызовете метод с аргументами, которые вы должны иметь во время выполнения.

Если вы хотите пойти дальше, вы можете сохранить не только строку, но и ссылку на фактический метод. Это было бы более эффективно, но если вам нужно управлять памятью, это может сбить с толку. Я бы порекомендовал вам просто держаться за строку сначала. Позже вы можете попытаться оптимизировать.

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

Надеюсь, это поможет! Добро пожаловать в Программисты SE!


2

Читая ваш пост, я заметил два вопроса в вашем вопросе. Самый важный из них - как разобрать. Есть много видов анализаторов (например , метод рекурсивного спуска , LR парсеры , Packrat парсеры ) и синтаксический анализатор генераторов (например , GNU Зубр , ANTLR ) , вы можете использовать для обхода текстовой программы «рекурсивно» задан (явно или неявно) грамматики.

Второй вопрос касается формата хранения функций. Когда вы не выполняете синтаксически-ориентированный перевод , вы создаете промежуточное представление вашей программы, которое может быть абстрактным синтаксическим деревом или каким-либо пользовательским промежуточным языком, для дальнейшей обработки с ним (компиляция, преобразование, выполнение, запись в файл и т. д.).


1

С общей точки зрения, определение функции - это немного больше, чем просто метка или закладка в коде. Большинство других операторов цикла, области видимости и условных операторов аналогичны; они заменяют базовую команду «перейти» или «перейти» на нижних уровнях абстракции. Вызов функции в основном сводится к следующим низкоуровневым компьютерным командам:

  • Объединить данные всех параметров плюс указатель на следующую инструкцию текущей функции в структуру, известную как «кадр стека вызовов».
  • Вставьте этот кадр в стек вызовов.
  • Перейти к смещению памяти первой строки кода функции.

«Возвратный» оператор или аналог будет делать следующее:

  • Загрузить значение, которое будет возвращено в регистр.
  • Загрузите указатель на звонящего в регистр.
  • Выскочить текущий кадр стека.
  • Перейти к указателю звонящего.

Следовательно, функции - это просто абстракции в спецификации языка более высокого уровня, которые позволяют людям организовывать код более понятным и интуитивно понятным способом. При компиляции в ассемблер или промежуточный язык (JIL, MSIL, ILX) и, безусловно, при визуализации в виде машинного кода, почти все такие абстракции исчезают.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.