Фил Кроу. Шаблоны проектирования в Perl, часть 3, 2003

Перевод статьи: Perl Design Patterns, Part 3

Автор: Фил Кроу (Phil Crow)

Эта третья (и заключительная) статья в серии статей, составляющих единый ответ программистов Perl на книгу "Шаблоны проектирования" (также известную как "книга банды четырёх", поскольку её написали четверо авторов). Как было показано во второй статье, Perl предоставляет типы, необходимые для реализации многих шаблонов. Шаблоны Стратегия и Шаблонный метод можно реализовать с использованием ссылок на код. Строитель обычно строит структуру с использованием некоторого сочетания ссылок на хэши и списки. Интерпретатор может быть реализован с использованием простых средств, вроде split или при помощи всемогущего Parse::RecDescent, который является лучшим генератором синтаксических анализаторов, подобных yacc, внутри скриптов на Perl (хотя он и менее эффективен, чем yacc).

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

1. В каких случаях хороши объекты?

Поскольку Ларри Уолл (Larry Wall) позаботился обо всех способах написания программ, стоит использовать объекты только тогда, когда они имеют смысл и не использовать, если нет. А когда они имеют смысл? Отчасти это дело вкуса. Этот подраздел описывает то, что нравится мне.

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

  1. Есть только данные, а методы либо тривиальны, либо несущественны. Контейнеры данных (также называемые узлами) относятся к этому случаю. Например, объект не нужен, если в вызывающий код нужно всего-лишь вернуть три числа и строку.
  2. Есть только методы. Класс Math из Java - этот случай. Он даже не позволяет сделать объект Math. Очевидно, что эти методы должны быть просто встроенными функциями языка.

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

Теперь, когда я закончил объяснять свои предпочтения, перейдём к шаблонам, использующим объекты.

2. Абстрактная фабрика

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

Для демонстрации основной идеи приведу пример, который возвращает один из двух типов. В этом примере четыре файла с исходными текстами. Два первых файла содержат классы для приветствия пользователя.

package Greet::Repeat;
use strict; use warnings;

sub new {
  my $class = shift;
  my $self = {
    greeting => shift,
    repeat => shift,
  };
  return bless $self, $class;
}

sub greet {
  my $self = shift;
  print ($self->{greeting} x $self->{repeat});
}

1;

Конструктор приветствия ожидает строку приветствия и количество повторов. Он сохраняет их в хэш и возвращает "благословлённую" ссылку на этот хэш. Когда будет вызван метод greet, он несколько раз выведет приветствие (отсюда и название класса). (Я не говорю, что этот пример полезен, просто он достаточно маленький.)

package Greet::Stamp;
use strict; use warnings;

sub new {
  my $class = shift;
  my $greeting = shift;
  return bless \$greeting, $class;
}

sub greet {
  my $greeting = shift;
  my $stamp = localtime();
  print "$stamp $$greeting";
}

1;

Этот класс приветствия ожидает только строку приветствия, поэтому "благословляет" ссылку на неё. Когда вызывается метод greet, но выводит текущее время с последующей строкой приветствия.

Теперь - фабрика:

package GreetFactory;
use strict; use warnings;

sub instantiate {
  my $class = shift;
  my $requested_type = shift;

  my $location = "Greet/$requested_type.pm";
  my $class = "Greet::$requested_type";

  require $location;
  return $class->new(@_);
}

1;

Фабрика в Perl похожа на большинство других фабрик в других языках. В ней есть лишь один метод. Он возвращает экземпляр запрошенного класса и использует указанный в аргументе тип в качестве имени класса и имени модуля Perl, в котором находится этот класс.

Теперь можно воспользоваться этой фабрикой следующим образом:

#!/usr/bin/perl
use strict; use warnings;

use GreetFactory;

my $greeter_n = GreetFactory->instantiate("Repeat", "Hello\n", 3);
$greeter_n->greet();

my $greeter_stamp = GreetFactory->instantiate("Stamp", "Good-bye\n");
$greeter_stamp->greet();

Для создания каждого из объектов приветствия вызывается метод instantiate класса GreetFactory, которому передаётся имя требуемого класса и любые аргументы, которые принимает конструктор этого класса.

