Книга: Чистый код: создание, анализ и рефакторинг. Библиотека программиста
Назад: 5. Форматирование
Дальше: 7. Обработка ошибок

6. Объекты и структуры данных

06_01.tif 

Существует веская причина для ограничения доступа к переменным в программах: мы не хотим, чтобы другие программисты зависели от них. Мы хотим иметь возможность свободно менять тип или реализацию этих переменных так, как считаем нужным. Тогда почему же многие программисты автоматически включают в свои объекты методы чтения/записи, предоставляя доступ к приватным переменным так, словно они являются открытыми?

Абстракция данных

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

Листинг 6.1. Конкретная реализация Point

public class Point {

  public double x;

  public double y;

}

Листинг 6.2. Абстрактная реализация Point

public interface Point {

  double getX();

  double getY();

  void setCartesian(double x, double y);

  double getR();

  double getTheta();

  void setPolar(double r, double theta);

}

Элегантность решения из листинга 6.2 заключается в том, что внешний пользователь не знает, какие координаты использованы в реализации — прямоугольные или полярные. А может, еще какие-нибудь! Тем не менее интерфейс безусловно напоминает структуру данных.

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

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

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

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

Листинг 6.3. Конкретная реализация Vehicle

public interface Vehicle {

  double getFuelTankCapacityInGallons();

  double getGallonsOfGasoline();

}

Листинг 6.4. Абстрактная реализация Vehicle

Abstract Vehicle

public interface Vehicle {

  double getPercentFuelRemaining();

}

В обоих примерах вторая реализация является предпочтительной. Мы не хотим раскрывать подробности строения данных. Вместо этого желательно использовать представление данных на абстрактном уровне. Задача не решается простым использованием интерфейсов и/или методов чтения/записи. Чтобы найти лучший способ представления данных, содержащихся в объекте, необходимо серьезно поразмыслить. Бездумное добавление методов чтения и записи — худший из всех возможных вариантов.

Антисимметрия данных/объектов

Два предыдущих примера показывают, чем объекты отличаются от структур данных. Объекты скрывают свои данные за абстракциями и предоставляют функции, работающие с этими данными. Структуры данных раскрывают свои данные и не имеют осмысленных функций. А теперь еще раз перечитайте эти определения. Обратите внимание на то, как они дополняют друг друга, фактически являясь противоположностями. Различия могут показаться тривиальными, но они приводят к далеко идущим последствиям.

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

Листинг 6.5. Процедурные фигуры

public class Square {

  public Point topLeft;

  public double side;

}

 

public class Rectangle {

  public Point topLeft;

  public double height;

  public double width;

}

 

public class Circle {

  public Point center;

  public double radius;

}

Листинг 6.5 (продолжение)

public class Geometry {

  public final double PI = 3.141592653589793;

  public double area(Object shape) throws NoSuchShapeException

  {

    if (shape instanceof Square) {

      Square s = (Square)shape;

      return s.side * s.side;

    }

    else if (shape instanceof Rectangle) {

      Rectangle r = (Rectangle)shape;

      return r.height * r.width;

    }

    else if (shape instanceof Circle) {

      Circle c = (Circle)shape;

      return PI * c.radius * c.radius;

    }

    throw new NoSuchShapeException();

  }

}

Объектно-ориентированный программист недовольно поморщится и пожалуется на процедурную природу реализации — и будет прав. Но возможно, его презрительная усмешка не обоснована. Подумайте, что произойдет при включении в Geometry функции perimeter(). Классы фигур остаются неизменными! И все остальные классы, зависящие от них, тоже остаются неизменными! С другой стороны, при добавлении новой разновидности фигур мне придется изменять все функции Geometry, чтобы они могли работать с ней. Перечитайте еще раз. Обратите внимание на то, что эти два условия диаметрально противоположны.

