В компилируемых языках такие возможности, как правило, ограничены и отключаемы в целях оптимизации, в интерпретируемых же более обширны, поскольку эти данные все равно необходимы самому интерпретатору, соответственно содержатся в памяти. Вопрос только в том, предоставлять ли к ним доступ языковыми средствами.
В данной статье я планирую рассмотреть те средства «самопознания», которые доступны для программ на Ruby.
Возвращаясь к компилируемым языкам: в них существует четкое разделение – есть отладочная информация, которая самой программе недоступна, и есть RTTI (Run-Time Type Information) – первая включается только для отладки, вторая может использоваться в нормальной логике программы, если есть такая потребность (это полезно для написания гибко строящихся программ из «кирпичиков» – компонентов, которые могут добавляться/подгружаться и во время выполнения тоже). Такое функциональное деление удобно и для интерпретируемых языков, в которых, правда, к этим двум категориям можно добавить еще одну – состояние интерпретатора/ виртуальной машины в целом.
Отладочная информация, доступная программе
Начнем с самого простого: специальные методы __FILE__ и __LINE__ позволят определить и, скажем, вывести текущую точку исполнения программы. Например, для логирования.
def log msg, file, line
$stderr.puts "[#{file}:#{line}] #{msg}"
end
log 'Сообщение', __FILE__, __LINE__
Запустив пример, получим что-то вроде:
$ ruby intro01.rb
[intro01.rb:7] Сообщение
Почему 7, а не 5? В файле примера [1] присутствует еще две строки: первая – специальный комментарий с указанием кодировки, вторая для отступа. Внутри статей я подобные повторяемые везде вещи опускаю.
Конечно, в момент написания кода с __FILE__ и __LINE__ мы и так знаем, в каком файле и на какой строке находимся, но при дальнейшем редактировании эта строчка кода может оказаться где угодно.
Однако было бы здорово, если бы наш метод логирования как-то сам узнавал, откуда был вызван, без лишних параметров. И это вполне возможно – рассмотрим следующий пример.
def log msg
$stderr.puts "[#{caller[0]}] #{msg}"
end
def log2 msg
cl = caller_locations[0]
$stderr.puts "[#{cl.path}:#{cl.lineno}] #{msg}"
end
log 'Сообщение'
log2 'Сообщение'
Запустив его, мы получим следующее:
$ ruby intro02.rb
[intro02.rb:12:in '<main>'] Сообщение
[intro02.rb:14] Сообщение
Замечательные методы caller и caller_locations предоставляют нам весь стек вызовов в виде строк и специальных объектов класса Thread::Backtrace::Location соответственно. Второй вариант дает более гибкие возможности, но надо помнить, что он стал доступен только начиная с версии Ruby 2.0. На момент написания статьи версия 1.9.3 еще считается актуальной, впрочем, ее официальная поддержка заканчивается в феврале 2015-го. Тем не менее столкнуться с ее использованием в старом коде вполне вероятно.
Еще одно традиционное использование caller – при генерации исключений: очень часто исключения генерируются сразу после входа в метод, при проверке переданных параметров. И нас в этом случае неособо-то интересует место в программе, где эта проверка производится, – гораздо удобнее сразу указать на то место, откуда был вызван метод и где соответственно были заданы неверные параметры.
def divide a, b
if b == 0 || b == 0.0
raise StandardError, 'На ноль делить нельзя', caller
end
a / b
end
puts divide(1, 0)
Получаем:
$ ruby intro02a.rb
intro02a.rb:10:in '<main>': На ноль делить нельзя (StandardError)
Если мы закомментируем «, caller», то получим более длинный вывод:
$ ruby intro02a.rb
intro02a.rb:5:in 'divide': На ноль делить нельзя (StandardError)
from intro02a.rb:10:in `<main>'
Но все полезное, что мы могли бы узнать, изучив пятую строку и ее окружение, уже известно из сообщения...
В общем, такая генерация ошибок принята, если исключение бросается непосредственно по итогам проверки параметров, и не принята в других случаях, когда внутренняя логика метода, где произошло исключение, важна для понимания его причин.
RTTI
Во-первых, для любого объекта Ruby мы можем получить его класс и список методов. Во-вторых, классы и модули также дают информацию о методах, определяемых в них, а кроме того, и о константах. И, в-третьих, зная объект (класс) и имя метода, мы можем получить более подробную информацию, включая список параметров и место, где метод был определен.
Чтобы узнать класс, мы можем воспользоваться методами obj.class или singleton_class. Я не случайно написал в первом случае obj.class через точку, поскольку, даже находясь в контексте объекта [2], безточки вызвать его мы не можем – это будет воспринято интерпретатором как ключевое слово 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.
Связь между этими методами и описанными выше можно выразить так:
obj.xxx_methods == obj.singleton_class.xxx_instance_methods
Классы и модули (в отличие от объектов) позволяют получить еще и список констант. Для этого служит метод constants.
Все эти методы принимают один необязательный параметр, указывающий, нужно ли включать унаследованные методы (по умолчанию – true).
Продемонстрирую вышесказанное на примере:
def print_module mod, ancestors = true
if ancestors
puts "#{mod.class.name.downcase} #{mod.name}" +
" #{mod.ancestors.inspect}"
else
puts " #{mod.class.name.downcase} #{mod.name}"
end
mod.constants(false).each do |c|
puts " const #{c.inspect}"
end
mod.public_instance_methods(false).each do |m|
puts " #{m.inspect}"
end
if ancestors
ancs = mod.ancestors[1..-1]
ancs.each do |anc|
print_module anc, false
end
end
end
print_module Class
Запустив пример, мы получим довольно длинный вывод, приведу лишь его начало:
$ ruby intro03.rb
class Class [Class, Module, Object, Kernel, BasicObject]
:allocate
:new
:superclass
class Module
:freeze
:===
Константы в классах Class и Module не содержатся, но далее в выводе они появятся в большом количестве – константы, которые принято считать глобальными, относятся к классу Object.
Список имен – это, конечно, хорошо, но мало. Ruby позволяет получить и более подробную информацию о каждом методе. Для этого нам нужно получить соответствующий объект посредством method (для любого объекта) или instance_method (для классов и модулей). В первом случае мы получим объект класса Method, а во втором – UnboudMethod. Разница между ними в том, что первый привязан кобъекту и может быть вызван непосредственно, тогда как второй существует как бы сам по себе и для вызова должен быть предварительно привязан посредством bind. Но сейчас для нас это непринципиально, нас интересует информация, которую они предоставляют, а она одинакова.
Итак, что мы можем получить?
Во-первых, source_location, т.е. расположение исходников метода. Возвращает массив из двух значений – имя файла и номер строки, или nil, если метод определен во внешней библиотеке (Ruby позволяет писать «расширения» – специальные разделяемые библиотеки на компилируемых языках, в первую очередь, конечно, на C).
Во-вторых, owner – класс или модуль, в котором данный метод определен.
И, в-третьих, самое, пожалуй, интересное – это parameters – массив, описывающий все параметры метода, как они заданы в его определении. Возвращаемое значение – массив, в котором каждый параметр представлен массивом же из двух элементов: первый описывает вид параметра – обязательный, необязательный и т.д., а второй – его имя. Выглядит это примерно так:
def test a, b = 1, *c, d:, e: 2, **f, &g
end
p method(:test).parameters
Получим:
$ ruby intro04.rb
[[:req, :a], [:opt, :b], [:rest, :c], [:keyreq, :d], [:key, :e], [:keyrest, :f], [:block, :g]]
Впрочем, если метод определен во внешней библиотеке-расширении или в ядре языка, то есть опять же в скомпилированном коде, то Ruby знает о параметрах только их вид, и массивы в списке состоят изодного элемента. Таким образом, например, method(:method).parameters вернет [[:req]].
Исходя из этого мы можем написать функцию, восстанавливающую примерный заголовок метода из соответствующего ему объекта.
ARG_TEMPLATE = {
req: '%s',
opt: '%s = <..>',
rest: '*%s',
key: '%s: <..>',
keyreq: '%s:',
keyrest: '**%s',
block: '&%s'
}
def header mobj
anprefix = 'arg'
ancounter = 0
params = []
mobj.parameters.each do |param|
if param.size == 2
name = param[1]
else
name = anprefix + ancounter.to_s
ancounter += 1
end
params << (ARG_TEMPLATE[param[0]] % name)
end
result = "#{mobj.name}(#{params.join(', ')})"
if mobj.source_location
result += " [#{mobj.source_location.join(':')}]"
else
result += " [<binary>]"
end
result
end
def test a, b = 1, *c, d:, e: 2, **f, &g
end
puts header(method(:test))
puts header(method(:header))
puts header(method(:puts))
Запустив этот код, получим:
$ ruby intro05.rb
test(a, b = <..>, *c, d:, e: <..>, **f, &g) [intro05.rb:35]
header(mobj) [intro05.rb:13]
puts(*arg0) [<binary>]
К сожалению, конкретные значения, заданные для опциональных параметров по умолчанию, так просто выяснить не получится. Можно, правда, зная, где данный метод определен, распарсить текст программы, но это уже выходит за рамки нашей темы.
Картина в целом
Итак, мы можем посмотреть методы и константы для любого класса, модуля да и произвольного объекта (хотя это и редко может потребоваться). Однако при этом нам надо откуда-то знать о егосуществовании вообще. Неплохо было бы иметь возможность получить список классов и модулей, существующих в программе, и такая возможность есть – метод ObjectSpace.each_object позволяет перебрать все «живые» объекты, при необходимости отобрав их по классу. Поскольку в Ruby все – объекты, и при этом класс Class является наследником Module, мы можем спокойно использовать отбор по Module.
Таким образом, мы можем получить общую картину классов и модулей, использовав вышеприведенный метод header и немного переделав print_module:
def print_module mod
title = "#{mod.class.name.downcase} #{mod}"
if Class === mod && mod.superclass != nil
title += " < #{mod.superclass}"
end
puts title
puts " ancestors: #{mod.ancestors.join(', ')}"
puts " constants:"
mod.constants(false).each do |c|
puts " #{c}"
end
puts " class methods:"
mod.public_methods(false).each do |m|
puts " #{header(mod.method(m))}"
end
puts " instance methods:"
mod.public_instance_methods(false).each do |m|
puts " #{header(mod.instance_method(m))}"
end
puts ''
end
ObjectSpace.each_object(Module) do |mod|
print_module mod
end
Полный вывод такой программы получится совсем гигантским [3], поэтому приведу лишь малую часть, относящуюся к специально созданному для примера классу:
class Alpha
ALPHA = 1
class << self
attr_accessor :beta
end
def alpha a, b = 0, *c
p [a, b, c]
end
end
Вот, что мы для него получим:
class Alpha < Object
ancestors: Alpha, Object, Kernel, BasicObject
constants:
ALPHA
class methods:
beta() [intro06.rb:40]
beta=(arg0) [intro06.rb:40]
allocate() [<binary>]
new(*arg0) [<binary>]
superclass() [<binary>]
instance methods:
alpha(a, b = <..>, *c) [intro06.rb:43]
И что это нам дает?
Механизм интроспекции достаточно универсальный и «неприкладной», поэтому его, с одной стороны, трудно применить к чему-нибудь практическому «в лоб», а с другой, есть масса случаев, когда он втой или иной мере полезен. Я, пожалуй, выделю только некоторые направления.
Мы можем создавать прокси-объекты, полностью (снаружи) эквивалентные некоторым заданным, при этом возможные изменения исходных объектов, которые могут разрабатываться где-то в другом месте другими людьми, нас не волнуют, поскольку все делается автоматически.
В сложных системах с подключением сторонних скриптов в качестве плагинов или компонентов интроспекция дает больший контроль за совместимостью, проверкой функциональности и так далее. Например, введя изменения в интерфейс плагинов в очередной версии, мы можем автоматически определять устаревшие плагины (то есть с устаревшим интерфейсом) и как-то корректно их обрабатывать, например, создавая прокси-обертку (см. предыдущий пункт).
Развитые инструменты интроспекции можно (и нужно) использовать для отладки, логирования, автоматического тестирования и так далее. То есть в инструментах для создания и обслуживания кода ненужно отдельно парсить исходные тексты, интерпретатор уже делает это за нас, причем таким же образом, как и при «боевом» выполнении. Так что, если кто задумывает написать IDE для Ruby, этими средствами пренебрегать никак нельзя.
Кроме того, не стоит забывать, что в Ruby с его развитыми средствами метапрограммирования, изучая код, относящийся к какому-нибудь классу, мы никогда не можем быть уверены, что это весь код, относящийся к этому классу. Иными словами, получить полную информацию о том, как выглядит некий класс в определенный момент исполнения программы, мы можем только в этот момент исполнения.
Соответственно данные инструменты могут стать очень хорошим подспорьем как при обучении Ruby, так и при изучении чужого кода, особенно, если он плохо документирован, что, к сожалению, всовременной программной индустрии скорее норма, чем исключение.
В целом информация о структуре программы во время выполнения, хоть и не уменьшает сложность, однако дает дополнительные возможности с ней как-то справляться.
- Полные тексты примеров размещены на GitHub – https://gist.github.com/shikhalev/12090b4e64340d9d8c2e.
- Шихалев И. Блоки и контекст в Ruby, или Что стоит за идентификатором в данном окружении. // «Системный администратор», № 1-2, 2014 г. – С. 111-115 (http://samag.ru/archive/article/2622).
- Файл list.txt в https://gist.github.com/shikhalev/12090b4e64340d9d8c2e.