Книга: Чистый код: создание, анализ и рефакторинг. Библиотека программиста
Назад: 9. Модульные тесты
Дальше: 11. Системы

10. Классы

Совместно с Джеффом Лангром

10_01.tif 

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

Строение класса

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

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

Инкапсуляция

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

Классы должны быть компактными!

Первое правило: классы должны быть компактными. Второе правило: классы должны быть еще компактнее. Нет, мы не собираемся повторять текст из главы 3. Но как и в случае с функциями, компактность должна стать основным правилом проектирования классов. И для классов начинать следует с вопроса: «А насколько компактными?»

Размер функций определяется количеством физических строк. В классах используется другая метрика; мы подсчитываем ответственности [RDD].

В листинге 10.1 представлен класс SuperDashboard, предоставляющий около 70 открытых методов. Большинство разработчиков согласится с тем, что это перебор.

Листинг 10.1. Слишком много ответственностей

public class SuperDashboard extends JFrame implements MetaDataUser

   public String getCustomizerLanguagePath()

   public void setSystemConfigPath(String systemConfigPath)

   public String getSystemConfigDocument()

   public void setSystemConfigDocument(String systemConfigDocument)

   public boolean getGuruState()

   public boolean getNoviceState()

   public boolean getOpenSourceState()

   public void showObject(MetaObject object)

   public void showProgress(String s)

   public boolean isMetadataDirty()

   public void setIsMetadataDirty(boolean isMetadataDirty)

   public Component getLastFocusedComponent()

   public void setLastFocused(Component lastFocused)

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

   public void setMouseSelectState(boolean isMouseSelected)

   public boolean isMouseSelected()

   public LanguageManager getLanguageManager()

   public Project getProject()

   public Project getFirstProject()

   public Project getLastProject()

   public String getNewProjectName()

   public void setComponentSizes(Dimension dim)

   public String getCurrentDir()

   public void setCurrentDir(String newDir)

   public void updateStatus(int dotPos, int markPos)

   public Class[] getDataBaseClasses()

   public MetadataFeeder getMetadataFeeder()

   public void addProject(Project project)

   public boolean setCurrentProject(Project project)

   public boolean removeProject(Project project)

   public MetaProjectHeader getProgramMetadata()

   public void resetDashboard()

   public Project loadProject(String fileName, String projectName)

   public void setCanSaveMetadata(boolean canSave)

   public MetaObject getSelectedObject()

   public void deselectObjects()

   public void setProject(Project project)

   public void editorAction(String actionName, ActionEvent event)

   public void setMode(int mode)

   public FileManager getFileManager()

   public void setFileManager(FileManager fileManager)

   public ConfigManager getConfigManager()

   public void setConfigManager(ConfigManager configManager)

   public ClassLoader getClassLoader()

   public void setClassLoader(ClassLoader classLoader)

   public Properties getProps()

   public String getUserHome()

   public String getBaseDir()

   public int getMajorVersionNumber()

   public int getMinorVersionNumber()

   public int getBuildNumber()

   public MetaObject pasting(

   MetaObject target, MetaObject pasted, MetaProject project)

   public void processMenuItems(MetaObject metaObject)

   public void processMenuSeparators(MetaObject metaObject)

   public void processTabPages(MetaObject metaObject)

   public void processPlacement(MetaObject object)

   public void processCreateLayout(MetaObject object)

   public void updateDisplayLayer(MetaObject object, int layerIndex)

   public void propertyEditedRepaint(MetaObject object)

   public void processDeleteObject(MetaObject object)

   public boolean getAttachedToDesigner()

   public void processProjectChangedState(boolean hasProjectChanged)

   public void processObjectNameChanged(MetaObject object)

   public void runProject()

   public void setAçowDragging(boolean allowDragging)

   public boolean allowDragging()

   public boolean isCustomizing()

   public void setTitle(String title)

   public IdeMenuBar getIdeMenuBar()

   public void showHelper(MetaObject metaObject, String propertyName)

   // ... и еще много других, не-открытых методов...

}

