Парадигмы программирования

Моё кунг-фу сильнее твоего кунг-фу (из разговора программистов).

Время чтения: 11 мин

Кратко

Скопировано

Программировать можно по-разному. Набор приёмов и понятий, которые определяют «как писать», называют парадигмой.

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

Программа — это инструкция. Когда вы объясняете другу, как к вам доехать, вы, в принципе, программируете.

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

Например:

«Выходи на Александра Невского, сверни налево и иди до перекрёстка, там перейдёшь дорогу, свернёшь налево и пройдёшь до 38 дома, обойди дом, так как вход со двора, дойди до 2 подъезда слева и набери 2468, поднимись на четвёртый этаж, тридцать третья квартира.

Или:

«Адрес: ул. Свободы, д. 38, кв. 33, домофон 2468.»

Обе инструкции — об одном и том же, но в них есть одно значительное отличие.

Стили объяснения

Скопировано

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

Такой стиль объяснения (или программирования) называется императивным.

Вторая же инструкция объясняет, что мы должны получить в результате — где друг должен оказаться. Нам не особо важно, как он или она туда доберётся, важно — куда.

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

Императивный стиль

Скопировано

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

        
          
          function onlyOdd(array) {  const result = []  for (const element of array) {    if (element % 2 !== 0) {      result.push(element)    }  }  return result}
          function onlyOdd(array) {
  const result = []

  for (const element of array) {
    if (element % 2 !== 0) {
      result.push(element)
    }
  }

  return result
}

        
        
          
        
      

Заметьте, как мы строим эту функцию. Мы как бы говорим:

  • сперва присвой переменной result значение [];
  • затем пройдись по каждому элементу в массиве array;
    • проверь, что значение этого элемента нечётное;
    • если это так, добавь этот элемент в result;
  • после — верни result.

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

Декларативный стиль

Скопировано

Теперь попробуем ту же функцию написать декларативно, то есть не вдаваясь в детали:

        
          
          function onlyOdd(array) {  return array.filter((element) => element % 2 !== 0)}
          function onlyOdd(array) {
  return array.filter((element) => element % 2 !== 0)
}

        
        
          
        
      

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

Разница между подходами

Скопировано

Разница между этими подходами — в деталях реализации. В первом случае детали описываем мы сами, во втором они от нас скрыты.

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

Человеческий мозг может держать одновременно в памяти ограниченное количество объектов. Если мы работаем с какой-то сложной системой, нам будет трудно одновременно помнить и о том, что она делает, и о том, как она устроена.

Императивный стиль смешивает назначение программы и детали её реализации, в то время как декларативный старается описывать только назначение.

Плюсы и минусы зависят от контекста:

  • Если вам нужно детально описать какое-то действие — например при разработке конкретного алгоритма — то больше подходит императивный подход;
  • Если вы работаете на уровне бизнес-логики, то лучше писать декларативно, а детали реализации скрыть в более низком уровне абстракций.

Парадигмы императивного стиля

Скопировано

Стили программирования развивались много лет, и за это время в каждом появились практики и приёмы, которые со временем стали парадигмами.

Процедурное программирование

Скопировано

Функция onlyOdd(), которую мы написали ранее в императивном стиле, как раз похожа на одну из парадигм — процедурное программирование.

Это парадигма, в которой последовательные команды собираются в подпрограммы.

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

        
          
          /*  Как пример мы можем рассмотреть программу,    которая использует «подпрограммы» (в нашем случае функции),    меняя состояние памяти (в нашем случае простой массив битов).    Состояние памяти потом может быть использовано,    например, для работы с каким-то устройством. */let memory = [0, 0, 0, 0, 0, 0, 0, 0]function invertSmallestBit() {  memory[7] = Number(!memory[7])  return memory}function invertBiggestBit() {  memory[0] = Number(!memory[0])  return memory}invertSmallestBit()// [0, 0, 0, 0, 0, 0, 0, 1]invertBiggestBit()// [1, 0, 0, 0, 0, 0, 0, 1]invertSmallestBit()// [1, 0, 0, 0, 0, 0, 0, 0]
          /*  Как пример мы можем рассмотреть программу,
    которая использует «подпрограммы» (в нашем случае функции),
    меняя состояние памяти (в нашем случае простой массив битов).

    Состояние памяти потом может быть использовано,
    например, для работы с каким-то устройством. */

let memory = [0, 0, 0, 0, 0, 0, 0, 0]

function invertSmallestBit() {
  memory[7] = Number(!memory[7])
  return memory
}

function invertBiggestBit() {
  memory[0] = Number(!memory[0])
  return memory
}

invertSmallestBit()
// [0, 0, 0, 0, 0, 0, 0, 1]

invertBiggestBit()
// [1, 0, 0, 0, 0, 0, 0, 1]

