yegor256

View the Project on GitHub serge3ling/yegor256

Конструктори чи статичні фабричні методи?

(Оригінал цієї статті знаходиться тут.)

14 листопада 2017 р. Одеса, Україна

Думаю, першим це сказав Джошуа Блох у своїй чудовій книжці Effective Java: статичним фабричним методам треба віддавати перевагу перед конструкторами при інстанціюванні об’єктів. Я не згоден. Не тільки через моє переконання, що статичні методи є чисте зло, але більше тому, що в цьому частинному випадку вони прикидаються добрими і змушують нас думати, що ми їх маємо любити.

Extract (Image :copyright: Extract (2009) by Mike Judge)

Проаналізуймо доводи і погляньмо, чому вони хибні з об’єктно-орієнтованої точки зору.

Ось клас із одним типовим (primary) і двома додатковими конструкторами:

class Color {
  private final int hex;
  Color(String rgb) {
    this(Integer.parseInt(rgb, 16));
  }
  Color(int red, int green, int blue) {
    this(red << 16 + green << 8 + blue);
  }
  Color(int h) {
    this.hex = h;
  }
}

А це подібний клас із трьома фабричними методами:

class Color {
  private final int hex;
  static Color makeFromRGB(String rgb) {
    return new Color(Integer.parseInt(rgb, 16));
  }
  static Color makeFromPalette(int red, int green, int blue) {
    return new Color(red << 16 + green << 8 + blue);
  }
  static Color makeFromHex(int h) {
    return new Color(h);
  }
  private Color(int h) {
    this.hex = h;
  }
}

Як вважаєте, який ліпший?

Джошуа Блох наводить три головні переваги використання статичних фабричних методів замість конструкторів (взагалі-то чотири, але четверта більше не стосується мови Java):

Всі ці три переваги є дуже логічними … якщо дизайн неправильний. Вони підходять для виправдання кривих шляхів. Перегляньмо їх одну за одною.

Народження об’єкта конструктором є найбільш “священною” подією в будь-якому об’єктно-орієнтованому програмному забезпеченні, не нехтуйте цією красою!

Вони мають імена

Ось як ви створюєте об’єкт томатного кольору конструктором:

Color tomato = new Color(255, 99, 71);

А так ви це робите статичним фабричним методом:

Color tomato = Color.makeFromPalette(255, 99, 71);

Здається, makeFromPalette() — це семантично багатше, ніж просто new Color(), правда? Хто його знає, що це за три числа, передані конструктору. А от слово “palette” (“палітра”) допомагає все уявити негайно.

Це правда.

І все-таки правильно буде застосувати поліморфізм і інкапсуляцію, щоб розбити проблему на кілька семантично багатих класів:

interface Color {
}
class HexColor implements Color {
  private final int hex;
  HexColor(int h) {
    this.hex = h;
  }
}
class RGBColor implements Color {
  private final Color origin;
  RGBColor(int red, int green, int blue) {
    this.origin = new HexColor(
      red << 16 + green << 8 + blue
    );
  }
}

А тепер використовуємо правильний конструктор правильного класу:

Color tomato = new RGBColor(255, 99, 71);

Бачиш, Джошуа?

Їх можна кешувати

Нехай в мене використовується томатний колір в різних місцях мого додатку:

Color tomato = new Color(255, 99, 71);
// ... десь пізніше
Color red = new Color(255, 99, 71);

Будуть створені два об’єкти; очевидно, це неефективно, бо вони ідентичні. Ліпше зберегти перший десь у пам’яті і повернути його на другий виклик. Статичні методи дають можливість це вирішити:

Color tomato = Color.makeFromPalette(255, 99, 71);
// ... десь пізніше
Color red = Color.makeFromPalette(255, 99, 71);

Тоді ми десь в класі Color зберігаємо приватну статичну Map зі всіма вже інстанційованими об’єктами:

class Color {
  private static final Map<Integer, Color> CACHE =
    new HashMap<>();
  private final int hex;
  static Color makeFromPalette(int red, int green, int blue) {
    final int hex = red << 16 + green << 8 + blue;
    return Color.CACHE.computeIfAbsent(
      hex, h -> new Color(h)
    );
  }
  private Color(int h) {
    return new Color(h);
  }
}

Для швидкодії це — дуже ефективно. З малим об’єктом, як наш Color, проблема не дуже очевидна, але коли об’єкти більші, їх інстанціація і збирання сміття забере багато часу.

Це правда.

І все-таки є об’єктно-орієнтований спосіб розв’язати цю проблему. Просто вводимо новий клас Palette, який стає сховищем кольорів:

class Palette {
  private final Map<Integer, Color> colors =
    new HashMap<>();
  Color take(int red, int green, int blue) {
    final int hex = red << 16 + green << 8 + blue;
    return this.computeIfAbsent(
      hex, h -> new Color(h)
    );
  }
}

Тепер ми один раз створюємо один примірник Palette і просимо його повертати нам колір кожного разу, коли нам треба:

Color tomato = palette.take(255, 99, 71);
// Пізніше отримаємо той же примірник:
Color red = palette.take(255, 99, 71);

Бачиш, Джошуа, без статичних методів, без статичних атрибутів.

Вони мають підтип

Нехай наш клас Color має метод lighter(), який повинен повернути перший з доступних світліших кольорів:

class Color {
  protected final int hex;
  Color(int h) {
    this.hex = h;
  }
  public Color lighter() {
    return new Color(hex + 0x111);
  }
}

Але деколи бажано вибрати наступний світліший колір з множини стандарту Pantone:

class PantoneColor extends Color {
  private final PantoneName pantone;
  PantoneColor(String name) {
    this(new PantoneName(name));
  }
  PantoneColor(PantoneName name) {
    this.pantone = name;
  }
  @Override
  public Color lighter() {
    return new PantoneColor(this.pantone.up());
  }
}

Тоді ми створюємо статичний фабричний метод, який буде вирішувати, яка імплементація нам потрібна:

class Color {
  private final String code;
  static Color make(int h) {
    if (h == 0xBF1932) {
      return new PantoneColor("19-1664 TPX");
    }
    return new RGBColor(h);
  }
}

Якщо потрібен справжній червоний колір, повертаємо примірник PantoneColor. У всіх інших випадках це буде просто звичайний RGBColor. Рішення приймає статичний фабричний метод. Викликаємо його так:

Color color = Color.make(0xBF1932);

Це “розгалуження” неможливо буде зробити конструктором, бо він може повернути тільки примірник класу, в якому оголошений. А статичний метод має повну свободу, щоб повертати будь-який підтип класу Color.

Це правда.

І все-таки в об’єктно-орієнтованому світі ми можемо і мусимо робити це все по-іншому. Спочатку Color робимо інтерфейсом:

interface Color {
  Color lighter();
}

Далі переносимо прийняття рішення в окремий клас Colors, як і в попередньому прикладі:

class Colors {
  Color make(int h) {
    if (h == 0xBF1932) {
      return new PantoneColor("19-1664-TPX");
    }
    return new RGBColor(h);
  }
}

І використовуємо примірник класу Colors замість статичного фабричного методу всередині класу Color:

colors.make(0xBF1932);

Але і це ще не зовсім об’єктно-орієнтоване мислення, бо ми забрали прийняття рішення з об’єкту, якому воно належить. Чи то статичним фабричним методом make(), чи новим класом Colors — неважливо як — ми розриваємо наші об’єкти на дві частини. Перша — сам об’єкт, друга — алгоритм прийняття рішення, який знаходиться десь зовні.

(Кілька думок щодо конструкторів в ООП (вебінар #7); 7 жовтня 2015 р.)

Набагато кращим об’єктно-орієнтованим дизайном буде помістити логіку в об’єкт класу PantoneColor, а він буде декоратором оригінального RGBColor:

class PantoneColor {
  private final Color origin;
  PantoneColor(Color color) {
    this.origin = color;
  }
  @Override
  public Color lighter() {
    final Color next;
    if (this.origin.hex() == 0xBF1932) {
      next = new RGBColor(0xD12631);
    } else {
      next = this.origin.lighter();
    }
    return new PantoneColor(next);
  }
}

Тоді створюємо примірник RGBColor і декоруємо його примірником PantoneColor:

Color red = new PantoneColor(
  new RGBColor(0xBF1932)
);

Ми просимо red повернути світліший колір, і він повертає світліший з палітри Pantone, а не просто світліший за мірками RGB:

Color lighter = red.lighter(); // 0xD12631

Звичайно, це простенький приклад, і його треба ще покращувати, якщо ми справді хочемо застосовувати його до всіх кольорів Pantone, але ви вже, мабуть, зловили думку. Логіці місце всередині класу, не зовні де-небудь, не в статичних фабричних методах або навіть в іншому допоміжному класі. Я говорю про логіку, яка належить до цього конкретного класу, звичайно. Якщо це якось стосується керування примірниками класу, то можна впровадити контейнери і сховища, як у вищенаведеному прикладі.

Підсумуємо: я б настійно радив ніколи не використовувати статичні методи, особливо якщо вони покликані замінити конструктори об’єктів. Народження об’єкта конструктором є найбільш “священною” подією в будь-якому об’єктно-орієнтованому програмному забезпеченні, не нехтуйте цією красою.