А если бы класс SuperDashboard содержал только методы, приведенные в листинге 10.2?

Листинг 10.2. Достаточно компактно?

public class SuperDashboard extends JFrame implements MetaDataUser

    public Component getLastFocusedComponent()

    public void setLastFocused(Component lastFocused)

    public int getMajorVersionNumber()

    public int getMinorVersionNumber()

    public int getBuildNumber()

}

Пять методов — не слишком много, не так ли? В нашем случае слишком, потому что несмотря на малое количество методов, класс SuperDashboard по-прежнему имеет слишком много ответственностей.

Имя класса должно описывать его ответственности. В сущности, имя должно стать первым фактором, способствующим определению размера класса. Если для класса не удается подобрать четкое, короткое имя, вероятно, он слишком велик. Чем туманнее имя класса, тем больше вероятность, что он имеет слишком много ответственностей. В частности, присутствие в именах классов слов-проныр «Processor», «Manager» и «Super» часто свидетельствует о нежелательном объединении ответственностей.

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

Принцип единой ответственности (SRP)

Принцип единой ответственности (SRP) утверждает, что класс или модуль должен иметь одну — и только одну — причину для изменения. Этот принцип дает нам как определение ответственности, так и критерий для оценки размера класса. Классы должны иметь одну ответственность, то есть одну причину для изменений.

Небольшой, казалось бы, класс SuperDashboard в листинге 10.2 имеет две причины для изменений. Во-первых, он отслеживает версию, которая, вероятно, будет изменяться при каждом обновлении продукта. Во-вторых, он управляет компонентами Java Swing (потомки класса JFrame, представляющего графическое окно верхнего уровня в Swing). Несомненно, номер версии должен обновляться при любых изменениях кода Swing, но обратное не всегда верно: номер версии также может изменяться вследствие изменений в другом коде системы.

Попытки идентификации ответственностей (причин для изменения) часто помогают выявить и создать более качественные абстракции для нашего кода. Все три метода SuperDashboard, относящиеся к версии, легко выделяются в отдельный класс с именем Version (листинг 10.3). Класс Version обладает хорошим потенциалом для повторного использования в других приложениях!

Листинг 10.3. Класс с единой ответственностью

public class Version {

    public int getMajorVersionNumber()

    public int getMinorVersionNumber()

    public int getBuildNumber()

}

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

Заставить программу работать и написать чистый код — совершенно разные вещи. Обычно мы думаем прежде всего о том, чтобы наш код заработал, а не о его структуре и чистоте. И это абсолютно законно. Разделение ответственности в работе программиста играет не менее важную роль, чем в наших программах.

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

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

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

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

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

Связность

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

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

Рассмотрим реализацию стека из листинга 10.4. Этот класс обладает очень высокой связностью. Из трех его методов только size() не использует обе переменные.

Листинг 10.4. Stack.java — класс с высокой связностью

public class Stack {

  private int topOfStack = 0;

  List<Integer> elements = new LinkedList<Integer>();

 

  public int size() {

    return topOfStack;

  }

 

  public void push(int element) {

    topOfStack++;

    elements.add(element);

  }

 

  public int pop() throws PoppedWhenEmpty {

    if (topOfStack == 0)

      throw new PoppedWhenEmpty();

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

    int element = elements.get(--topOfStack);

    elements.remove(topOfStack);

    return element;

  }

}

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

Поддержание связности приводит  к уменьшению классов

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

Ни в коем случае! Преобразовав эти четыре переменные в переменные экземпляров класса, мы сможем выделить код без передачи переменных. Таким образом, разбиение функции на меньшие фрагменты упрощается.

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

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

Для демонстрации мы воспользуемся проверенным временем примером из замечательной книги Кнута «Literate Programming» [Knuth92]. В листинге 10.5 представлена программа Кнута PrintPrimes, переведенная на Java. Справедливости ради стоит отметить, что это не та программа, которую написал Кнут, а та, которую выводит его утилита WEB. Я воспользуюсь ей, потому что она является отличной отправной точкой для разбиения большой функции на несколько меньших функций и классов.

