3 альтернативы инструкции Switch в Typescript

Как вы думаете, что не так с этим кодом? 

//...
const animal = new Animal();

switch(animal.getType()) {
  case IS_DOG:
    console.log("Woof!")
  break;
  case IS_CAT:
    console.log("Meow!")
  break;
}
//....

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

Применяя ее, вы создаете массивный блок кода, который должен выполняться последовательно, проверяя одно условие за другим. Синтаксис switch заключает в себе потенциальные проблемы: во-первых, можно по забывчивости не написать инструкцию break, а во-вторых, столкнуться со случайным выполнением нескольких операторов case. При наличии альтернатив стали бы вы использовать инструкцию switch? Вряд ли. Но какие же варианты есть в нашем распоряжении? 

В зависимости от языка программирования вам доступны различные возможности. Работая с TypeScript, а следовательно и с JavaScript, мы можем смешивать их и сочетать. Ниже будут представлены 3 способа, которые позволят обойтись без инструкций switch в коде.  

Объектные литералы 

Это самый простой и абсолютно естественный вариант для JavaScript и TypeScript. Вам не нужно дополнительных знаний, библиотек или сложных шаблонов проектирования. Потребуется лишь базовая конструкция  —  объектный литерал ({}).

Суть решения заключается в том, чтобы инкапсулировать логику внутри каждого блока case в отдельную функцию и затем проиндексировать их в объектном литерале. Да, все верно  —  “проиндексировать их”. Наш объектный литерал будет работать как индекс для данных блоков кода, и время поиска станет O(1), что послужит отличным дополнительным преимуществом. 

Вот что имеется в виду: 

const IS_DOG = "dog"
const IS_CAT = "cat"

class Animal {

    constructor(
        private type: string
    ){}

    getType(): string {
        return this.type
    }
    
    bark() {
        console.log("Woof!")
    }

    meow() {
        console.log("Meow!!!")
    }
}

function speak(animal: Animal) {
    const theIndex = {
        [IS_DOG]: animal.bark.bind(animal),
        [IS_CAT]: animal.meow.bind(animal)
    }

    theIndex[animal.getType()]()
}
//...
const animal = new Animal(IS_DOG);
speak(animal)

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

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

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

//...
function speak(animal: Animal) {
    const theIndex = {
        [IS_DOG]: animal.bark.bind(animal),
        [IS_CAT]: animal.meow.bind(animal)
    }

    theIndex[animal.getType()] ? theIndex[animal.getType()]() : console.log("unknown animal")
}
//..

Полиморфизм 

Если вы большой фанат ООП, то этот вариант придется вам более по вкусу. 

В рассматриваемой задаче мы пытаемся инкапсулировать различные версии одного и того же концептуального поведения, заставляя “animal” (животное) “говорить”. Оно может гавкать, мяукать, крякать или издавать другие требуемые звуки. И хотя для каждого случая, возможно, понадобится разный код, по сути все они делают одно и то же. 

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

Такой прием позволяет специализировать логику внутри каждого отдельного класса:

class Dog {
    speak() {
        console.log("Woof!")
    }
}

class Cat {
    speak() {
        console.log("Meow!")
    }
}

class Duck {
    speak() {
        console.log("Cuack!")
    }
}

const aDog = new Dog()
const aCat = new Cat()
const aDuck = new Duck()

aDog.speak()
aCat.speak()
aDuck.speak()

В чем же теперь загвоздка? Каждый объект требует разного подхода. Нельзя составить список animal и определить метод для вызова, если только у нас нет уверенности, что они принадлежат к одному типу. Мы сможем это сделать посредством класса более высокого порядка (например, используя наследование) или общего интерфейса. 

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

interface IAnimal {
    speak()
}

class Dog implements IAnimal{
    speak() {
        console.log("Woof!")
    }
}

class Cat implements IAnimal{
    speak() {
        console.log("Meow!")
    }
}

class Duck implements IAnimal{
    speak() {
        console.log("Cuack!")
    }
}

const animals:IAnimal[] = [new Dog(), new Cat(), new Duck()]

animals.forEach( animal => {
   animal.speak()
});

Обратим внимание на ряд моментов.

  • Интерфейс объявляет единую форму для всех классов (требуемый метод speak).
  • Теперь все классы реализуют одинаковый интерфейс IAnimal.
  • На данном этапе не важно, с каким animal мы имеем дело, можно просто попросить его заговорить. 

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

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

Обобщения 

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

Допустим, вы написали код своего первого решения и попали в тупиковую ситуацию: 

