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

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

В данной статье рассмотрим те средства «самопознания», которые доступны для программ на 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 через точку, поскольку, даже находясь в контексте объекта, без точки вызвать его мы не можем – это будет воспринято интерпретатором как ключевое слово 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.ancestors2
        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

Полный вывод такой программы получится совсем гигантским — около 7 тысяч строк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, так и при изучении чужого кода, особенно, если он плохо документирован, что, к сожалению, всовременной программной индустрии скорее норма, чем исключение.

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

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

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