Листинг 10.5. PrintPrimes.java

package literatePrimes;

 

public class PrintPrimes {

  public static void main(String[] args) {

    final int M = 1000;

    final int RR = 50;

    final int CC = 4;

    final int WW = 10;

    final int ORDMAX = 30;

    int P[] = new int[M + 1];

    int PAGENUMBER;

    int PAGEOFFSET;

    int ROWOFFSET;

    int C;

    int J;

    int K;

    boolean JPRIME;

    int ORD;

    int SQUARE;

    int N;

    int MULT[] = new int[ORDMAX + 1];

 

    J = 1;

    K = 1;

    P[1] = 2;

    ORD = 2;

    SQUARE = 9;

 

    while (K < M) {

      do {

        J = J + 2;

        if (J == SQUARE) {

          ORD = ORD + 1;

          SQUARE = P[ORD] * P[ORD];

          MULT[ORD - 1] = J;

        }

        N = 2;

        JPRIME = true;

        while (N < ORD && JPRIME) {

          while (MULT[N] < J)

            MULT[N] = MULT[N] + P[N] + P[N];

          if (MULT[N] == J)

            JPRIME = false;

          N = N + 1;

        }

      } while (!JPRIME);

      K = K + 1;

      P[K] = J;

    }

    {

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

      PAGENUMBER = 1;

      PAGEOFFSET = 1;

      while (PAGEOFFSET <= M) {

        System.out.println("The First " + M +

                             " Prime Numbers --- Page " + PAGENUMBER);

        System.out.println("");

        for (ROWOFFSET = PAGEOFFSET; ROWOFFSET < PAGEOFFSET + RR; ROWOFFSET++){

          for (C = 0; C < CC;C++)

            if (ROWOFFSET + C * RR <= M)

              System.out.format("%10d", P[ROWOFFSET + C * RR]);

          System.out.println("");

        }

        System.out.println("\f");

        PAGENUMBER = PAGENUMBER + 1;

        PAGEOFFSET = PAGEOFFSET + RR * CC;

      }

    }

  }

}

Записанная в виде одной функции, эта программа представляет собой полную неразбериху. Многоуровневая вложенность, множество странных переменных, структура с жесткой привязкой… По крайней мере, одну большую функцию следует разбить на несколько меньших функций.

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

Листинг 10.6. PrimePrinter.java (переработанная версия)

package literatePrimes;

 

public class PrimePrinter {

  public static void main(String[] args) {

    final int NUMBER_OF_PRIMES = 1000;

    int[] primes = PrimeGenerator.generate(NUMBER_OF_PRIMES);

 

    final int ROWS_PER_PAGE = 50;

    final int COLUMNS_PER_PAGE = 4;

    RowColumnPagePrinter tablePrinter =

      new RowColumnPagePrinter(ROWS_PER_PAGE,

                               COLUMNS_PER_PAGE,

                               "The First " + NUMBER_OF_PRIMES +

                               " Prime Numbers");

 

    tablePrinter.print(primes);

  }

 

}

Листинг 10.7. RowColumnPagePrinter.java

package literatePrimes;

 

import java.io.PrintStream;

 

public class RowColumnPagePrinter {

  private int rowsPerPage;

  private int columnsPerPage;

  private int numbersPerPage;

  private String pageHeader;

  private PrintStream printStream;

 

  public RowColumnPagePrinter(int rowsPerPage,

                              int columnsPerPage,

                              String pageHeader) {

    this.rowsPerPage = rowsPerPage;

    this.columnsPerPage = columnsPerPage;

    this.pageHeader = pageHeader;

    numbersPerPage = rowsPerPage * columnsPerPage;

    printStream = System.out;

  }

 