Теперь рассмотрим объектно-ориентированное решение из листинга 6.6. Метод area() является полиморфным, класс Geometry становится лишним. Добавление новой фигуры не затрагивает ни одну из существующих функций, но при добавлении новой функции приходится изменять все фигуры!

Листинг 6.6. Полиморфные фигуры

Polymorphic Shapes

public class Square implements Shape {

  private Point topLeft;

  private double side;

 

  public double area() {

    return side*side;

  }

}

public class Rectangle implements Shape {

  private Point topLeft;

  private double height;

  private double width;

 

  public double area() {

    return height * width;

  }

}

 

public class Circle implements Shape {

  private Point center;

  private double radius;

  public final double PI = 3.141592653589793;

 

  public double area() {

    return PI * radius * radius;

  }

}

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

Процедурный код (код, использующий структуры данных) позволяет легко добавлять новые функции без изменения существующих структур данных. Объектно-ориентированный код, напротив, упрощает добавление новых классов без изменения существующих функций.

Обратные утверждения также истинны.

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

Таким образом, то, что сложно в ОО, просто в процедурном программировании, а то, что сложно в процедурном программировании, просто в ОО!

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

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

Закон Деметры

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

В более точной формулировке закон Деметры гласит, что метод f класса C должен ограничиваться вызовом методов следующих объектов:

• C;

• объекты, созданные f;

• объекты, переданные f в качестве аргумента;

• объекты, хранящиеся в переменной экземпляра C.

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

Следующий код нарушает закон Деметры (среди прочего), потому что он вызывает функцию getScratchDir() для возвращаемого значения getOptions(), а затем вызывает getAbsolutePath() для возвращаемого значения getScratchDir().

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

Крушение поезда

Подобная структура кода часто называется «крушением поезда», потому что цепочки вызовов напоминают сцепленные вагоны поезда. Такие конструкции считаются проявлением небрежного стиля программирования и их следует избегать [G36]. Обычно цепочки лучше разделить в следующем виде:

Options opts = ctxt.getOptions();

File scratchDir = opts.getScratchDir();

final String outputDir = scratchDir.getAbsolutePath();

06_02.tif

Нарушают ли эти два фрагмента закон

Деметры? Несомненно, вмещающий модуль знает, что объект контекста ctxt содержит значения параметров, в число которых входит и временный каталог, обладающий абсолютным путем. Это довольно большой объем информации для одной функции. Вызывающая функция должна знать, как перемещаться между множеством разных объектов.

Нарушает ли этот код закон Деметры или нет? Все зависит от того, чем являются ctxt, Options и ScratchDir — объектами или структурами данных. Если это объекты, то их внутренняя структура должна скрываться, поэтому необходимость ин­формации об их строении является явным нарушением закона Деметры. С другой стороны, если ctxt, Options и ScratchDir представляют собой обычные структуры данных, не обладающие поведением, то они естественным образом раскрывают свою внутреннюю структуру, а закон Деметры на них не распространяется.

Применение функций доступа затрудняет ситуацию. Если бы код был записан следующим образом, вероятно, у нас не возникало бы вопросов по поводу нарушения закона Деметры:

final String outputDir = ctxt.options.scratchDir.absolutePath;

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

Гибриды

Вся эта неразбериха иногда приводит к появлению гибридных структур — наполовину объектов, наполовину структур данных. Гибриды содержат как функции для выполнения важных операций, так и открытые переменные или открытые методы чтения/записи, которые во всех отношениях делают приватные переменные открытыми. Другим внешним функциям предлагается использовать эти переменные так, как в процедурных программах используются структуры данных.

Подобные гибриды усложняют как добавление новых функций, так и новых структур данных. Они объединяют все худшее из обеих категорий. Не используйте гибриды. Они являются признаком сумбурного проектирования, авторы которого не уверены (или еще хуже, не знают), что они собираются защищать: функции или типы.

Скрытие структуры

А если ctxt, options и scratchDir представляют собой объекты с реальным поведением? Поскольку объекты должны скрывать свою внутреннюю структуру, мы не сможем перемещаться между ними. Как же в этом случае узнать абсолютный путь временного каталога?

