среда, 9 марта 2016 г.

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

Оригинал этой статьи опубликован в журнале «Системный администратор» №1-2 (146-147) за февраль 2015.

Что программа может знать о самой себе?

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

В данной статье я планирую рассмотреть те средства «самопознания», которые доступны для программ на 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.02.

Еще одно традиционное использование 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 через точку, поскольку даже находясь в контексте объекта3, без точки вызвать мы его не можем — это будет воспринято интерпретатором как ключевое слово 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

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

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 — https://gist.github.com/shikhalev/12090b4e64340d9d8c2e.

2) На момент написания статьи версия 1.9.3 еще считается актуальной, впрочем, ее официальная поддержка заканчивается в феврале 2015... Тем не менее, столкнуться с ее использованием в старом коде вполне вероятно.

3) О контекстах см. статью «Блоки и контекст в Ruby», Системный администратор, январь-февраль 2014.

4) Данный вывод приведен вместе с исходниками на GitHub в файле list.txt.

Комментариев нет:

Отправить комментарий