  public void print(int data[]) {

    int pageNumber = 1;

    for (int firstIndexOnPage = 0;

         firstIndexOnPage < data.length;

         firstIndexOnPage += numbersPerPage) {

      int lastIndexOnPage =

        Math.min(firstIndexOnPage + numbersPerPage - 1,

                 data.length - 1);

      printPageHeader(pageHeader, pageNumber);

      printPage(firstIndexOnPage, lastIndexOnPage, data);

      printStream.println("\f");

      pageNumber++;

    }

  }

 

  private void printPage(int firstIndexOnPage,

                         int lastIndexOnPage,

                         int[] data) {

    int firstIndexOfLastRowOnPage =

      firstIndexOnPage + rowsPerPage - 1;

    for (int firstIndexInRow = firstIndexOnPage;

         firstIndexInRow <= firstIndexOfLastRowOnPage;

         firstIndexInRow++) {

      printRow(firstIndexInRow, lastIndexOnPage, data);

      printStream.println("");

    }

  }

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

  private void printRow(int firstIndexInRow,

                        int lastIndexOnPage,

                        int[] data) {

    for (int column = 0; column < columnsPerPage; column++) {

      int index = firstIndexInRow + column * rowsPerPage;

      if (index <= lastIndexOnPage)

        printStream.format("%10d", data[index]);

    }

  }

 

  private void printPageHeader(String pageHeader,

                               int pageNumber) {

    printStream.println(pageHeader + " --- Page " + pageNumber);

    printStream.println("");

  }

 

  public void setOutput(PrintStream printStream) {

    this.printStream = printStream;

  }

}

Листинг 10.8. PrimeGenerator.java

package literatePrimes;

 

import java.util.ArrayList;

 

public class PrimeGenerator {

  private static int[] primes;

  private static ArrayList<Integer> multiplesOfPrimeFactors;

 

  protected static int[] generate(int n) {

    primes = new int[n];

    multiplesOfPrimeFactors = new ArrayList<Integer>();

    set2AsFirstPrime();

    checkOddNumbersForSubsequentPrimes();

    return primes;

  }

 

  private static void set2AsFirstPrime() {

    primes[0] = 2;

    multiplesOfPrimeFactors.add(2);

  }

 

  private static void checkOddNumbersForSubsequentPrimes() {

    int primeIndex = 1;

    for (int candidate = 3;

         primeIndex < primes.length;

         candidate += 2) {

      if (isPrime(candidate))

        primes[primeIndex++] = candidate;

    }

  }

  private static boolean isPrime(int candidate) {

    if (isLeastRelevantMultipleOfNextLargerPrimeFactor(candidate)) {

      multiplesOfPrimeFactors.add(candidate);

      return false;

    }

    return isNotMultipleOfAnyPreviousPrimeFactor(candidate);

  }

 

  private static boolean

  isLeastRelevantMultipleOfNextLargerPrimeFactor(int candidate) {

    int nextLargerPrimeFactor = primes[multiplesOfPrimeFactors.size()];

    int leastRelevantMultiple = nextLargerPrimeFactor * nextLargerPrimeFactor;

    return candidate == leastRelevantMultiple;

  }

 

  private static boolean

  isNotMultipleOfAnyPreviousPrimeFactor(int candidate) {

    for (int n = 1; n < multiplesOfPrimeFactors.size(); n++) {

      if (isMultipleOfNthPrimeFactor(candidate, n))

        return false;

    }

    return true;

  }

 

  private static boolean

  isMultipleOfNthPrimeFactor(int candidate, int n) {

    return

      candidate == smallestOddNthMultipleNotLessThanCandidate(candidate, n);

  }

 

  private static int

  smallestOddNthMultipleNotLessThanCandidate(int candidate, int n) {

    int multiple = multiplesOfPrimeFactors.get(n);

    while (multiple < candidate)

      multiple += 2 * primes[n];

    multiplesOfPrimeFactors.set(n, multiple);

    return multiple;

  }

}

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

Обратите внимание на логическое разбиение программы в соответствии с тремя основными видами ответственности. Основной код программы содержится в классе PrimePrinter; он отвечает за управлении средой выполнения. Именно этот код изменится в случае смены механизма вызова. Например, если в будущем программа будет преобразована в службу SOAP, то изменения будут внесены в код PrimePrinter.