ctxt.getAbsolutePathOfScratchDirectoryOption();

или

ctx.getScratchDirectoryOption().getAbsolutePath()

Первый вариант приведет к разрастанию набора методов объекта ctxt. Второй вариант предполагает, что getScratchDirectoryOption() возвращает структуру данных, а не объект. Ни один из вариантов не вызывает энтузиазма.

Если ctxt является объектом, то мы должны приказать ему выполнить некую операцию, а не запрашивать у него информацию о его внутреннем устройстве. Зачем нам понадобился абсолютный путь к временному каталогу? Что мы собираемся с ним делать? Рассмотрим следующий фрагмент того же модуля (расположенный на много строк ниже):

String outFile = outputDir + "/" + className.replace('.', '/') + ".class";

FileOutputStream fout = new FileOutputStream(outFile);

BufferedOutputStream bos = new BufferedOutputStream(fout);

Смешение разных уровней детализации [G34][G6] выглядит немного пугающе. Точки, косые черты, расширения файлов и объекты File не должны так беспечно перемешиваться между собой и с окружающим кодом. Но если не обращать на это внимания, мы видим, что абсолютный путь временного каталога определялся для создания временного файла с заданным именем.

Так почему бы не приказать объекту ctxt выполнить эту операцию?

BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

Выглядит вполне разумно! Такое решение позволяет объекту ctxt скрыть свое внутреннее строение, а текущей функции не приходится нарушать закон Деметры, перемещаясь между объектами, о которых ей знать не положено.

Объекты передачи данных

Квинтэссенцией структуры данных является класс с открытыми переменными и без функций. Иногда такие структуры называются объектами передачи данных, или DTO (Data Transfer Object). Структуры DTO чрезвычайно полезны, особенно при работе с базами данных, разборе сообщений из сокетов и т.д. С них часто начинается серия фаз преобразования низкоуровневых данных, полученных из базы, в объекты кода приложения.

Несколько большее распространение получила форма bean-компонентов, представленная в листинге 6.7. Bean-компоненты состоят из приватных переменных, операции с которыми осуществляются при помощи методов чтения/записи. Подобная форма псевдоинкапсуляции поднимает настроение некоторым блюстителям чистоты ОО, но обычно не имеет других преимуществ.

Листинг 6.7. address.java

public class Address {

  private String street;

  private String streetExtra;

  private String city;

  private String state;

  private String zip;

 

  public Address(String street, String streetExtra,

                  String city, String state, String zip) {

    this.street = street;

    this.streetExtra = streetExtra;

    this.city = city;

    this.state = state;

    this.zip = zip;

  }

 

  public String getStreet() {

    return street;

  }

 

  public String getStreetExtra() {

    return streetExtra;

  }

 

  public String getCity() {

    return city;

  }

 

  public String getState() {

    return state;

  }

 

  public String getZip() {

    return zip;

  }

}

Активные записи

Активные записи (Active Records) составляют особую разновидность DTO. Они тоже представляют собой структуры данных с открытыми переменными (или переменными с bean-доступом), но обычно в них присутствуют навигационные методы — такие, как save или find. Активные записи чаще всего являются результатами прямого преобразования таблиц баз данных или других источников данных.

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

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

Заключение

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

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

Литература

[Refactoring]: Refactoring: Improving the Design of Existing Code, Martin Fowler et al., Addison-Wesley, 1999.

У проблемы существуют обходные решения, хорошо известные опытным объектно-ориентированным программистам: например, паттерн ПОСЕТИТЕЛЬ или двойная диспетчеризация. Но у этих приемов имеются собственные издержки, к тому же они обычно возвращают структуру к состоянию процедурной программы.

Иногда это называется «функциональной завистью» (Feature Envy) — из [Refactoring].

Назад: 5. Форматирование
Дальше: 7. Обработка ошибок