• 16.1. Библиотека Test::Unit
  • 16.2. Комплект инструментов ZenTest
  • 16.3. Работа с отладчиком Ruby
  • 16.4. Использование irb в качестве отладчика
  • 16.5. Измерение покрытия кода
  • 16.6. Измерение производительности
  • 16.7. Объекты печати
  • 16.8. Заключение
  • Глава 16. Тестирование и отладка

    Неполадки в блоке АЕ-35. В ближайшие семьдесят два часа блок может отказать.

    (Артур Кларк, «Космическая Одиссея 2001 года»)

    Тестирование — вещь важная. Все компетентные программисты об этом знают, хотя не всегда этот вопрос стоит для них на первом месте.

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

    Исторически сложилось так, что программисты не всегда тестируют как положено. Объясняют это обычно тем, что тесты трудно готовить и прогонять, что вся процедура требует ручного вмешательства или отнимает слишком много времени.

    В 1990 году в сообществе программистов стала распространяться «культура тестирования». Идеи экстремального программирования и управляемой тестами разработки начали овладевать умами разработчиков по всему миру.

    Являетесь ли вы твердокаменным приверженцем идеологии «тестируй с самого начала», не так существенно. Важно, что любой человек может воспользоваться инструментами, которые позволяют автоматизировать тестирование, упростив написание и прогон тестов.

    Такие инструменты, как

    Test::Unit
    и ZenTest, написать на Ruby было проще в силу динамичности и гибкости языка. Не менее легко и (посмею ли сказать?) приятно ими пользоваться. Внес изменение в программу, а потом смотришь, как все тесты успешно доходят до конца, — положительно в этом что-то есть!

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

    16.1. Библиотека Test::Unit

    «Стандартный» способ автономного тестирования компонентов в Ruby — библиотека

    Test::Unit
    Натаниэля Тэлбота (Nathaniel Talbott). Она была включена в дистрибутив Ruby еще в 2001 году.

    В этой библиотеке для анализа тестового кода применяется отражение. Когда вы создаете подкласс класса

    Test::Unit::TestCase
    , все методы, имена которых начинаются с
    test
    , считаются тестовыми.

    require 'test/unit'


    class TC_MyTest < Test::Unit::TestCase

     def test_001

      # ...

     end

     def test_002

      # ...

     end

     # ...

    end

    Методы необязательно нумеровать, как показано в этом примере. Это мое личное соглашение, но, конечно, есть и другие.

    Нежелательно и, пожалуй, даже неправильно составлять тесты так, чтобы их поведение зависело от порядка запуска. Однако

    Test::Unit
    прогоняет их в алфавитном (лексикографическом) порядке, поэтому, нумеруя свои методы, я вижу, как они выполняются в определенной последовательности.

    Я также предпочитаю включать некий «заголовок» в имя метода (описывающий его область действия или назначение):

    def test_053_default_to_current_directory

     # ...

    end

    def test_054_use_specified_directory

     # ...

    end

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

    А если нужно организовать некую среду выполнения, для чего требуется время? Неразумно делать это для каждого теста, и мы не вправе завести для данной цели отдельный метод (поскольку поведение не должно зависеть от порядка прогона).

    Если всем тестам нужна особая среда, можно воспользоваться методами класса

    setup
    и
    teardown
    . Возможно, вам это покажется странным, но вызываются они для каждого теста. Если вы хотите выполнить настройку один раз, перед прогоном одного конкретного или всех тестов, то можете поместить соответствующий код в тело класса раньше всех тестовых методов (или даже до самого класса).

    А если после выполнения всех тестов нужно разрушить созданную среду? По техническим причинам (так уж работает библиотека

    Test::Unit
    ) сделать это трудно. «Самый лучший» способ — переопределить метод run всего комплекта тестов (но не метод класса
    run
    ), обернув его функциональность. Рассмотрим пример в листинге 16.1.

    Листинг 16.1. Подготовка и разрушение среды исполнения

    require 'test/unit'


    class MyTest < Test::Unit::TestCase


     def self.major_setup

      # ...

     end


     def self.major_teardown

      # ...

     end


     def self.suite

      mysuite = super        # Вызвать метод suite родителя.


      def mysuite.run(*args) # Добавить синглетный метод

       MyTest.major_setup

       super

       MyTest.major_teardown

      end


      mysuite                # и вернуть новое значение.

     end


     def setup

      # ...

     end


     def teardown

      # ...

     end


     def test_001

      # ...

     end


     def test_002

      # ...

     end


     # ...

    end

    Вряд ли вы будете поступать так часто. О методе

    suite
    мы поговорим чуть позже, а пока продолжим рассмотрение отдельных тестов.

    Что должно входить в тест? Нужно как-то решить, прошел он или нет. Для этой цели применяются утверждения.

    Простейшее утверждение — это метод

    assert
    . Он принимает проверяемый параметр и еще один необязательный параметр (сообщение). Если значение параметра истинно (то есть все, кроме
    false
    и
    nil
    ), тест прошел. В противном случае тест не прошел — тогда печатается сообщение, если оно было задано.

    Есть и другие методы для формулирования утверждений. Обратите внимание, что «ожидаемое» значение всегда предшествует «фактическому».

    assert_equal(expected, actual)     # assert(expected==actual)

    assert_not_equal(expected, actual) # assert(expected!=actual)

    assert_match(regex, string)        # assert(regex =~ string)

    assert_no_match(regex, string)     # assert(regex string)

    assert_nil(object)                 # assert(object.nil?)

    assert_not_nil(object)             # assert(!object.nil?)

    Некоторые утверждения носят более объектно-ориентированный характер:

    assert_instance_of(klass, obj) # assert(obj.instance_of? klass)

    assert_kind_of(klass, obj)     # assert(obj.kind_of? klass)

    assert_respond_to(obj, meth)   # assert(obj.respond_to? meth)

    Другие относятся к исключениям и символам, которые генерируются методом

    throw
    . Понятно, что такие методы принимают блок.

    assert_nothing_thrown { ... }    # Не было throw.

    assert_nothing_raised { ... }    # Не было raise.

    assert_throws(symbol) { ... }    # Символ в результате throw.

    assert_raises(exception) { ... } # Исключение в результате raise.

    Есть еще несколько утверждений, но эти применяются чаще всего и отвечают почти всем потребностям. Дополнительную информацию можно найти в онлайновой документации на сайте http://ruby-doc.org.

    Имеется еще метод

    flunk
    , который всегда завершается неудачно. Можно считать, что это некий вид заглушки.

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

    Test::Unit::UI::GTK::TestRunner
    . Любой исполнитель тестов можно вызвать, обратившись к его методу
    run
    , которому передается специальный параметр, описывающий набор тестов:

    class MyTests < Test::Unit::TestCase

     # ...

    end


    # Явное указание исполнителя тестов...

    runner = Test::Unit::UI::Console::TestRunner

    runner.run(MyTests)

    Параметром может быть любой объект, обладающий методом

    suite
    , который возвращает объект, представляющий комплект тестов. Что все это означает?

    Познакомимся к понятием комплекта тестов ближе. Оказывается, комплект тестов может состоять из набора тестов или набора подкомплектов. Следовательно, можно сгруппировать тесты так, что будет прогоняться либо только один набор, либо сразу все.

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

    require 'test/unit/testsuite'


    require 'tc_set1'

    require 'tc_set2'

    require 'ts_set3'


    class TS_MyTests

     def self.suite

      mysuite = Test::Unit::TestSuite.new

      mysuite << TC_Set1.suite

      mysuite << TC_Set2.suite

      mysuite << TS_Set3.suite

      return mysuite

     end

    end


    Test::Unit::UI::Console::TestRunner.run(TS_MyTests)

    Но такая сложность ни к чему. Имея отдельные наборы тестов, библиотека

    Test::Unit
    в состоянии просмотреть пространство объектов и объединить их все в один комплект. Поэтому следующий код тоже будет работать (и даже вызывать подразумеваемый по умолчанию исполнитель тестов):

    require 'test/unit'


    require 'tc_set1'

    require 'tc_set2'

    require 'ts_set3'

    Библиотека

    Test::Unit
    располагает и другими возможностями, а в дальнейшем, вероятно, будет усовершенствована. Самую свежую информацию ищите в сети.

    16.2. Комплект инструментов ZenTest

    Этот великолепный инструментарий написал Райан Дэвис (Ryan Davis). Основной инструмент (

    zentest
    ) — это исполняемая программа, которая генерирует файл с тестами на основе анализа вашего кода.

    Тестируемый класс (class under test — CUT) служит основой тестового класса (test class — ТС). На каждом уровне области видимости в начало имени класса добавляется строка

    Test
    , а в начало имени метода — строка
    test_
    . Иногда имена методов приходится «подправлять», например в случае с методом
    ==
    (к имени которого нельзя добавлять никакой префикс) или если имя метода оканчивается на ?, ! или =. В листинге 16.2 приведен пример подлежащего тестированию кода:

    Листинг 16.2. Класс, подлежащий тестированию

    class Alpha


     class Beta


      attr_accessor :foo, :bar


      def initialize

      end


      def foo?

       @foo

      end


     end


     def initialize

     end


     def process

     end


     def process!

     end


     def ==(other)

     end


     def ===(other)

     end

    end

    После запуска команды

    zentest file.rb >tfile.rb
    получится файл, показанный в листинге 16.3.

    Листинг 16.3. Результат работы ZenTest

    # Code Generated by ZenTest v. 3.2.0

    # classname: asrt / meth = ratio%

    # Alpha::Beta: 0 / 7 = 0.00%


    require 'test/unit' unless defined? $ZENTEST and $ZENTEST


    class TestAlpha < Test::Unit::TestCase

     def test_process

      raise NotImplementedError, 'Need to write test_process'

     end


     def test_process_bang

      raise NotImplementedError, 'Need to write test_process_bang'

     end

    end


    module TestAlpha

     class TestBeta < Test::Unit::TestCase

      def test_bar

       raise NotImplementedError, 'Need to write test_bar'

      end


      def test_bar_equals

       raise NotImplementedError, 'Need to write test_bar_equals'

      end


      def test_foo

       raise NotImplementedError, 'Need to write test_foo'

      end


      def test_foo_eh

       raise NotImplementedError, 'Need to write test_foo_eh'

      end


      def test_foo_equals

       raise NotImplementedError, 'Need to write test_foo_equals'

      end

     end

    end


    # Number of errors detected: 9

    Обратите внимание, что каждый тестовый метод возбуждает исключение (предложение

    raise
    ). Идея в том, что все тесты завершаются неудачно, пока вы явно не напишете код.

    Исходный файл почему-то не включается в тестовый. Можно поместить в начало тестового файла директиву

    require 'file'
    или эквивалентную ей (предварительно затребовав
    test/unit
    ). Тогда тестовый код увидит определения ваших классов.

    В командной строке можно указать и второй параметр. Если добавить код в тестируемый класс, то тестовые классы окажутся устаревшими. Чем обновлять их вручную, можно сгенерировать только «обновления»:

    zentest file.rb tfile.rb >tfile2.rb

    В комплект входит еще одна полезная программа:

    unit_diff
    . Рассмотрим простое утверждение
    assert_equal ("foo", "bar")
    . Оно приводит к печати следующего сообщения:

     1) Failure:

    testme(Foo) [(irb):7]:

    <"foo"> expected but was

    <"bar">.

    Тут все просто и понятно. Но предположим, что каждая из переданных строк (string) состояла из нескольких строчек (line), а различие начиналось только в седьмой строчке. Программа

    unit_diff
    как раз призвана навести порядок в таком плохо читаемом тексте. Она работает аналогично утилите
    diff
    , имеющейся в UNIX; вызывать ее следует как фильтр после обычной тестовой программы.

    ruby testfile.rb | unit_diff

    Программа понимает следующие флаги:

    -h Справка о порядке запуска

    -v Номер версии

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

       пробелов

    -c Выполнять контекстное сравнение

    -k Не удалять временные файлы

    -l Включать в дельту номера строк

    -u Выполнять унифицированное сравнение

    Программа

    autotest
    наблюдает за всеми вашими комплектами тестов и запускает те, которые были недавно изменены. Она предназначена для тех лентяев, которым трудно даже ввести имя файла для прогона тестов.

    Для пользования этим инструментом нужно соблюдать некоторые соглашения об именах. Правила простые:

    • все тесты должны находиться в каталоге

    test
    ;

    • имена всех файлов должны начинаться с

    Test_
    ;

    • имена классов должны начинаться с

    Test
    ;

    • подлежащий тестированию код должен находиться в каталоге

    lib
    ;

    • файлам в каталоге

    lib
    должны соответствовать файлы в каталоге
    test
    (их имена, конечно, должны начинаться с
    test_
    ).

    Будучи запущена, программа

    autotest
    прогоняет тесты по мере их обновления. Если какой-то тест завершается неудачно, она будет прогонять его снова и снова, пока вы не исправите ошибку. Она начнет «сверху», если нажать комбинацию клавиш Ctrl+C, и завершится, если нажать Ctrl+C во второй раз.

    Программа

    multiruby
    позволяет тестировать код относительно разных версий Ruby. Она входит в комплект ZenTest, но пока еще работает не очень стабильно и плохо документирована.

    16.3. Работа с отладчиком Ruby

    Честно говоря, отладчик Ruby не особенно популярен. Лично я им не пользуюсь и встречал не так уж много людей, которые с ним работали. Но сознавать, что он есть, приятно. Ниже приводится краткое описание работы с ним.

    Для вызова отладчика нужно просто затребовать библиотеку

    debug
    — например, в командной строке:

    ruby -rdebug rayfile.rb

    В ответ на приглашение вида (

    rdb:1
    ) вы можете вводить различные команды, например
    list
    для получения текста всей программы или ее части, step для пошагового исполнения метода и т.д. Некоторые команды перечислены в таблице 16.1 (полужирным шрифтом набраны сокращения).


    Таблица 16.1. Наиболее употребительные команды отладчика

    Команда Описание
    break
    Установить точку прерывания или получить их список.
    delete
    Удалить все или некоторые точки прерывания.
    catch
    Установить точку перехвата или получить их список.
    step
    Вход внутрь метода.
    next
    Перейти к следующей строке (без захода внутрь метода).
    help
    Справка (вывести список всех команд).
    quit
    Выйти из отладчика.

    В листинге 16.4 приведен код простой программы (даже слишком простой, чтобы ее отлаживать).

    Листинг 16.4. Простая программа для демонстрации работы отладчика

    STDOUT.sync = true


    def palindrome?(word)

     word == word.reverse

    end


    def signature(w)

     w.split("").sort.join

    end


    def anagrams?(w1,w2)

     signature(w1) == signature(w2)

    end


    print "Give me a word: "

    w1 = gets.chomp


    print "Give me another word: "

    w2 = gets.chomp


    verb = palindrome?(w1) ? "is" : "is not"

    puts "'#{w1}' #{verb} a palindrome."


    verb = palindrome?(w2) ? "is" : "is not"

    puts "'#{w2}' #{verb} a palindrome."


    verb = anagrams?(w1,w2) ? "are" : "are not"

    puts "'{w1}' and '#{w2}' #{verb} anagrams."

    В листинге 16.5 показан полный сеанс отладки. Некоторые места не слишком понятны, потому что на консоль выводят данные и сама программа, и отладчик.

    Листинг 16.5. Сеанс отладки простой программы

    $ ruby -rdebug db.rb

    Debug.rb

    Emacs support available.


    db.rb:1:STDOUT.sync = true

    (rdb:1) b palindrome?

    Set breakpoint 1 at db.rb:palindrome?

    (rdb:1) b anagrams?

    Set breakpoint 2 at db.rb:anagrams?

    (rdb:1) b

    Breakpoints:

     1 db.rb:palindrome?

     2 db.rb:anagrams?

    (rdb:1) n

    db.rb:3:def palindrome?(word)

    (rdb:1) n

    db.rb:7:def signature(w)

    (rdb:1) n

    db.rb:11:def anagrams?(w1,w2)

    (rdb:1) n

    db.rb:15:print "Give me a word: "

    (rdb:1) n

    Give me a word: db.rb:16:w1 = gets.chomp

    (rdb:1) live

    db.rb:16:undefined local variable or method 'live' for main:Object

    (rdb:1) n

    live

    db.rb:18:print "Give me another word: "

    (rdb:1) n

    Give me another word: db.rb:19:w2 = gets.chomp

    (rdb:1) n

    evil

    db.rb:21:verb = palindrome?(w1) ? "is" : "is not"

    (rdb:1) с

    Breakpoint 1, palindrome? at db.rb:palindrome?

    db.rb:3:def palindrome?(word)

    (rdb:1) n

    db.rb: 4: word == word.reverse

    (rdb:1) word

    "live"

    (rdb:1) n

    db.rb: 22: puts "'#{w1}' #{verb} a palindrome."

    (rdb:1) verb "is not"

    (rdb:1) n

    'live' is not a palindrome.

    db.rb:24:verb = palindrome?(w2) ? "is" : "is not"

    (rdb:1) n

    db.rb:24:verb = palindrome?(w2) ? "is" : "is not"

    (rdb:1) n

    Breakpoint 1, palindrome? at db.rb:palindrome?

    db.rb:3:def palindrome?(word)

    (rdb:1) n

    db.rb:4: word == word.reverse

    (rdb:1) c

    'evil' is not a palindrome.

    Breakpoint 2, anagrams? at db.rb:anagrams?

    db.rb:11:def anagrams?(w1,w2)

    (rdb:1) n

    db.rb:12: signature(w1) == signature(w2)

    (rdb:1) n

    db.rb:28:puts "'#{w1}' and '#{w2}' #{verb} anagrams."

    (rdb:1) verb

    "are"

    (rdb:1) c

    'live' and 'evil' are anagrams.

    Если вы затребуете другие библиотеки, то, возможно, придется в самом начале «перешагнуть» через несколько методов. Я рекомендую перед началом отладки установить где-нибудь в собственном коде точку прерывания, а потом выполнить команду

    continue
    для останова в этой точке.

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

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

    Современные отладчики имеют графический интерфейс. Если вам необходим такой инструмент, загляните в главу 21, где обсуждаются интегрированные среды разработки для Ruby.

    16.4. Использование irb в качестве отладчика

    Библиотеку

    ruby-breakpoint
    написал Флориан Гросс (Florian Gross). Этот великолепный, несмотря на малый объем, инструмент позволяет расставлять в программе точки прерывания методом
    breakpoint
    . Когда в процессе исполнения встречается точка прерывания, запускается сеанс
    irb
    (программа интерактивной работы с Ruby
    irb
    подробно рассматривается в главе 21).

    Эта библиотека не входит в стандартный дистрибутив. Установить ее можно, например, выполнив команду

    gem install ruby-breakpoint
    .

    Внесем несколько изменений в программу из листинга 16.4. Поместим в начало директиву

    require 'breakpoint'
    и добавим вызов метода
    breakpoint
    после обоих обращений к
    gets
    :

    require 'breakpoint'


    # ...

    w2 = gets.chomp


    breakpoint


    # ...

    Теперь запустим ее. В следующем протоколе сеанса показано, как мы входим в

    irb
    , после чего можем делать все что угодно — в частности, вызывать ранее определенные методы и изменять значения переменных.

    $ ruby myprog.rb

    Give me a word: parental

    Give me another word: prenatal

    Executing break point at myprog.rb:23

    irb(main):001:0> w1

    => "parental"

    irb(main):002:0> w2

    => "prenatal"

    irb(main):003:0> palindrome?(w1)

    => false

    irb(main):004:0> palindrome?("detartrated")

    => true

    irb(main):005:0> signature(w1)

    => "aaelnprt"

    irb(main):006:0> quit

    'parental' is not a palindrome.

    'prenatal' is not a palindrome.

    'parental' and 'prenatal' are anagrams.

    Особенно подкупает, что отлаживаемая программа может быть не только командной или текстовой. Существует клиент drb (распределенный Ruby), который позволяет удаленно отлаживать программу Ruby, работающую в другом процессе.

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

    breakpoint
    ):

    Breakpoint.activate_drb("druby://127.0.0.1:2001", "localhost")

    # Запустить сервер на порту 2001 машины localhost.

    Запустите клиент командой

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

    $ breakpoint_client druby://localhost:2001

    No connection to breakpoint service at druby://localhost:2001 (DRb::DRbConnError)

    Tries to connect will be made every 3 seconds...

    После установления соединения вы можете и не получить приглашение

    irb
    . Программа будет выполняться до точки прерывания — вот тогда-то вы и увидите приглашение.

    Дополнительную информацию об этой библиотеке поищите в документации, которая входит в комплект поставки.

    16.5. Измерение покрытия кода

    Очень полезно знать, какие части программы не были протестированы, а следовательно, нуждаются в автономных тестах. Иногда и сам инструмент для замера покрытия может обнаружить ошибки. Допустим, в программе есть предложение

    if
    , которое «должно» исполняться примерно в половине всех случаев. Если выясняется, что оно не исполняется никогда, значит, имеет место ошибка.

    Командную утилиту

    rcov
    (и соответствующую библиотеку) написал Маурисио Фернандес (Mauricio Fernandez). Устанавливается она в виде gem-пакета.

    В простейшем случае для ее запуска достаточно указать имя вашей программы в качестве параметра:

    rcov myfile.rb

    Одновременно с исполнением вашей программы

    rcov
    будет собирать статистику. По умолчанию она создает каталог
    coverage
    , в котором вы найдете HTML-файлы. В файле
    index.html
    представлены сводные результаты и ссылки на исходные тексты, где строки, которые хотя бы раз исполнялись, подсвечены.

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

    Хотя программа

    rcov
    полезна даже в стандартном режиме, она понимает порядка 30 различных параметров. Можно указать каталог для выходных файлов, образцы имен файлов, для которых собирать и не собирать статистику, задать режим сортировки по именам файлов и многое другое. Можно выводить результаты в текстовом виде и даже запросить цветную диаграмму покрытия. Рекомендую прочитать поставляемую документацию, запросить справку командой
    rcov -h
    и… получать удовольствие.

    Можно использовать

    rcov
    и в качестве библиотеки для написания аналогичных инструментов анализа. Ее API состоит из трех основных классов:

    • 

    Rcov::FileStatistics
    позволяет отличить исполняемые предложения от комментариев (и тем самым уточнить статистику покрытия);

    • 

    Rcov::CodeCoverageAnalyzer
    применяется для трассировки выполнения, возвращает информацию о покрытии и счетчики выполненных предложений;

    • 

    Rcov::CallSiteAnalyzer
    нужен для того, чтобы понять, где определены методы и откуда они вызываются.

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

    16.6. Измерение производительности

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

    Конечно, быстродействие имеет значение. Иногда даже очень большое. Однако начинать думать об этом на раннем этапе цикла разработки — ошибка. Как говорится, «преждевременная оптимизация — источник всех зол»; эту мысль впервые высказал Хоар (Hoare), а потом подтвердил Кнут (Knuth). Или, перефразируя, сначала пусть работает правильно, а уж потом быстро». На уровне отдельного приложения эта рекомендация обычно оказывается хорошим эвристическим правилом, хотя для больших систем она, быть может, и не так актуальна.

    Я бы еще добавил: «Не оптимизируйте, пока не измерите».

    Это не такое уж серьезное ограничение. Просто не приступайте к переработке ради скорости, пока не ответите на два вопроса: «Действительно ли программа работает медленно? Какие именно ее части снижают производительность?»

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

    Нам нужны объективные измерения. Профилировщик нужен.

    В комплект поставки Ruby входит профилировщик

    profile
    . Для его вызова достаточно включить библиотеку:

    ruby -rprofile myprog.rb

    Рассмотрим листинг 16.6. Эта программа открывает файл

    /usr/share/dict/words
    и ищет в нем анаграммы. Затем смотрит, у каких слов оказалось больше всего анаграмм, и распечатывает их.

    Листинг 16.6. Поиск анаграмм в словаре

    words = File.readlines("/usr/share/dict/words")

    words.map! {|x| x.chomp }


    hash = {}

    words.each do |word|

     key = word.split("").sort.join

     hash[key] ||= []

     hash [key] << word

    end


    sizes = hash.values.map {|v| v.size }

    most = sizes.max

    list = hash.find_all {|k,v| v.size == most }


    puts "Ни у одного слова нет более #{most-1} анаграмм."

    list.each do |key,val|

     anagrams = val.sort

     first = anagrams.shift

     puts "Слово #{first} имеет #{most-1) анаграмм:"

     anagrams.each {|a| puts " #{a}" }

    end


    num = 0

    hash.keys.each do |key|

     n = hash[key].size

     num += n if n > 1

    end


    puts

    puts "Всего слов в словаре: #{words.size},"

    puts "из них имеют анаграммы: #{num}."

    Наверняка вам интересно, какие получились результаты. Вот какие:

    Ни у одного слова нет более 14 анаграмм.

    Слово alerts имеет 14 анаграмм:

     alters

     artels

     estral

     laster

     lastre

     rastle

     ratels

     relast

     resalt

     salter

     slater

     staler

     stelar

     talers

    Всего слов в словаре: 483523,

    из них имеют анаграммы: 79537.

    На моем компьютере этот файл содержит более 483000 слов, и программа работала чуть меньше 18 секунд. Как вы думаете, на что ушло это время? Попробуем выяснить. Профилировщик выдал более 100 строк, отсортированных в порядке убывания времени. Мы покажем только первые 20:

    %     cumulative self            self    total

    time     seconds seconds  calls  ms/call ms/call  name

    42.78     190.93 190.93      15 12728.67 23647.33 Array#each

    10.78     239.04  48.11 1404333     0.03     0.04 Hash#[]

     7.04     270.48  31.44       2 15720.00 25575.00 Hash#each

     5.66     295.73  25.25  483523     0.05     0.05 String#split

     5.55     320.51  24.78 1311730     0.02     0.02 Array#size

     3.64     336.76  16.25       1 16250.00 25710.00 Array#map

     3.24     351.23  14.47  483524     0.03     0.03 Array#sort

     3.12     365.14  13.91  437243     0.03     0.03 Fixnum#==

     3.04     378.72  13.58  483526     0.03     0.03 Array#join

     2.97     391.98  13.26  437244     0.03     0.03 Hash#default

     2.59     403.53  11.55  437626     0.03     0.03 Hash#[]=

     2.43     414.38  10.85  483568     0.02     0.02 Array#<<

     2.29     424.59  10.21       1 10210.00 13430.00 Array#map!

     1.94     433.23  8.64   437242     0.02     0.02 Fixnum#<=>

     1.86     441.54  8.31   437244     0.02     0.02 Fixnum#>

     0.72     444.76  3.22   483524     0.01     0.01 String#chomp

     0.11     445.26  0.50        4   125.00   125.00 Hash#keys

     0.11     445.73  0.47        1   470.00   470.00 Hash#values

     0.06     446.00  0.27        1   270.00   270.00 IO#readlines

     0.05     446.22  0.22    33257     0.01     0.01 Fixnum#+

    Видно, что больше всего времени программа тратит в методе

    Array#each
    . Это понятно: ведь цикл выполняется для каждого слова и на каждой итерации делает довольно много. Среднее значение в данном случае сбивает с толку, поскольку почти все время уходит на первый вызов
    each
    , а остальные 14 (см.
    anagrams.each
    ) выполняются очень быстро.

    Мы также видим, что

    Hash#[]
    — дорогая операция (главным образом потому что часто выполняется); на 1.4 миллиона вызовов было потрачено почти 11 секунд.

    Обратите внимание, что метод

    readlines
    оказался чуть ли не в самом конце списка. Эта программа тратит время не на ввод/вывод, а на вычисления. На чтение всего файла ушло всего-то четверть секунды.

    Но этот пример не показывает, в чем истинная ценность профилирования. В программе нет ни методов, ни классов. На практике вы, скорее всего, увидите свои методы среди системных. И тогда будете точно знать, какие из ваших методов находятся в числе первых 20 «пожирателей времени».

    Надо ясно понимать, что профилировщик Ruby (видно, по иронии судьбы) работает медленно. Он подключается к программе во многих местах и следит за ее выполнением на низком уровне (причем сам написан на чистом Ruby). Так что не удивляйтесь, если ваша программа в ходе сеанса профилирования будет работать на несколько порядков медленнее. В нашем примере она работала 7 минут 40 секунд (460 секунд), то есть в 25 раз медленнее обычного.

    Помимо профилировщика, есть еще один низкоуровневый инструмент — стандартная библиотека

    benchmark
    , которая тоже полезна для измерения производительности.

    Один из способов ее применения — вызвать метод

    Benchmark.measure
    и передать ему блок.

    require 'benchmark'


    file = "/usr/share/dict/words"

    result = Benchmark.measure { File.readlines(file) }

    puts result

    # Выводится: 0.350000 0.070000 0.420000 ( 0.418825)

    Этот метод выводит следующую информацию:

    • время, затраченное процессором в режиме пользователя (в секундах);

    • время, затраченное процессором в режиме ядра (в секундах);

    • полное затраченное время — сумму вышеупомянутых величин;

    • время работы программы (по часам).

    Для сравнения производительности отдельных участков удобен метод

    Benchmark.bm
    . Передайте ему блок, а он сам передаст блоку объект формирования отчета. Если вызвать этот объект, передав ему метку и блок, то он выведет метку, а за ней временные характеристики блока. Пример:

    require 'benchmark'


    n = 200_000

    s1 = ""

    s2 = ""

    s3 = ""

    Benchmark.bm do |rep|

     rep.report("str << ") { n.times { s1 << "x" } }

     rep.report("str.insert ") { n.times { s3.insert(-1,"x") } }

     rep.report("str += ") { n.times { s2 += "x" } }

    end

    Здесь мы сравниваем три способа добавить символ в конец строки, дающие один и тот же результат. Чтобы можно было получить более точные цифры, каждая операция выполняется 200000 раз. Вот что вышло:

          user    system     total      real

    str <<      0.180000  0.000000  0.180000 ( 0.174697)

    str.insert  0.200000  0.000000  0.200000 ( 0.200479)

    str +=     15.250000 13.120000 28.370000 (28.375998)

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

    Вы можете предположить, что оператор

    +
    почему-то работает медленно, но дело в другом. Это единственный из трех способов, который не работает с одним и тем же объектом, а каждый раз создает новый.

    Стало быть, вывод такой: создание объекта — дорогая операция. Библиотека Benchmark может преподать много подобных уроков, но я все же рекомендую сначала заняться высокоуровневым профилированием.

    16.7. Объекты печати

    Метод

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

    Проблема в том, что результат, формируемый методом

    p
    , бывает трудно читать. Из-за этого и появилась библиотека
    pp
    , добавляющая одноименный метод. Рассмотрим следующий искусственный пример объекта
    my_obj
    :

    class MyClass


     attr_accessor :alpha, :beta, :gamma


     def initialize(a,b,c)

      @alpha, @beta, @gamma = a, b, с

     end


    end


    x = MyClass.new(2, 3, 4)

    y = MyClass.new(5, 6, 7)

    z = MyClass.new(7, 8, 9)

    my_obj = { x => y, z => [:p, :q] }


    p my_obj

    Вызов метода

    p
    печатает следующее:

    {#<MyClass:0xb7eed86c @beta=3, @alpha=2,

     @gamma=4>=>#<MyClass:0xb7eed72c @beta=6, @alpha=5, @gamma=7>,

     #<MyClass:0xb7eed704 @beta=8, @alpha=7 , @gamma=9>=>[:p, :q]}

    Все правильно и в общем-то даже читаемо. Но… некрасиво. А давайте затребуем библиотеку

    pp
    и воспользуемся предоставляемым ей методом
    pp
    :

    require 'pp'


    # ...


    pp my_obj

    Теперь вывод приобретает такой вид:

    {#<MyClass:0xb7f7a050 @alpha=7, @beta=8, @gamma=9>=>[:p, :q],

     #<MyClass:0xb7f7a1b8 @alpha=2, @beta=3, @gamma=4>=>

      #<MyClass:0xb7f7a078 @alpha=5, @beta=6, @gamma=7>}

    Мы получили хотя бы пробелы и разбиение на строки. Уже лучше. Но можно пойти еще дальше. Предположим, что в классе

    MyClass
    определен специальный метод
    pretty_print
    :

    class MyClass


     def pretty_print(printer)

      printer.text "MyClass(#@alpha, #@beta, #@gamma)"

     end


    end

    Аргумент printer передается вызывающей программой (или методом pp). Это аккумулятор текста, являющийся экземпляром класса

    PP
    ; мы вызываем его метод
    text
    и передаем ему текстовое представление
    self
    . Вот что получается в результате:

    {MyClass(7, 8, 9)=>[:p, :q] , MyClass(2, 3, 4)=>MyClass(5, 6, 7)}

    Разумеется, можно настроить поведение по своему вкусу. Можно, например, печатать переменные экземпляра на разных строчках с отступами.

    На самом деле в библиотеке

    pp
    есть много средств для подготовки ваших классов к совместной работе с методом
    pp
    . Методы
    object_group
    ,
    seplist
    ,
    breakable
    и прочие позволяют управлять расстановкой запятых, разбиением на строки и другими способами форматирования. Дополнительную информацию можно найти в документации на сайте http://ruby-doc.org.

    16.8. Заключение

    В этой главе мы рассмотрели некоторые подходы к тестированию (преимущественно, к автономному тестированию компонентов). Мы познакомились с библиотекой

    Test::Unit
    и комплектом инструментов ZenTest.

    Мы бросили беглый взгляд на отладчик Ruby, а также показали, как с помощью библиотеки

    ruby-breakpoint
    можно переходить в интерактивную оболочку
    irb
    для проведения сеанса отладки.

    Мы рассмотрели инструмент для анализа покрытия кода

    rcov
    и обсудили, для чего могут понадобиться такие измерения. Наконец, остановились на профилировании и замере временных характеристик программы на Ruby.

    Предположим, что вы работаете над программой, которую собираетесь предложить обществу. Что вы делаете по завершении тестирования? Пришло время задуматься над оформлением дистрибутивного пакета и способами его распространения. Этим мы и займемся в следующей главе.









    Главная | В избранное | Наш E-MAIL | Добавить материал | Нашёл ошибку | Наверх