Этот пример демонстрирует основную идею. У него простая цель. Но он показывает, как фабрика может абстрагироваться от нижележащих классов. Каждый новый класс приветствия, добавленный в систему, должен иметь имя вида Greet::Имя и должен располагаться в каталоге Greet в пути поиска @INC под именем Имя.pm. Если это так, можно использовать его без изменения фабрики. Это был лишь учебный пример, а теперь посмотрим на пример из практики.

Модуль DBI (DataBase Interface) из Perl демонстрирует великолепный пример фабрики. Каждый вызов DBI->connect принимает тип базы данных и информацию, необходимую для подключения к этой базе данных. Это классическая фабрика. Она загружает любой модуль DBD (DataBase Driver), который установлен в системе, в соответствии с запросом. Дополнительные модули DBD могут быть добавлены в любой момент. Если модуль установлен, любой клиент сможет использовать его при помощи того же API модуля DBI. Вот пример использования DBI:

use DBI;

my $dbh = DBI->connect("dbi:mysql:mydb:localhost", "user", "password");
...
my $sth = $dbh->prepare('select * from table');
...

Однажды полученный дескриптор базы данных (который обычно называют $dbh) может использоваться без оглядки на нижележащую реализацию. Если позже потребуется перевести программу на Oracle, потребуется лишь изменить вызов connect. Если появится новая база данных, то какой-то умный человек вместе с Тимом Бансом (Tim Bunce) реализует класс для работы с ней. Как только они закончат свою работу, можно будет установить модуль и переключиться на его использование. Может быть даже новый модуль напишете вы, но я - вряд ли.

3. Композиция

Теперь рассмотрим, как использовать полностью объектно-ориентированный шаблон Композиция. Если вы заинтересованы в более простой, не объектно-ориентированной реализации этого шаблона, обратитесь к разделу Строитель в предыдущей статье.

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

В наши дни один из наиболее популярных примеров композиции - это файл XML. У этих файлов есть корневой элемент, содержащий подчинённые элементы различных типов, включая тэги и комментарии. Тэги обладают атрибутами и иногда могут содержать другие подчинённые элементы. Образуется обычное дерево композиции. Выделяют два важных этапа: построение композиции и её использование. Рассмотрим оба этапа на простом примере.

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

За практическим примером обратимся к использованию модели DOM для обработки XML. (Модуль XML::DOM можно взять на CPAN.) Чтобы найти все параграфы в документе, нужно поступить примерно так:

use XML::DOM;

my $parser = XML::DOM::Parser->new();
my $doc = $parser->parsefile("file.xml");

foreach my $paragraph ($doc->getElementsByTagName("paragraph")) {
  print "<p>";
  foreach my $child ($paragraph->getChildNodes) {
    print $child->getNodeValue if ($child->getNodeType eq TEXT_NODE);
  }
}

$doc->dispose();

Вызов getElementsByTagName начинается с корневого элемента (потому что он вызван у объекта $doc). Корень возвращает все дочерние элементы, которые являются параграфами, но он также передаёт запрос ко всем своим элементам-тегам, запрашивая их вернуть их параграфы. Они делают то же самое.

Примечание не по теме: Отметим, что вышеприведённый пример завершается вызовом dispose. Композиция XML::DOM использует ссылки от родителей к потомкам и от потомков к родителям. Обычно это приводит к образованию циклических ссылок. Сборщик мусора в Perl 5 не умеет убирать такие структуры данных. Необходимо вызывать dispose, чтобы порвать "лишние" ссылки и сделать возможным восстановление свободной памяти. Если вы строите структуры данных с циклическими ссылками, вам нужно разрывать такие ссылки самостоятельно, в противном случае произойдёт утечка памяти.

Было показано, как можно воспользоваться созданной композицией, но как сделать композицию самостоятельно? Объекты в структуре должны содержать в себе все методы, которые могут понадобиться для обхода композиции. Они могут сразу возвращать undef, но они должны существовать. Далее, варианты этих методов в составных объектах (которые могут содержать потомков) должны передавать сообщение своим потомкам.