Класс RowColumnPagePrinter специализируется на форматировании списка чисел в страницы с определенным количеством строк и столбцов. Если потребуется изменить формат вывода, то изменения затронут только этот класс.

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

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

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

Структурирование с учетом изменений

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

Класс Sql в листинге 10.9 используется для построения правильно сформированных строк SQL по соответствующим метаданным. Работа еще не завершена, поэтому класс не поддерживает многие функции SQL (например, команды update). Когда придет время включения в класс Sql поддержки update, придется «открыть» этот класс для внесения изменений. Но как уже говорилось, открытие класса создает риск. Любые изменения в этом классе создают потенциальную возможность для нарушения работы остального кода класса, поэтому весь код приходится полностью тестировать заново.

Листинг 10.9. Класс, который необходимо открыть для внесения изменений

public class Sql {

   public Sql(String table, Column[] columns)

   public String create()

   public String insert(Object[] fields)

   public String selectAll()

   public String findByKey(String keyColumn, String keyValue)

   public String select(Column column, String pattern)

   public String select(Criteria criteria)

   public String preparedInsert()

   private String columnList(Column[] columns)

   private String valuesList(Object[] fields, final Column[] columns)

   private String selectWithCriteria(String criteria)

   private String placeholderList(Column[] columns)

}

Класс Sql изменяется при добавлении нового типа команды. Кроме того, он будет изменяться при изменении подробностей реализации уже существующего типа команды — скажем, если нам понадобится изменить функциональность select для поддержки подчиненной выборки. Две причины для изменения означают, что класс Sql нарушает принцип единой ответственности.

Нарушение принципа единой ответственности проявляется и в структуре кода. Из набора методов Sql видно, что класс содержит приватные методы (например, selectWithCriteria), относящиеся только к командам select.

Приватные методы, действие которых распространяется только на небольшое подмножество класса, — хороший признак для поиска потенциальных усовершенствований. Тем не менее основные усилия следует направить на изменение самой системы. Если бы класс Sql выглядел логически завершенным, то нам не пришлось бы беспокоиться о разделении ответственности. Если бы в обозримом будущем функциональность update не понадобилась, Sql можно было бы оставить в покое. Но как только выясняется, что класс необходимо открыть, нужно рассмотреть возможность усовершенствования его структуры.

Почему бы не воспользоваться решением, представленным в листинге 10.10? Для каждого метода открытого интерфейса, определенного в предыдущей версии Sql из листинга 10.9, создается соответствующий класс, производный от Sql. При этом приватные методы (такие, как valuesList) перемещаются непосредственно туда, где они понадобятся. Общее приватное поведение изолируется в паре вспомогательных классов, Where и ColumnList.

Листинг 10.10. Набор закрытых классов

abstract public class Sql {

   public Sql(String table, Column[] columns)

   abstract public String generate();

}

 

public class CreateSql extends Sql {

   public CreateSql(String table, Column[] columns)

   @Override public String generate()

}

 

public class SelectSql extends Sql {

   public SelectSql(String table, Column[] columns)

   @Override public String generate()

}

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

public class InsertSql extends Sql {

   public InsertSql(String table, Column[] columns, Object[] fields)

   @Override public String generate()

   private String valuesList(Object[] fields, final Column[] columns)

}

 

public class SelectWithCriteriaSql extends Sql {

   public SelectWithCriteriaSql(

      String table, Column[] columns, Criteria criteria)

   @Override public String generate()

}

 

public class SelectWithMatchSql extends Sql {

   public SelectWithMatchSql(

      String table, Column[] columns, Column column, String pattern)

   @Override public String generate()

}

 

public class FindByKeySql extends Sql

   public FindByKeySql(

      String table, Column[] columns, String keyColumn, String keyValue)

   @Override public String generate()

}

 

public class PreparedInsertSql extends Sql {

   public PreparedInsertSql(String table, Column[] columns)

   @Override public String generate() {

   private String placeholderList(Column[] columns)

}

 

public class Where {

