ONE DUDE`S BLOG

/media/base_b5d9f8d555.webp

Почему вам не нужен оператор switch/case

23.08.2020
Как ни странно, программисты пишут программу для программистов, а не для машин, поэтому код должен быть чистым, лаконичным и простым

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

Уверен что многие, начиная программировать приходят к мысли что это просто. Куда меньше разработчиков перешагивают эту идею и осознают что это очень сложно. Сложно писать не просто работающие программы, а поддерживаемые, продуманные, те которые можно читать как хорошо написанную книгу. Пожалуй я не открою Америку, сказав что программист ~90% времени читает код, и только оставшиеся 10 пишет. Это вполне логично, таким образом подытожим 1 аксиому, хороший код — код который легко читать, код написанный для людей, но не для машин. Это достаточно тонкое понимание, и к нему нужно прийти.

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

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

Итак, возвращаясь к основному топику, чем же плох switch case? (конечно же на мой субъективный взгляд).

В чем же проблема switch/case и есть ли она вообще?

  1. Данный оператор создает дополнительный уровень вложенности (разумеется не во всех языках). Чем больше вложенность мы имеем, тем сложнее читать, и самое главное, понимать код. Рассмотрим небольшой пример
switch (vehicle) {
  case vehicle.type === 'car':
    if (vehicle.hasBenzin && vehicle.isWork) {
      vehicle.ride();
    } else {
      if (vehicle.hasInsuarence) {
        vehicle.support.call()
      }
    }
  case vehicle.type === 'bicycle':
    if (vehicle.isWork) {
      vehicle.ride()
    } else {
      if (vehicle.couldRecover) {
        vehicle.recover()
      } else {
        vehicle.writeOff()
      }
    }
}

В данном примере проблема не только в switch case, однако, я очень часто встречаю подобный код (с гораздо большим ветвлением), в следующей статье по условным операторам, я постараюсь рассказать про минусы else.

  1. Данный подход потенциально нарушает принцип единой ответственности (Signle Responsability, пример выше). Разумеется мы можем вызывать функции в каждом из кейсов, эту проблему мы решить можем. Так в чем же проблема? Проблема в том что этот код будет являться потенциальным местом, где существует возможность написать код (это можно сделать как из-за недостаточно квалификации, так и в случае когда поджимают сроки) сделав тем самым код, более громоздким и менее читабельным. Чаще всего мы работаем в команде, в команде где у каждого члена свое понимание, свой уровень и свой опыт разработки. Да, разумеется, это будет видно на ревью, но зачем это лишнее звено? Если его можно просто избежать.

  2. Данный пункт вытекает из предыдущего - тестирование. Гораздо проще протестировать каждый отдельный кейс в виде функции, чем написать 10 тестов для всего свитчкейса, попутно пропустив парочку.

  3. break? Нужен? Не нужен? Я свято верю в то что комментарии в коде (не путать с паблик методами sdk и описанием формул) является злом, подробнее про это можно прочитать в "Чистом коде" Роберта Мартина. Если вкратце, то комментарии устаревают.., код меняется, а они, чаще всего нет. Проблема оператора break в том что непонятно, разработчик просто забыл его написать? Или это предполагаемое поведение. Да комментарий решает эту проблему, но комментариям не стоит доверять.

Небольшой пример как убить абстрактного человечка(

class Pedestrian {

  public crossTheRoad(): void {/* impl */ }

  public wait(): void { /* impl */ }

  public beCareful(): void { /* impl */ }

  public lookAtLights(): void { /* impl */ }

  public tryToRun(): void { console.log('accident') }

}

const p = new Pedestrian()

enum Color {
  RED = 'red',
  YELLOW = 'yellow',
  GREEN = 'green',
}

let a: any = Color.RED;


switch (a) {
  case Color.YELLOW:
    p.beCareful();
    break;
  case Color.GREEN:
    p.crossTheRoad();
  case Color.RED:
    p.wait();
        // Разработчик специально не поставил break? Или это было сделано осознано
  default:
    p.tryToRun();
}

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

  1. Данная проблема тесно связана с предыдущим пунктом, если мы говорим про инструкцию break, мы должны понимать как устроен язык (в некоторых языках этот оператор вызывется по дефолту). И, впринципе, это не большая проблема, однако, учитывая разный уровень разработчиков, это еще 1 потенциальное место где можно поймать непредвиденное поведение. Код должен быть максимально тупым, чтобы любой смог его прочитать не особо задумываясь, в случае с break мы будем вынуждены думать об этом.

Как сделать код лаконичнее

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

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

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

class Pedestrian {

  public crossTheRoad(): void {/* impl */ }

  public wait(): void { /* impl */ }

  public beCareful(): void { /* impl */ }

  public lookAtLights(): void { /* impl */ }

  public tryToRun(): void { console.log('accident') }

}

const p = new Pedestrian()

enum Color {
  RED = 'red',
  YELLOW = 'yellow',
  GREEN = 'green',
}

let a: any = Color.RED;

const actions: Record<Color, (p: Pedestrian) => any> = {
  [Color.RED]: p => p.wait(),
  [Color.GREEN]: p => p.crossTheRoad(),
  [Color.YELLOW]: p => p.beCareful(),
}

actions[a] ? actions[a](p) : p.wait();

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

Хорошо, с простым примером вроде все более-менее понятно, давайте рассмотрим пример посложнее, с множественными условиями.

Реализация со свитч кейсом выглядела бы примерно так:

const v = 1*Math.round(Math.random() * 10)

switch (true) {
  case v % 2 === 0 && v < 6:
    console.log('V is even and less then 6')
    break;
  case v === 10:
    console.log('its 10')
    break;
  case v % 2 === 1 && v > 5:
    console.log('V is odd and greatter then 5')
  default:
    console.log('something else')
}

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

function getMessageFromN(n: number): void {
  if (v % 2 === 0 && v < 6) {
    console.log('V is even and less then 6');
    return;
  }
  if (v === 10) {
    console.log('its 10')
    return;
  }
  if (v % 2 === 1 && v > 5) {
    console.log('V is odd and greatter then 5')
    return;
  }
  console.log('something else')
}

Из примера видно, что мы избавились от еще 1 уровня вложенности, однако остальные проблемы остались. Мы имеем потенциальное место для расширения кода внутри условных операторов. Попробуем сделать лучше:

function isTen(n: number): boolean {
  return n === 10;
}

function isOddAndLessThen6(n: number): boolean {
  return v % 2 === 0 && v < 6;
}

function isEvenAdnGreatThen5(n: number): boolean {
  return v % 2 === 1 && v > 5;
}

const compares = [
  { method: isTen, action: () => console.log('Its ten') },
  { method: isOddAndLessThen6, action: () => console.log('V is even and less then 6') },
  { method: isEvenAdnGreatThen5, action: () => console.log('V is odd and greatter then 5') },
  // default
  { method: () => true, action: () => console.log('something else') }
]

function getMessageFromN(n: number): void {
  compares.every((c) => {
    if (c.method(n)) {
      c.action();
      return false;
    }
    return true;
  })
}

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

P.S. Также хотел бы отметить 1 нюанс, в языке python отсутствует оператор switch/case, и сделано это не случайно. Тем не менее, разработчики успешно пишут без него, избегая каких-либо проблем.

Чистый код
0
1810