Для примера рассмотрим не двоичное дерево (о котором и шла речь выше). Предположим, что нужно узнать, сколько узлов в дереве. Можно спросить об этом корень, вызвав метод count_nodes. Он должен посчитать и добавить себя к сумме, которую вернут вызовы метода count_nodes для каждого из потомков. Узлы, не являющиеся составными (то есть не имеющие потомков), возвращают единицу. Составные узлы возвращают единицу плюс сумму, полученную от своих потомков. Далее будет приведён пример.

Пример состоит из четырёх частей: (1) базовый класс для узлов дерева: Node.pm, (2) класс для узлов, у которых могут быть потомки: Composite.pm, (3) класс для узлов, у которых не может быть потомков: Regular.pm, и (4) управляющая программа для демонстрации работы системы: comp. Я приведу их исходные тексты сразу, в том порядке, в котором они были перечислены.

package Node;
use strict; use warnings;

sub count_nodes {
  my $self = shift;
  my $class_name = ref $self;
  die "$class_name не реализует count_nodes\n";
}

1;

Единственный метод здесь - это count_nodes. Он является заглушкой для настоящей реализации метода (такие методы также называются абстрактными). Попытка воспользоваться подклассом Node, не имеющем собственной реализации count_nodes приведёт к фатальной ошибке времени выполнения. Каждый подкласс должен иметь соответствующую проверку, чтобы убедиться, что эта ошибка никогда не произойдёт при его использовании.

package Regular;
use Node;

@ISA = qw(Node);
use strict; use warnings;

sub new {
  my $class = shift;
  my $name = shift;

  return bless \$name, $class;
}

sub count_nodes {
  return 1;
}

1;

Обычные узлы являются ссылками, "благословлёнными" их именами. Они всегда насчитывают только один узел. (Отвлечённое замечание: иногда бывает удобно включить прагму strict после преамбулы пакета, чтобы позволить себе воспользоваться @ISA, не объявляя его явно.)

package Composite;
use Node;

@ISA = qw(Node);
use strict; use warnings;

sub new {
  my $class = shift;
  my $name = shift;

  my $self = { name => $name, children => [] };
  return bless $self, $class;
}

sub add_child {
  my $self = shift;
  my $new_child = shift;

  push @{$self->{children}}, $new_child;
  return $new_child;
}

sub count_nodes {
  my $self = shift;

  my $count = 1;
  foreach my $child (@{$self->{children}}) {
    $count += $child->count_nodes();
  }
  return $count;
}

1;

Этот класс аналогичен классу Regular, но в нём должно быть место для хранения ссылок на потомков. Поскольку он также содержит собственное имя, здесь "благословляется" хэш. Новые потомки просто помещаются в список. Подсчёт учитывает один родительский узел и полное количество узлов каждого из потомков. Поскольку листья дерева тоже реализуют count_nodes, можно обрабатывать все объекты, реализующие класс Node, одновременно. Это преимущество полиморфизма объектов, которое и является основополагающей концепцией шаблона Композиция.

#!/usr/bin/perl
use strict; use warnings;

use Composite;
use Regular;

my $root = Composite->new("Root");

my $eldest   = $root->add_child(Composite->new("Jim"));
my $middle   = $root->add_child(Composite->new("Jane"));
               $root->add_child(Regular->new("Bob"));
my $youngest = $root->add_child(Composite->new("Joe"));

            $eldest->add_child(Regular->new("JII"));
my $kayla = $eldest->add_child(Composite->new("Kayla"));
            $kayla->add_child(Regular->new("Max"));

my $count = $root->count_nodes();

print "Количество: $count\n";

В этом надуманном примере происходит ручное построение простого дерева и запрос количества узлов в нём. Правильный ответ - 8.

4. Заместитель

В примере шаблона заместителя из книги банды четырёх иллюстрируется способ отложенной загрузки ресурсоёмких компонентов до того момента, пока они действительно не понадобятся пользователю. В ходе примера они демонстрируют настоящего заместителя. Заместитель перенаправляет все запросы к другому объекту. Думайте о нём как об организаторе убийства. Вы передаёте ему заказ, как будто он будет выполнять его сам. Он передаёт заказ киллеру, которого вы никогда не видели, но именно он и выполнит работу. (Примечание для Джона Эшкрофта (John Ashcroft): я только вообразил этот процесс, но у меня НЕТ личного опыта. Честно.)

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

Вот класс, который на самом деле сохраняет и выводит файлы:

package File;
use strict; use warnings;

sub new {
  my $class = shift;
  my $file = shift;

  open FILE, "$file" or die "Не могу прочитать файл $file: $!\n";
  my @data = <FILE>;
  close FILE;

  return bless \@data, $class;
}

sub print_file {
  my $data = shift;

  print @$data;
}

sub DESTROY { }

1;

Когда вызывается конструктор File, он читает файл в массив для последующего использования, возвращая вызывающему коду "благословлённую" ссылку на данные. Когда запрошена печать, он отправляет данные в текущий дескриптор вывода (обычно это стандартный вывод).

Подпрограмма DESTROY вызывается Perl каждый раз, когда благословлённая ссылка покидает область видимости. Она позволяет гарантировать, что будет выполнена очистка. В данном случае очистка не требуется, но я просто хочу продемонстрировать, каким образом класс-заместитель явным образом вызывает этот метод. Этот явный вызов настолько сильно обижает Perl, что он жалуется на экран. Чтобы избежать этого предупреждения, я включаю заполнитель.

В классе File, показанном выше, нет ничего особенного. Теперь рассмотрим класс-заместитель.

package FileProxy;
use strict; use warnings;
use File;

sub new {
  my $class = shift;

  my $self = {
    params => \@_,
    wrapped_object => undef,
  };

  return bless $self, $class;
}

sub AUTOLOAD {
  my $self = shift;
  my $command = our $AUTOLOAD;

  $command =~ s/.*://;
  unless (defined $self->{wrapped_object}) {
    $self->{wrapped_object} = File->new(@{$self->{params}});
  }

  $self->{wrapped_object}->$command(@_);
}

1;

Конструктор заместителя принимает аргументы, необходимые для создания настоящего объекта File (а конкретнее - имя файла) и сохраняет его в качестве своего атрибута params. Другой атрибут в конечном счёте будет содержать обёрнутый объект File. Атрибуты сохраняются в хэше, ссылка на хэш "благословляется" и возвращается вызывающему коду.

Каждый раз, когда Perl не может найти вызываемый метод, он вызывает AUTOLOAD (как в примере выше). Метод AUTOLOAD в FileProxy обрабатывает все запросы, за исключением методов new и DESTROY, которые определены явным образом. AUTOLOAD пишется в верхнем регистре, чтобы напомнить нам о том, что Perl вызывает этот метод самостоятельно. Когда выполняется вызов, Perl записывает в глобальную переменную пакета $AUTOLOAD имя метода, который был вызван. Регулярное выражение отрезает имя пакета от $AUTOLOAD, оставляя только имя метода.

Если объект ещё не определён, AUTOLOAD вызовет File->new, передав ему аргументы, сохранённые при создании самого объекта-заместителя. Затем, когда объект уже определён, AUTOLOAD вызовет запрошенный метод обёрнутого объекта. Красота этого механизма заключается в том, что класс FileProxy знает только о существовании конструктора new. Его не нужно менять, если внесены изменения в File.pm. Любые ошибки, например вызов не существующего метода, по прежнему окажутся фатальными.

Для использования этой схемы замещения можно написать, например, следующий код:

#!/usr/bin/perl
use strict; use warnings;
use FileProxy;

my $file1 = FileProxy->new("art1");
my $file2 = FileProxy->new("art2");

$file1->print_file();
$file1->print_file();
$file2->print_file();

Если внести небольшие изменения, то можно будет использовать заместителя для работы с любым классом. Вот новая, обобщённая версия:

package DelayLoad;
use strict; use warnings;

our %proxied_classes;

sub import {
  shift; # пропускаем имя класса

  %proxied_classes = @_;

  foreach my $class (keys %proxied_classes) {
    require "$class.pm";
  }
}

sub new {
  my $class = shift;

  my $self = {
    type => shift,
    constructor => shift,
    params => \@_,
    wrapped_object => undef,
  };
  return bless $self, $class;
}

sub AUTOLOAD {
  my $self = shift;
  my $command = our $AUTOLOAD;
  
  $command =~ s/.*://;
  if ($proxied_classes{$command}) {
    return $self->new($command, $proxied_classes{$command}, @_);
  }
  else {
    unless (defined $self->{wrapped_object}) {
      my $proxied_class = $self->{type};
      my $constructor = $self->{constructor};
      $self->{wrapped_object} = $proxied_class->$constructor(@{$self->{params}});
    }
    $self->{wrapped_object}->$command(@_);
  }
}

