Средства самопознания в Ruby

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

В данной статье рассмотрим те средства «самопознания», которые доступны для программ на Ruby.

Возвращаясь к компилируемым языкам: в них существует четкое разделение – есть отладочная информация, которая самой программе недоступна, и есть RTTI (Run-Time Type Information) – первая включается только для отладки, вторая может использоваться в нормальной логике программы, если есть такая потребность (это полезно для написания гибко строящихся программ из «кирпичиков» – компонентов, которые могут добавляться/подгружаться и во время выполнения тоже). Такое функциональное деление удобно и для интерпретируемых языков, в которых, правда, к этим двум категориям можно добавить еще одну – состояние интерпретатора/ виртуальной машины в целом.

Отладочная информация, доступная программе

Начнем с самого простого: специальные методы __FILE__ и __LINE__ позволят определить и, скажем, вывести текущую точку исполнения программы. Например, для логирования.

Запустив пример, получим что-то вроде:

Почему 7, а не 5? В файле примера1 присутствует еще две строки: первая – специальный комментарий с указанием кодировки, вторая для отступа. Внутри статей мы подобные повторяемые везде вещи опускаем.

Конечно, в момент написания кода с __FILE__ и __LINE__ мы и так знаем, в каком файле и на какой строке находимся, но при дальнейшем редактировании эта строчка кода может оказаться где угодно.

Однако было бы здорово, если бы наш метод логирования как-то сам узнавал, откуда был вызван, без лишних параметров. И это вполне возможно – рассмотрим следующий пример.

Запустив его, мы получим следующее:

Замечательные методы caller и caller_locations предоставляют нам весь стек вызовов в виде строк и специальных объектов класса Thread::Backtrace::Location соответственно. Второй вариант дает более гибкие возможности, но надо помнить, что он стал доступен только начиная с версии Ruby 2.0. На момент написания статьи версия 1.9.3 еще считается актуальной, впрочем, ее официальная поддержка заканчивается в феврале 2015-го. Тем не менее столкнуться с ее использованием в старом коде вполне вероятно.

Еще одно традиционное использование caller – при генерации исключений: очень часто исключения генерируются сразу после входа в метод, при проверке переданных параметров. И нас в этом случае неособо-то интересует место в программе, где эта проверка производится, – гораздо удобнее сразу указать на то место, откуда был вызван метод и где соответственно были заданы неверные параметры.

Получаем:

Если мы закомментируем «, caller», то получим более длинный вывод:

Но все полезное, что мы могли бы узнать, изучив пятую строку и ее окружение, уже известно из сообщения…

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

RTTI

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

Чтобы узнать класс, мы можем воспользоваться методами obj.class или singleton_class. Мы не случайно написали в первом случае obj.class через точку, поскольку, даже находясь в контексте объекта, без точки вызвать его мы не можем – это будет воспринято интерпретатором как ключевое слово class. singleton_class, т.е. уникальный класс данного единичного объекта, нам обычно не нужен, если только мы не определяли какие-то уникальные методы для него.

Далее мы можем узнать всю цепочку наследования, в том числе включенные посредством include или extend модули. Для этого нам понадобится вызов метода ancestors у класса.

Чтобы получить список имен методов объекта, мы можем воспользоваться следующими методами класса Object:

  • private_methods – вернет массив имен приватных методов;
  • protected_methods – «защищенных»;
  • public_methods – публичных;
  • methods – публичных и защищенных вместе.

Разница приватных и «защищенных» методов в том, что первые могут быть вызваны только в контексте того объекта, для которого они вызываются, тогда как вторые – в контексте любого объекта того же класса. Дополнительно отмечу singleton_methods, возвращающий список методов, определенных только для конкретного объекта.

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

  • private_instance_methods;
  • protected_instance_methods;
  • public_instance_methods;
  • instance_methods.

Связь между этими методами и описанными выше можно выразить так:

Классы и модули (в отличие от объектов) позволяют получить еще и список констант. Для этого служит метод constants.

Все эти методы принимают один необязательный параметр, указывающий, нужно ли включать унаследованные методы (по умолчанию – true).

Продемонстрирую вышесказанное на примере:

Запустив пример, мы получим довольно длинный вывод, приведем лишь его начало:

Константы в классах Class и Module не содержатся, но далее в выводе они появятся в большом количестве – константы, которые принято считать глобальными, относятся к классу Object.

