Оглавление

  1. Введение

  2. Немного про утиную типизацию

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

  4. Наследование

  5. Полиморфизм

  6. Итоги

Введение

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

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

Готовы? Поехали!

Немного про утиную типизацию

Вы можете продолжить изучать код сами, скопировав и вставив код в TypeScript Playground. Цель состоит в том, чтобы вы смогли воспроизвести следующий код, чтобы доказать, что эти методы работают в любой кодовой базе.

Взгляните на это:

interface Todo {
  title: string;
  description?: string;
}
 
const todo1 = {
  title: "organize desk",
  extra: "metadata", // duck typing is allowed!
};
 
const updateTodo = (
  todo: Todo,
  fieldsToUpdate: Partial<Todo> // allow partial updates
) => ({ ...todo, ...fieldsToUpdate });
 
const result1 = updateTodo(todo1, {
  description: "throw out trash",
});
 
const todo2 = {
  ...todo1,
  description: "clean up", // call bombs without description
};
 
const updateRequiredTodo = (
  todo: Required<Todo>,
  fieldsToUpdate: Partial<Todo>
): Required<Todo> => ({ ...todo, ...fieldsToUpdate });
 
const result2 = updateRequiredTodo(todo2, {
  description: "throw out trash",
});

Интерфейс Todo объявлен с необязательным свойством description, поэтому этот код может пропустить свойство. Вопросительный знак ? сообщает TypeScript, что это необязательно. Один из способов отменить это дизайнерское решение - обернуть интерфейс вокруг Required<Todo> , что делает все свойства обязательными. В классическом ООП важна целостность данных объектов. Здесь компилятор автоматизирует это поведение.

Если цель состоит в том, чтобы разрешить частичные обновления, то Partial<Todo> поможет вернуться к необязательным свойствам. Это распространяется только на параметр fieldsToUpdate . Функция updateRequiredTodo явно объявляет тип вывода Required<Todo> , гарантируя тип возвращенного объекта.

Обратите внимание, что свойство extra принимается в todo1. Это наследие того факта, что TypeScript является надмножеством JavaScript, и возможна утиная типизация. Без явного указания типа у TypeScript нет другого выбора, кроме как вернуться к устаревшему. По мере того, как вы пишете все больше программ на TypeScript, рекомендуется максимально полагаться на средство проверки типов с помощью явных типов.

Теперь давайте рассмотрим три столпа объектно-ориентированного программирования.

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

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

class Base {
  private hiddenA = 0;
  #hiddenB = 0;
 