   public Where(String criteria)

   public String generate()

}

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

Что не менее важно, когда придет время добавления update, вам не придется изменять ни один из существующих классов! Логика построения команды update реализуется в новом субклассе Sql с именем UpdateSql. Это изменение не нарушит работу другого кода в системе.

Переработанная логика Sql положительна во всех отношениях. Она поддерживает принцип единой ответственности. Она также поддерживает другой ключевой принцип проектирования классов в ООП, называемый принципом открытости/закрытости [PPP]: классы должны быть открыты для расширений, но закрыты для модификации. Наш переработанный класс Sql открыт для добавления новой функциональности посредством создания производных классов, но при внесении этого изменения все остальные классы остаются закрытыми. Новый класс UpdateSql просто размещается в положенном месте.

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

Изоляция изменений

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

Зависимости от конкретики создает проблемы при тестировании системы. Если мы строим класс Portfolio, зависящий от внешнего API TokyoStockExchange для вычисления текущей стоимости портфеля ценных бумаг, наши тестовые сценарии начинают зависеть от ненадежного внешнего фактора. Трудно написать тест, если вы получаете разные ответы каждые пять минут!

Вместо того чтобы проектировать Portfolio с прямой зависимостью от Tokyo­StockExchange, мы создаем интерфейс StockExchange, в котором объявляется один метод:

public interface StockExchange {

   Money currentPrice(String symbol);

}

Класс TokyoStockExchange проектируется с расчетом на реализацию этого интерфейса. При ссылке на StockExchange передается в аргументе конструктора Portfolio:

public Portfolio {

   private StockExchange exchange;

   public Portfolio(StockExchange exchange) {

      this.exchange = exchange;

   }

   // ...

}

Теперь наш тест может создать пригодную для тестирования реализацию интерфейса StockExchange, эмулирующую реальный API TokyoStockExchange. Тестовая реализация задает текущую стоимость каждого вида акций, используемых при тестировании. Если тест демонстрирует приобретение пяти акций Microsoft, мы кодируем тестовую реализацию так, чтобы для Microsoft всегда  возвращалась стоимость $100 за акцию. Тестовая реализация интерфейса StockExchange сводится к простому поиску по таблице. После этого пишется тест, который должен вернуть общую стоимость портфеля в $500:

public class PortfolioTest {

  private FixedStockExchangeStub exchange;

  private Portfolio portfolio;

 

  @Before

  protected void setUp() throws Exception {

    exchange = new FixedStockExchangeStub();

    exchange.fix("MSFT", 100);

    portfolio = new Portfolio(exchange);

  }

 

  @Test

  public void GivenFiveMSFTTotalShouldBe500() throws Exception {

    portfolio.add(5, "MSFT");

    Assert.assertEquals(500, portfolio.value());

  }

}

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

Сведение к минимуму логических привязок соответствует другому принципу проектирования классов, известному как принцип обращения зависимостей (DIP, Dependency Inversion Principle). По сути DIP гласит, что классы системы должны зависеть от абстракций, а не от конкретных подробностей.

Вместо того чтобы зависеть от подробностей реализации класса Tokyo­Stock­Exchange, наш класс Portfolio теперь зависит от интерфейса StockExchange. Интерфейс StockExchange представляет абстрактную концепцию запроса текущей стоимости акций. Эта абстракция изолирует класс от конкретных подробностей получения такой цены — в том числе и от источника, из которого берется реальная информация.

Литература

[RDD]: Object Design: Roles, Responsibilities, and Collaborations, Rebecca Wirfs-Brock et al., Addison-Wesley, 2002.

[PPP]: Agile Software Development: Principles, Patterns, and Practices, Robert C. Martin, Prentice Hall, 2002.

[Knuth92]: Literate Programming, Donald E. Knuth, Center for the Study of language and Information, Leland Stanford Junior University, 1992.

За более подробной информацией об этом принципе обращайтесь к [PPP].

Назад: 9. Модульные тесты
Дальше: 11. Системы