Список имен – это, конечно, хорошо, но мало. Ruby позволяет получить и более подробную информацию о каждом методе. Для этого нам нужно получить соответствующий объект посредством method (для любого объекта) или instance_method (для классов и модулей). В первом случае мы получим объект класса Method, а во втором – UnboudMethod. Разница между ними в том, что первый привязан к объекту и может быть вызван непосредственно, тогда как второй существует как бы сам по себе и для вызова должен быть предварительно привязан посредством bind. Но сейчас для нас это непринципиально, нас интересует информация, которую они предоставляют, а она одинакова.

Итак, что мы можем получить?

Во-первых, source_location, т.е. расположение исходников метода. Возвращает массив из двух значений – имя файла и номер строки, или nil, если метод определен во внешней библиотеке (Ruby позволяет писать «расширения» – специальные разделяемые библиотеки на компилируемых языках, в первую очередь, конечно, на C).

Во-вторых, owner – класс или модуль, в котором данный метод определен.

И, в-третьих, самое, пожалуй, интересное – это parameters – массив, описывающий все параметры метода, как они заданы в его определении. Возвращаемое значение – массив, в котором каждый параметр представлен массивом же из двух элементов: первый описывает вид параметра – обязательный, необязательный и т.д., а второй – его имя. Выглядит это примерно так:

Получим:

Впрочем, если метод определен во внешней библиотеке-расширении или в ядре языка, то есть опять же в скомпилированном коде, то Ruby знает о параметрах только их вид, и массивы в списке состоят из одного элемента. Таким образом, например, method(:method).parameters вернет [[:req]].

Исходя из этого мы можем написать функцию, восстанавливающую примерный заголовок метода из соответствующего ему объекта.

Запустив этот код, получим:

К сожалению, конкретные значения, заданные для опциональных параметров по умолчанию, так просто выяснить не получится. Можно, правда, зная, где данный метод определен, распарсить текст программы, но это уже выходит за рамки нашей темы.

Картина в целом

Итак, мы можем посмотреть методы и константы для любого класса, модуля да и произвольного объекта (хотя это и редко может потребоваться). Однако при этом нам надо откуда-то знать о егосуществовании вообще. Неплохо было бы иметь возможность получить список классов и модулей, существующих в программе, и такая возможность есть – метод ObjectSpace.each_object позволяет перебрать все «живые» объекты, при необходимости отобрав их по классу. Поскольку в Ruby все – объекты, и при этом класс Class является наследником Module, мы можем спокойно использовать отбор по Module.

Таким образом, мы можем получить общую картину классов и модулей, использовав вышеприведенный метод header и немного переделав print_module:

Полный вывод такой программы получится совсем гигантским — около 7 тысяч строк2 — поэтому приведем лишь малую часть, относящуюся к специально созданному для примера классу:

Вот, что мы для него получим:

И что это нам дает?

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

Мы можем создавать прокси-объекты, полностью (снаружи) эквивалентные некоторым заданным, при этом возможные изменения исходных объектов, которые могут разрабатываться где-то в другом месте другими людьми, нас не волнуют, поскольку все делается автоматически.

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

Развитые инструменты интроспекции можно (и нужно) использовать для отладки, логирования, автоматического тестирования и так далее. То есть в инструментах для создания и обслуживания кода ненужно отдельно парсить исходные тексты, интерпретатор уже делает это за нас, причем таким же образом, как и при «боевом» выполнении. Так что, если кто задумывает написать IDE для Ruby, этими средствами пренебрегать никак нельзя.

Кроме того, не стоит забывать, что в Ruby с его развитыми средствами метапрограммирования, изучая код, относящийся к какому-нибудь классу, мы никогда не можем быть уверены, что это весь код, относящийся к этому классу. Иными словами, получить полную информацию о том, как выглядит некий класс в определенный момент исполнения программы, мы можем только в этот момент исполнения.

Соответственно данные инструменты могут стать очень хорошим подспорьем как при обучении Ruby, так и при изучении чужого кода, особенно, если он плохо документирован, что, к сожалению, всовременной программной индустрии скорее норма, чем исключение.

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

  1. Полные тексты примеров размещены на GitHub
  2. Файл list.txt

Материал взят из выпуска №146-147 журнала «Системный Администратор»