1;

Первое изменение - косметическое: имя теперь отражает природу заместителя. Другие изменения включают новый метод - import. Хотя имя метода записано в нижнем регистре, тем не менее Perl вызывает его всякий раз, когда вызывающий код говорит "use DelayLoad" (смотрите ниже). Он делает две вещи. Во-первых, он сохраняет имя каждого замещённого класса в глобальный хэш пакета %proxied_classes. Во-вторых, он подключает каждый модуль при помощи require. require подобен use, но выполняется во время работы программы, а не при её компиляции. (use также импортирует таблицу имён, но объектно-ориентированный модуль обычно ничего не экспортирует.)

Теперь конструктор хранит чуточку больше информации. Кроме подготовки места для хранения обёрнутого объекта и хранения параметров, он также записывает имя класса и имя его конструктора. Всё это используется в AUTOLOAD.

Есть и другие изменения в методе AUTOLOAD. В нём есть два изменения. Простейшее - это поиск класса и имени конструктора в объекте DelayLoad вместо прямого вызова File->new.

Другое изменение используется в процессе создания объекта. Моё объяснение будет более доходчивым, если сначала посмотреть на вызывающий код.

В новой версии нужно внести несколько изменений в вызывающий код. Одно из изменений заключается в том, что строка use превращается в:

use DelayLoad "File" => "new";

В этой строке мы сообщаем DelayLoad, что мы хотим его использовать для отложенной загрузки объектов File, а конструктор объектов File называется new.

Второе изменение касается того, как создаётся обёрнутый объект:

my $file1 = DelayLoad->File("art1");
my $file2 = DelayLoad->File("art2");

Здесь объясняется необъяснённый фрагмент AUTOLOAD. Когда пользователь вызывает метод File, AUTOLOAD замечает, что этот "метод" на самом деле является именем класса, для которого должна быть выполнена отложенная загрузка. Если условие if в AUTOLOAD истинно (то есть метод на самом деле является ключом в хэше %proxied_classes), вызывающий код получает новый объект DelayLoad, подготовленный к дальнейшему использованию. Если условие if не срабатывает, DelayLoad работает как FileLoad: он создаёт объект, если это необходимо, и вызывает запрошенный метод.

Главное в этом примере заключается в том, что Perl позволяет нам реализовать заместителя практически без необходимости что-либо знать о нижележащем классе. В этом случае import получает необходимую информацию от вызывающего кода, а AUTOLOAD заботится об остальном. Перекладывать часть работы на вызывающий код - не самая лучшая мысль. Здесь в этом есть смысл, поскольку если нужно выполнить отложенную загрузку объектов, то хотя-бы нужно знать API этих объектов. В данном случае под API понимается имя конструктора, которое указывается в выражении use для того, чтобы Perl мог передать его в метод DelayLoad::import.

Помните, что AUTOLOAD не предназначен для работы такого рода. Его действительное назначение заключается в том, чтобы загружать в текущий пакет подпрограммы по требованию. Здесь он не может этого сделать, поскольку изменение подпрограмм влияет на все экземпляры класса. Здесь мы используем AUTOLOAD для загрузки данных, а не подпрограмм. Подстраивая необходимым образом import и AUTOLOAD можно приспособить заместителя для выполнения других задач.

5. Итог

В этой статье окончательно продемонстрированы объектно-ориентированные шаблоны. Мы увидели, как реализовать Фабрику так, чтобы вызывающий код мог выбирать необходимый драйвер, как строить составные структуры данных и подпрограммы для их обхода (без явного использования указателей first_child и next, что было бы необходимо в случае языков без качественной встроенной поддержки списков), и как стать заместителем между вызывающим кодом и классом при помощи методов import и AUTOLOAD.

6. Примечания автора

Это последняя статья из серии статей, но в скором будущем вы сможете купить в ближайшем книжном магазине книгу Design Patterns in Perl (Шаблоны проектирования в Perl), выпущенную издательством Apress.

Написать автору перевода