invertSmallestBit()
// [1, 0, 0, 0, 0, 0, 0, 0]

        
        
          
        
      

Из подпрограмм появилось понятие модулей, но само по себе процедурное программирование не очень удобно в плане переиспользования кода.

Объектно-ориентированное программирование

Скопировано

Самая популярная парадигма императивного стиля — ООП. Это настолько большая тема, что мы собрали о ней отдельный лонгрид.

ООП (объектно-ориентированное программирование) — парадигма, в которой сущности в программе представляются в виде объектов.

Каждый объект — экземпляр какого-то класса, некой абстрактной сущности, в которой описано поведение.

        
          
          /*  Основные понятия ООП: классы и экземпляры классов.    Класс можно воспринимать как чертёж,    по которому создаются объекты.    Экземпляр класса — это созданный по чертежу объект. *//*  Класс User описывает, какие поля (name, admin)    и методы (isAdmin, nameOf) будет содержать созданный    по этому классу объект. */class User {  constructor(name) {    this.name = name    this.admin = false  }  isAdmin() {      return this.admin  }  nameOf() {      return this.name  }}/*  Объекты создаются с помощью new.    Свежесозданный объект содержит всё,    что было описано в классе User. */const user = new User('Alex')console.log(user.isAdmin())// falseconsole.log(user.nameOf())// 'Alex'/*  Плюс классов в том, что они позволяют    единожды описать все одинаковые поля и методы,    которые должны быть у однотипных объектов. */const anotherUser = new User('Alice')console.log(anotherUser.isAdmin())// falseconsole.log(anotherUser.nameOf())// 'Alice'
          /*  Основные понятия ООП: классы и экземпляры классов.
    Класс можно воспринимать как чертёж,
    по которому создаются объекты.
    Экземпляр класса — это созданный по чертежу объект. */

/*  Класс User описывает, какие поля (name, admin)
    и методы (isAdmin, nameOf) будет содержать созданный
    по этому классу объект. */

class User {
  constructor(name) {
    this.name = name
    this.admin = false
  }

  isAdmin() {
      return this.admin
  }

  nameOf() {
      return this.name
  }
}

/*  Объекты создаются с помощью new.
    Свежесозданный объект содержит всё,
    что было описано в классе User. */

const user = new User('Alex')
console.log(user.isAdmin())
// false
console.log(user.nameOf())
// 'Alex'

/*  Плюс классов в том, что они позволяют
    единожды описать все одинаковые поля и методы,
    которые должны быть у однотипных объектов. */

const anotherUser = new User('Alice')
console.log(anotherUser.isAdmin())
// false
console.log(anotherUser.nameOf())
// 'Alice'

        
        
          
        
      

ООП характеризуется 4 основными аспектами:

  • Абстракцией — выделением таких характеристик объекта, которые достаточно точно описывают его поведение, но не вдаются в детали;
  • Инкапсуляцией — размещением данных внутри того объекта, который их использует;
  • Полиморфизмом — умением работать с разными типами объектов или данных;
  • Наследованием — умением объекта «забирать по наследству» свойства или характеристики от объектов-родителей.

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

Плюсы ООП

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

Большая часть энтерпрайз-инструментов для моделирования и документации (UML, DFD, IDEF, Entity-relations) основана именно на объектно-ориентированном представлении систем.

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

Минусы ООП

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

С чтением проблем обычно нет, но если какие-то объекты хотят данные изменить, то мы наткнёмся на проблему конкурентных вычислений.

Представим, что Google Docs спроектированы без учёта этой проблемы. Если два пользователя одновременно правят один и тот же документ, то правки одного могли бы затирать правки другого. Не круто.

Но это минус не только ООП, а вообще любой парадигмы, в которой есть общая память или общие данные.

Ещё одна проблема — это наследование. Простое наследование не всегда полностью отражает отношения компонентов.

Например, чайник с таймером должен наследоваться от чайника или от таймера? Хороший ответ — от того и от другого (a.k.a множественное наследование). Правильный ответ — наследованию стоит предпочесть композицию.

Парадигмы декларативного стиля

Скопировано

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

Логическое программирование

Скопировано

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

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

Логическое программирование часто используется для моделирования процессов.

Для примера нам бы понадобилось тащить сюда математическую модель для него, а это надолго. Поэтому приводить примеры мы, пожалуй, не станем :–)

Функциональное программирование

Скопировано

Самая известная парадигма декларативного стиля — функциональное программирование.

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

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

Побочный эффект — это какое-либо изменение внешней среды.

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

        
          
          // Эта функция чистая:function double(x) {  return x * 2}/*  При одинаковых вызовах она    всегда возвращает одинаковый результат. */double(2)// 4double(2)// 4double(2)// 4// Следующая функция нечистая:let x = 1function double() {  x *= 2  return x}/*  Она меняет (или мутирует) переменную x,    которая находится снаружи области видимости функции. */double()// 2double()// 4double()// 8// Эта функция — тоже нечистая:function double() {  return x * 2}/*  Она зависит от переменной    из области видимости снаружи функции. */// При одинаковых вызовах ответ может быть разным:x = 1double()// 2x = 2double()// 4
          // Эта функция чистая:

function double(x) {
  return x * 2
}

/*  При одинаковых вызовах она
    всегда возвращает одинаковый результат. */

double(2)
// 4
double(2)
// 4
double(2)
// 4

// Следующая функция нечистая:

let x = 1

function double() {
  x *= 2
  return x
}

/*  Она меняет (или мутирует) переменную x,
    которая находится снаружи области видимости функции. */

double()
// 2
double()
// 4
double()
// 8

// Эта функция — тоже нечистая:

function double() {
  return x * 2
}

/*  Она зависит от переменной
    из области видимости снаружи функции. */

// При одинаковых вызовах ответ может быть разным:

x = 1
double()
// 2

x = 2
double()
// 4

        
        
          
        
      

Плюсы ФП

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

Кроме этого чистые функции отлично тестируются, потому что не требуют сложной настройки теста. Мы прекрасно видим, что функции потребуется для проверки, потому что всё находится в списке аргументов.

Минусы ФП

Первый минус — потребление памяти. ФП требует, чтобы не было побочных эффектов. Значит, если мы хотим изменить какой-то объект, нам надо создать свежую копию этого объекта и менять её. Иногда это может приводить к большому количеству данных, которые надо держать в памяти.

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

На практике

Скопировано

Саша Беспоясов советует

Скопировано

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

Выбор парадигмы зависит от многих факторов: договорённости, язык программирования, привычки и прочее. Но один из таких факторов — удобство решения задачи.

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

Мультипарадигменные языки

Скопировано

Для большой части задач так мы вовсе можем использовать и ФП, и ООП, и процедурное, и логическое программирование. И есть языки, которые не привязаны к конкретной парадигме. JavaScript как раз один из таких языков. (Именно поэтому мы могли описать пример для каждой парадигмы на нём.)

Мы можем писать на JS в любой парадигме, которой захотим (В ООП, правда, — с небольшими ограничениями и поправками на систему прототипов).

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

В стиле ООП

Скопировано
        
          
          class Calculator {  // Храним (инкапсулируем) значение  // внутри модуля в поле value.  constructor(initial = 0) {    this.value = initial  }  // При добавлении увеличиваем value  // на указанное количество.  add(x) {    this.value += x  }  // При вычитании уменьшаем value  // на указанное количество.  subtract(x) {    this.value -= x  }  valueOf () {      return this.value  }}// Создаём экземпляр класса// с начальным состоянием, равным 0.const calculator = new Calculator()calculator.add(3)console.log(calculator.valueOf())// 3calculator.add(4)console.log(calculator.valueOf())// 7calculator.subtract(1)console.log(calculator.valueOf())// 6
          class Calculator {
  // Храним (инкапсулируем) значение
  // внутри модуля в поле value.
  constructor(initial = 0) {
    this.value = initial
  }

  // При добавлении увеличиваем value
  // на указанное количество.
  add(x) {
    this.value += x
  }

  // При вычитании уменьшаем value
  // на указанное количество.
  subtract(x) {
    this.value -= x
  }

  valueOf () {
      return this.value
  }
}

// Создаём экземпляр класса
// с начальным состоянием, равным 0.
const calculator = new Calculator()

calculator.add(3)
console.log(calculator.valueOf())
// 3

calculator.add(4)
console.log(calculator.valueOf())
// 7

calculator.subtract(1)
console.log(calculator.valueOf())
// 6

        
        
          
        
      

В стиле ФП

Скопировано
        
          
          // Функция будет принимать 2 параметра,// потому что состояния,// где бы хранилось первое значение, нет.// Функция должна зависеть только от аргументов.function add(a, b) {  return a + b}// Здесь точно так же.function subtract(a, b) {  return a - b}// Вызывали бы наши функции мы так:console.log(add(0, 3))// 3console.log(add(3, 4))// 7console.log(subtract(7, 1)) // 6// Или одной строкой:console.log(  subtract(add(add(0, 3), 4), 1))// 6
          // Функция будет принимать 2 параметра,
// потому что состояния,
// где бы хранилось первое значение, нет.
// Функция должна зависеть только от аргументов.
function add(a, b) {
  return a + b
}

// Здесь точно так же.
function subtract(a, b) {
  return a - b
}

// Вызывали бы наши функции мы так:
console.log(add(0, 3))
// 3
console.log(add(3, 4))
// 7
console.log(subtract(7, 1)) // 6

// Или одной строкой:
console.log(
  subtract(add(add(0, 3), 4), 1)
)
// 6