interface IsAnimal {
    speak()
}

type AnimalParams = {
    name: string,
    lives?: number
}

class Dog implements IsAnimal{

    constructor(
        private name: string
    ) {}

    speak() {
        console.log("Woof!")
    }
}

class Cat implements IsAnimal{

    constructor(
        private name: string,
        private lives: number
    ) {}

    speak() {
        console.log("Meow!")
    }
}

function animalCreator(type: string, params: AnimalParams): IsAnimal {
    switch(type) {
        case "dog": 
            return new Dog(params.name)
        break;
        case "cat": 
            return new Cat(params.name, params.lives)
        break;
    }
}

const myAnimal: IsAnimal = animalCreator("dog", { name: "John"})

Однако мы можем его улучшить, прибегнув к обобщениям (параметризованным типам). 

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

1.Преобразование зависящей от switch функции в обобщенную, которая будет создавать экземпляр любого предоставляемого типа animal, обходясь без инструкции switch

2. Поиск способа специализировать эту обобщенную конструкцию, который бы избавлял от необходимости добавления логики для выбора создаваемого типа animal. 

Второй этап отражает самую суть инструкции switch, и если нам удастся обойтись без нее, то и впредь она уже не понадобится. 

Первый этап реализуется путем преобразования animalCreator в:

function genericAnimalCreator<Type extends IsAnimal>(type: { new (...args): Type }, ...args): Type {
    return new type(...args)
}

Эта функция принимает “type” в качестве первого параметра, который по факту является классом (обратите внимание, что нужна возможность вызова метода new), и набор обобщенных атрибутов (например, часть ...args, также известных как остаточные атрибуты). 

Этого должно быть достаточно, но при ее использовании потребуется выполнить следующие действия: 

const myAnimal: IsAnimal = genericAnimalCreator(Cat, { name: "John", lives: 9})
myAnimal.speak()

///.... или вы также можно сделать 
const myAnimal: IsAnimal = genericAnimalCreator(Dog, { name: "John", lives: 9})
myAnimal.speak()

Все работает, но во втором примере мы создаем Dog (собаку), у которой 9 жизней (на самом деле этот параметр будет проигнорирован, но суть вы поняли). Наш код не может “принудительно установить” верный тип. 

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

interface GenericAnimalCreatorFn<Type> {
    (type: { new (...args): Type }, ...args): Type
}

const dogMaker: GenericAnimalCreatorFn<Dog> = genericAnimalCreator

И теперь выполняем: 

const myDog: IsAnimal = dogMaker(Dog, {name: "John"})

//однако мы не можем сделать 

const myDog: IsAnimal = dogMaker(Cat, {name: "Steven", lives: 9})

К данному моменту мы успешно избавились от потребности в инструкции switch. Больше не нужен большой набор операторов case для выбора класса, подлежащего инстанцированию. Теперь мы напрямую переходим в нужный класс. Но этого недостаточно (не при работе с JavaScript и TypeScript), поскольку есть возможность пойти дальше и избавиться от лишнего параметра Dog из dogMaker. У нас уже нет ни капли сомнения, что мы создаем dog. К тому же нет необходимости передавать его в качестве параметра. Такой ход также устраняет прямую зависимость этого класса от кода. 

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

interface GenericCurriedCreator<Type> {
    (...args): Type
}

function genericAnimalCreator<Type extends IsAnimal>(type: { new (...args): Type }, ...args): Type {
    return new type(...args)
}

//Определяем конкретных инициализаторов
const dogMaker: GenericCurriedCreator<Dog> = (params) => genericAnimalCreator(Dog, params)
const catMaker: GenericCurriedCreator<Cat> = (params) => genericAnimalCreator(Cat, params)

//Применяем их... 
const myAnimal: IsAnimal = dogMaker({ name: "John"})
myAnimal.speak()

const myCat: IsAnimal = catMaker({ name: "John", lives: 9})
myCat.speak()

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

Заключение 

Подведем итоги.

  1. Во многих случаях инструкция switch не является наилучшим решением. 
  2. Есть ситуации, когда ее применение в полной мере оправдано, но в 90% доступен более декларативный способ, позволяющий обойтись без нее. 

И в данной статье мы как раз рассмотрели 3 альтернативных подхода. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Fernando Doglio: Stop Using Switch in TypeScript — 3 Alternatives To Use Instead

Предыдущая статьяМашинное обучение с Amazon Aurora
Следующая статьяКак программирование избавляет от стресса