  printInternals() {
    console.log(this.hiddenA); // works
    console.log(this.#hiddenB); // works
  }
}
 
const obj = new Base();
console.log(obj.hiddenA); // these two bomb
console.log(obj.#hiddenB);

Здесь поле hiddenA имеет ограниченный доступ в пределах Base, принудительно применяемый компилятором. Однако компилятор предоставляет JS'у конструкциюin, которая даёт доступ даже к приватным полям. Вы можете использовать жесткий приватный # чтобы не допустить этого.

Класс - не единственный инструмент, доступный в вашем арсенале TypeScript. Утилитарные типы, такие как Pick<Type, Keys>, Omit<Type, Keys>, Readonly<Type> и NonNullable<Type> так же могут ограничивать доступы.

interface Todo {
  title: string;
  description?: string; // string | undefined
  completed: boolean;
}
 
type TodoPreview1 = Pick<Todo, "title" | "completed">;
 
const todo1: TodoPreview1 = {
  //explicit typing
  title: "Clean room",
  completed: false,
  description: "x", // duck typing is NOT allowed
};
 
type TodoPreview2 = Omit<Todo, "description" | "completed">;
 
const todo2: TodoPreview2 = {
  title: "Clean room",
};
 
const todo3: Readonly<Todo> = {
  title: "Delete inactive users",
  completed: true,
};
 
todo3.completed = false; // bombs
 
const todo4: Todo = {
  ...todo1,
  description: "Doing shores is fun",
};
 
const description: NonNullable<string | undefined> =
  // bombs without null coalescing
  todo4.description ?? "";

Указание типов с помощью Pick или Omit вырезают подмножество свойств. Если объект введен явно, то ввод утиная типизация не допускается. Этот метод разбивает типы на части и кубики и сужает доступную компилятору информацию.

Неизменяемость достижима с помощью Readonly. Любая попытка изменить объект автоматически завершается сбоем сборки. Если вам нужна защита во время выполнения, доступен также Object.freeze. Ограничивая объекты, которые могут изменять состояние, вы обеспечиваете инкапсуляцию, защищая взаимодействия между типами.

Все это время вы, возможно, предполагали, что undefined или null являются естественным следствием работы с ООП, поскольку разрешены типы с нулевым значением. Что ж, предлагать nullable объекты - это все равно что продавать уличные манго, а потом говорить людям, что вы никогда не собирались их носить! (От автора перевода: возможно какое-то устойчивое выражение, "is like selling street mangos, then telling people you never intended to carry any").

null - это отсутствие объекта, и поэтому он диаметрально противоположен объектно-ориентированному подходу. NonNullable значение помогает вам быть честным в отношении типов, чтобы не было непредвиденных неудач.

Наследование

Наследование представляет собой иерархию типов вида is-a(Sonar is a Pingable). Эта техника может отражать отношения в реальном мире. Допустим, есть Pingable интерфейс с методом ping. Чтобы сонар был доступен для пинга, он должен реализовать это поведение. Это облегчает рассуждения о сонаре, поскольку он моделирует определенную функциональность.

interface Pingable {
  ping(): void;
}
 
class Sonar implements Pingable {
  ping() {
    console.log("sonar ping!");
  }
}

TypeScript также предлагает более причудливые способы достижения наследования. Учитывая два типа, скажем, Colorful и Circle, вы можете комбинировать заданные свойства интересными способами с помощью объединения и пересечения.

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

type Colorful = {
  color: string;
};
 
type Circle = {
  radius: number;
};
 
type ColorfulCircle = Colorful | Circle; // union
 
function isCircle(circle: ColorfulCircle): circle is Circle {
  return "radius" in circle;
}
 
function draw(circle: ColorfulCircle) {
  if (isCircle(circle)) {
    // branch logic
    console.log(`Radius was ${circle.radius}`); // ok
  } else {
    console.log(`Color was ${circle.color}`);
  }
}
 
draw({ color: "blue" });
draw({ radius: 42 });

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

type Colorful = {
  color: string;
};
 
type Circle = {
  radius: number;
};
 
type ColorfulCircle = Colorful & Circle; // intersection
 
function draw(circle: ColorfulCircle) {
  console.log(`Radius was ${circle.radius}`); // ok
  console.log(`Color was ${circle.color}`);
}
 
draw({ color: "blue", radius: 42 });

С объектом this в JavaScript может быть сложно работать, потому что он меняется в зависимости от контекста. TypeScript динамически вводит это в класс и имеет защиту типов.

abstract class Box {
  content: string = "";
 
  sameAs(other: this) {
    return other.content === this.content;
  }
 
  isDerivedBox(): this is DerivedBox {
    return this instanceof DerivedBox;
  }
}
 
class DerivedBox extends Box {
  otherContent: string = "?";
}
 
const base = new Box(); // bombs
const derived = new DerivedBox();
derived.isDerivedBox(); // true
derived.sameAs(derived as Box); // bombs

Обратите внимание, что тип this is DerivedBox защищает возвращаемый тип в методе isDerivedBox. Смешивание этого с сужением типа с помощью instanceof делает этот тип предсказуемым, а не движущейся целью.

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

Полиморфизм

Вы можете добиться ad hoc ("специального") полиморфизма, используя аргументы, которые ведут себя по-разному в зависимости от типа. Давайте рассмотрим метод add, который работает в общем виде и соответствующим образом изменяет поведение.

interface GenericAdd<AddType> {
  add: (x: AddType, y: AddType) => AddType;
}
 
class GenericNumber implements GenericAdd<number> {
  add(x: number, y: number) {
    return x + y;
  } // number + number
}
 
class GenericString implements GenericAdd<string> {
  add(x: string, y: string) {
    return x + y;
  } // string + string
}
 
const genericNumber = new GenericNumber();
genericNumber.add(1, 2); // 3
 
const genericString = new GenericString();
genericString.add("Hello", ", Mammals!"); // Hello, Mammals!

Если вы думаете о типе как о Record, то возможно иметь row-polymorphic Record с кодом, который работает только с разделением типа. TypeScript упрощает это с помощью Partial. Обратите внимание, что подмножество вводится явно, а утиная типизация не допускается, поскольку beta не является подмножеством AType.

const subset: Partial<AType> = {
  x: 2,
  y: 3,
  beta: "bomb", // not allowed
};

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

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

interface Coffee {
  getCost(): number;
  getIngredients(): string;
}
 
class SimpleCoffee implements Coffee {
  getCost() {
    return 8;
  }
 
  getIngredients() {
    return "Coffee";
  }
}
 
abstract class CoffeeDecorator implements Coffee {
  constructor(private readonly decoratedCoffee: Coffee) {}
 
  getCost() {
    return this.decoratedCoffee.getCost();
  }
 
  getIngredients() {
    return this.decoratedCoffee.getIngredients();
  }
}
 
class WithMilk extends CoffeeDecorator {
  constructor(private readonly c: Coffee) {
    super(c);
  }
 
  getCost() {
    return super.getCost() + 2.5;
  }
 
  getIngredients() {
    return super.getIngredients() + ", Milk";
  }
}
 
class WithSprinkles extends CoffeeDecorator {
  constructor(private readonly c: Coffee) {
    super(c);
  }
 
  getCost() {
    return super.getCost() + 1.7;
  }
 
  getIngredients() {
    return super.getIngredients() + ", Sprinkles";
  }
}
 
class WithSugar extends CoffeeDecorator {
  constructor(private readonly c: Coffee) {
    super(c);
  }
 
  getCost() {
    return super.getCost() + 1;
  }
 
  getIngredients() {
    return super.getIngredients() + ", Sugar";
  }
}

Интерфейс Coffee работает как контракт, последовательно используемый во всем коде, и моделирует объект реального мира. Например, чашка кофе содержит некоторые ингредиенты и стоит денег. Начальная цена составляет 8 долларов США, исходя из валюты США и с поправкой на инфляцию, установленную в SimpleCoffee.

Шаблон декоратора является хорошим примером принципа подстановки Лискова, потому что все подтипы CoffeeDecorator придерживаются этого же контракта. Это делает код более предсказуемым и интуитивно понятным. Поскольку TypeScript хорошо справляется с обеспечением реализации контрактов подклассами, разработчикам сложнее скрывать странное поведение в странных местах.

class Barista {
  constructor(private readonly cupOfCoffee: Coffee) {}
 
  orders() {
    this.orderUp(this.cupOfCoffee);
    let cup: Coffee = new WithMilk(this.cupOfCoffee);
    this.orderUp(cup);
    cup = new WithSugar(cup);
    this.orderUp(cup);
    cup = new WithSprinkles(cup);
    this.orderUp(cup);
  }
 
  private orderUp(c: Coffee) {
    console.log(
      "Cost: " + c.getCost() + "; Ingredients: " + c.getIngredients()
    );
  }
}
 
const barista = new Barista(new SimpleCoffee());
barista.orders();

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

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

Итоги

В этом посте я рассмотрел три столпа объектно-ориентированного программирования — инкапсуляцию, наследование и полиморфизм, а также представил утиную типизацию.

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

Счастливого кодинга!

Комментарии (2)


  1. Hrodvitnir
    23.05.2022 09:21
    +1

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

    Статья ни про TS, ни про ООП, ни про паттерны.


  1. kenbekov
    23.05.2022 10:26
    +4

    Очень тяжело читать подстрочный перевод. К тому же смысл некоторых предложений отличается от оригинала. Так в переводе:

    Один из способов отменить это дизайнерское решение - обернуть интерфейс вокруг Required<Todo> , что делает все свойства необязательными

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

    One way to revert this design decision is to wrap the interface around Required<Todo>, which makes all properties non-optional.

    Т.е. наоборотRequiredделает все свойства обязатными (что в общем то логично исходя из названия типа). Вот еще пример в переводе:

    Однако компилятор предоставляет JS'у конструкции, подобные доступу к скрытому свойству. Вы можете использовать жесткий приватный # для поддержания приватных полей.

    Тут вообще смысл написанного ускользает. Обратимся к оригиналу:

    However, the compiler gives JavaScript runtime constructs like in access to the hidden property. can use the hard private # to maintain private fields.

    Т.е. автор имел в виду что несмотря на то, что поле объекта приватное, конструкция JS in все равно будет иметь доступ к нему (чего я, кстати, не знал). Чтобы этого не происходило, можно использовать #.

    Далее по тексту еще несколько неточностей и калькирования английской грамматики. Но все равно спасибо за статью. Оригинал прочитал с интересом.