Побочные эффекты (Side Effects).

Это перевод статьи-эпизода “Side Effects”, размещенной на сайте pointfree.co.
Код для этого фрагмента можно найти здесь.

“Побочные эффекты” – это то, без чего не можем жить; не можем писать программы. Давайте исследуем некоторого рода “побочные эффекты”, с которыми мы сталкиваемся каждый день. Давайте выясним, почему они делают код трудным для тестирования, и как мы можем управлять ими, не теряя при этом возможности “композиции” (composition).

Введение

У нас был целый эпизод, посвященный исключительно функциям, в котором мы сделали акцент на важности ТИПОВ входов и выходов функций для того, чтобы понять, как можно применить к ним “композицию” (compose). Но есть множество других вещей, которые могут делать функции и которые нельзя “поймать” исключительно их сигнатурой. Эти вещи называются “побочные эффекты” (“side effects“).

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

“Побочные эффекты” – это немного перегруженный термин, и для того, чтобы его определить, давайте сначала посмотрим на функцию, у которой нет “побочных эффектов”:

func compute(_ x: Int) -> Int {
  return x * x + 1
}

Если мы вызовем эту функцию, то получим результат:

compute(2) // 5

Действительно замечательным свойством функций без “побочного эффекта” является то, что не имеет значения, сколько раз мы вызываем эту функцию с одним и тем же входом, мы ВСЕГДА получим один и тот же выход:

compute(2) // 5
compute(2) // 5
compute(2) // 5

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

assertEqual(5, compute(2))


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

assertEqual(4, compute(2))

assertEqual(5, compute(3))


Давайте добавим “побочный эффект” в нашу функцию.

func computeWithEffect(_ x: Int) -> Int {
  let computation = x * x + 1
  print("Computed \(computation)")
  return computation

Мы вставили предложение print прямо в середину.
Если мы как и раньше вызываем функцию computeWithEffect с одним и тем же входом, то мы получаем тот же самый выход:

computeWithEffect(2) // 5

Но если мы посмотрим на нашу консоль, то увидим там дополнительный выход.

Computed 5

Если мы сравним сигнатуру функции computeWithEffect с сигнатурой функции compute, то она окажется той же самой, но при сравнении выполненной функциями работы мы должны принимать во внимание не только сигнатуру этих функций. Функция print вышла во внешний МИР и произвела там изменения, в нашем случае, напечатала что-то на консоли. “Побочные эффекты” требуют понимания внутреннего устройства функции, чтобы знать, что там скрывается.
Давайте напишем тест для этой функции:

assertEqual(5, computeWithEffect(2))


Тест прошел успешно! Но теперь у нас появилась дополнительная строка на консоли.

Computed 5
Computed 5

Это как раз то “поведение”, которое мы не можем тестировать. Да, в нашем случае мы просто печатаем на консоли, и это, в конце концов, не так уж и важно, но если мы вместо печати используем другой “побочный эффект”, например, запись на диск или создание API запроса или будем отслеживать какую-то аналитику, мы начнем больше беспокоиться об этом “поведении” и о том, чтобы его тестировать.

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

[2, 10].map(compute).map(compute) // [26, 10202]
[2, 10].map(compute >>> compute)  // [26, 10202]

Давайте посмотрим, работает ли это правило для computeWithEffect:

[2, 10].map(computeWithEffect).map(computeWithEffect)
// [26, 10202]
[2, 10].map(computeWithEffect >>> computeWithEffect)
// [26, 10202]

Возвращаемые значения совпадают, но если мы посмотрим на консоль, то эти “поведения” не совпадают!

Computed 5
Computed 101
Computed 26
Computed 10202
--
Computed 5
Computed 26
Computed 101
Computed 10202

Мы больше не можем воспользоваться преимуществами этого правила без рассмотрения “побочных эффектов”. Наша способность выполнить такого рода рефакторинг – это реальная оптимизация производительности: вместо того, чтобы обходить наш массив дважды, мы обходим его один раз. Но если ваша функция имеет “побочные эффекты”, порядок выполнения не тот же самый, то есть мы становимся зависимыми от порядка выполнения функций! Выполнение такого рода оптимизации производительности в МИРЕ “побочных эффектов” может разрушить ваш код!

Скрытые выходы

Давайте рассмотрим простейший способ управления этим “побочным эффектом”. Вместо того, чтобы выполнять “побочный эффект” в теле функции, мы можем вернуть дополнительное значение, которое описывает, что нам необходимо напечатать. Функция в теории могла бы распечатывать множество вещей, ток что для моделирования этой ситуации мы используем массивом строк [String].

func computeAndPrint(_ x: Int) -> (Int, [String]) {
   let computation = x * x + 1
   return (computation, ["Computed \(computation)"])
}

computeAndPrint(2) // (5, ["Computed 5"])

Мы получаем не только результат вычислений, но и массив логов (logs), которые мы хотим распечатать.
Давайте напишем тест:

assertEqual(
  (5, ["Computed 5"]),
  computeAndPrint(2)
)


Теперь мы получили не только результат вычисления, но и сам “побочный эффект”, который мы хотим выполнить! Наш тест проваливается, если “побочный эффект” не отвечает ожидаемому формату:

assertEqual(
  (5, ["Computed 3"]),
  computeAndPrint(2)
)


Здесь данные очень простые, но мы должны помнить, что потенциально они могут быть гораздо сложее, описывая API запроса или событие аналитики, и мы должны иметь возможность написать assertions, которые подготавливают этот “побочный эффект” к сравнению с тем, что мы ожидаем от него.
Рассматривая такого рода “побочные эффекты”, мы понимаем, что “побочный эффект”, который делает изменения во внешнем МИРЕ, есть ничто иное, как скрытый неявный выход этой функции. Неявность – обычно не очень хорошая вещь в программировании.
Теперь вы можете спросить: “Ну хорошо, а кто будет выполнять этот “побочный эффект”?” Выталкивая “побочный эффект” наружу в возвращаемый ТИП, мы перекладываем ответственность за этот “побочный эффект” на того, кто вызывает эту функцию.
Например:

let (computation, logs) = computeAndPrint(2)
logs.forEach { print($0) }

Возможно, вызывающий метод тоже не захочет иметь дело с этими “побочными эффектами” и передаст их дальше. Возможно, что последующий вызывающий метод также не захочет иметь с ними дело и так далее! Это похоже на неразрешимую проблему, но есть ряд способов ее решить, хотя нам необходимо детально понять ее.
Может показаться, что мы решили нашу проблему “побочных эффектов”, но мы просто заменили ее описанием выхода нашей функции. И, к сожалению, сломали одну из наиболее важных возможностей функций: их “композицию” (composition).

Наша функция compute – совершенно замечательная, потому что может составлять “прямую композицию” (forward-composition) сама с собой.

compute >>> compute // (Int) -> Int

И другая наша функция computeWithEffect– также замечательная, так как также может составлять “прямую композицию” (forward-composition) сама с собой.

computeWithEffect >>> computeWithEffect // (Int) -> In

Мы можем “протаскивать” в них значения через “конвейерный” оператор |> и получать результаты.

2 |> compute >>> compute // 26
2 |> computeWithEffect >>> computeWithEffect // 26

Конечно, теперь мы опять возвращаемся к тому, что у нас функция computeWithEffect печатает на консоли.

Computed 5
Computed 26

Межде тем, наша попытка решить эту проблему с помощью функции computeAndPrint не позволяют использовать “композицию”.

computeAndPrint >>> computeAndPrint
// Cannot convert value of type '(Int) -> (Int, [String])' 
// to expected argument type '((Int, [String])) -> (Int, [String])'

// (не могу преобразовать значение ТИПА '(Int) -> (Int, [String])'
// в ожидаемый ТИП аргумента '((Int, [String])) -> (Int, [String])'

Выходом функции computeAndPrint является кортеж (tuple) (Int, [String]), а входом исключительно целое число Int.
Мы видели до этого и видим опять: каждый раз, когда у нас есть функция, которой необходимо выполнять “побочные эффекты”, мы расширяем ТИП возвращаемого значения для того, чтобы описать “побочный эффект”, и мы “разрываем” возможность “композиции” функций. Теперь наша работа будет состоять в том, чтобы найти некоторый способ усиления “композиционности” такого рода функций.
В нашем случае, когда функция возвращает кортеж (tuple), мы можем поправить “композиционность” простым замечательным способом. Мы будем также поступать и в более общем случае, чем просто функция computeAndPrint. Давайте определим функцию compose, чья работа будет заключаеться исключительно в том, чтобы выполнять “композицию” такого рода функций.

func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {
  // …
}

Это выглядит очень знакомо. Сигнатура этой функции очень похожа на сигнатуру нашей функции >>> : мы имеем (A) -> B, (B) -> C и (A) -> C, но там присутствует еще дополнительная информация.
Мы можем реализовать эту функцию, смотря на ТИП функции и значения, которые имеются в нашем распоряжении.

func compose<A, B, C>(
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {
  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)
    return (c, logs + moreLogs)
  }

Мы знаем, что мы возвращаем функцию, так что мы начинаем с того, что открываем фигурную скобку и “привязываем” a. У нас есть также функция f, которая берет на входе As, так что давайте передадим ей a и  “привяжем” возвращаемые значения, в этом случае это будет b и некоторый массив строк logs. Теперь, когда у нас есть b, можем передать его в функцию g, и вернуть c и некоторый массив строк moreLogs. Теперь у нас есть c, и мы можем вернуть его в качестве возвращаемого значения наряду с массивом строк logs. Мы могли бы вернуть logs или moreLogs, то в нашем случае имеет смысл вернуть конкатенацию обоих.

Так что давайте проведем “композицию” наших функций!

compose(computeAndPrint, computeAndPrint)
// (Int) -> (Int, [String])

Мы создали совершенно новую функцию, которая вызывает computeAndPrint дважды. Если вы дадите ей данные, то получите не только результат вычислений, но и logs каждого этапа ее выполнения.

2 |> compose(computeAndPrint, computeAndPrint)
// (26, ["Computed 5", "Computed 26"])

Представление оператора >=>

Кажется, что мы разрешили все “композиционные” проблемы, но все опять начинает запутываться, если мы подвергаем “композиции” более, чем 2 функции.

2 |> compose(compose(computeAndPrint, computeAndPrint), computeAndPrint)

Еще хуже то, что существуют два различных способа “композиции” тех же самых функций:

2 |> compose(compose(computeAndPrint, computeAndPrint), computeAndPrint)
2 |> compose(computeAndPrint, compose(computeAndPrint, computeAndPrint))

Круглые скобки – всегда “враги” “композиции”.
А кто “враги” круглых скобок?
Инфиксные операторы.

Мы знаем, что мы всегда хотим применять “композицию” многократно в одной строке и мы хотим иметь возможность подать с помощью “конвейерного” оператора |> значение на эти композиции, так что давайте определим ассоциативную приоритетную группу precedencegroup с приоритетом выше, чем приоритет “конвейерного” оператора |>.

precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
}

Теперь мы можем определить инфиксный оператор >=>, который выглядит очень знакомым.

infix operator >=>: EffectfulComposition

Этот оператор очень похож на оператор  >>>, но мы заменили среднюю стрелку “>” на символ равенства “=“, напоминающий “трубу”. Этот оператор иногда называют забавным именем “fish” (рыба).

Теперь мы можем переименовать нашу функцию compose и “склеить” наши функции с “побочными эффектами” вместе без всякого постороннего “шума” и необходимости думать о круглых скобках.

func >=> <A, B, C> (
  _ f: @escaping (A) -> (B, [String]),
  _ g: @escaping (B) -> (C, [String])
  ) -> (A) -> (C, [String]) {
  return { a in
    let (b, logs) = f(a)
    let (c, moreLogs) = g(b)    return (c, logs + moreLogs)

  }
}

computeAndPrint >=> computeAndPrint >=> computeAndPrint // (Int) -> (Int, [String])

Мы можем подать через конвейерный оператор |> значение на эту многостроковую “композицию” и создать “конвейерную” обработку, которая прекрасно читается сверху донизу.

2
  |> computeAndPrint
  >=> computeAndPrint
  >=> computeAndPrint

Другая замечательная вещь относительно того, что мы перевели нашу “композицию” в МИР операторов, состоит в том, что наш новый оператор >=> может прекрасно взаимодействовать с уже существующими операторами наподобие >>>.

2
  |> computeAndPrint
  >=> (incr >>> computeAndPrint)
  >=> (square >>> computeAndPrint)

Здесь мы можем взять результат функций с “побочными эффектами” и применить их к функциям, которые не имеют “побочных эффектов”, и все это с помощью “композиции”. У нас появились новые проблемы с круглыми скобками, но мы можем их разрешить! Группа операторов ForwardСomposition, возможно, наиболее сильная форма композиции, приводящая к появлению круглых скобок, согласовывая при этом входные и выходные типы. В результате, мы приходим к заключению, что группа операторов ForwardСomposition ВСЕГДА имеет более высокий приоритет выполнения операторов. Поэтому нам необходимо модифицировать приоритет для группы операций EffectfulComposition.

precedencegroup EffectfulComposition {
  associativity: left
  higherThan: ForwardApplication
  lowerThan: ForwardComposition
}

У нас нет необходимости использовать круглые скобки и мы можем передать дальше по “конвейеру” (pipeline) нашу “композицию”.

2
  |> computeAndPrint
  >=> incr
  >>> computeAndPrint
  >=> square
  >>> computeAndPrint

Каждая строка в этом выражении теперь аннотируется оператором, который придает ей определенное значение. Строки с префиксом >>> имеют дело с результатом функции, которая не имеет “побочных эффектов”, в то время как строки с префиксом >=> имеют дело с результатом вычислений, имеющих “побочный эффект”.

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

  1. Этот оператор уже существует в Swift? Нет. Нет никакого шанса “перегрузить” (overload) уже существующий оператор.
  2. Есть ли этот оператор у языков программирования – ПРОТОТИПОВ и имеет ли он информативную форму? Да! Оператор “fish” встроен в Haskell и PureScript, а многие другие языки ввели его в свои функциональные библиотеки. Форма >=> этого оператора совершенно замечательная, особенно вместе с оператором  >>>. Оператор >=> чуть-чуть отличается от оператора >>>, что является индикатором того, что что-то еще здесь происходит.
  3. Является ли этот оператор универсальныи или он служит только каким-то локальным очень специфическим целям? Способ, каким мы его определили в данный момент, является достаточно специфическим и работает только на кортежах (tuples), но форма, которую он описывает, постоянно появляется в программировании. Мы можем даже определить этот оператор на паре других ТИПОВ Swift:
func >=> <A, B, C>(
  _ f: @escaping (A) -> B?,
  _ g: @escaping (B) -> C?
  ) -> ((A) -> C?) {

  return { a in
    fatalError() // упражнение для читателя
  }
}

Мы заменили наши кортежи (tuples)  Optionals и получили оператор, который помогает выполнять “композицию” функций, которые возвращают Optionals. Теперь мы можем создать цепочку из пары “проваливающихся” (failable) инициализаторов, соединенных вместе:

String.init(utf8String:) >=> URL.init(string:)
// (UnsafePointer<Int8>) -> URL?

В результате мы получили совершенно новый “проваливающийся” инициализатор без каких-либо усилий!
Мы могли бы также использовать этот оператор для улучшения “композиционных” способностей функций, возвращающих массив Array:

func >=> <A, B, C>(
  _ f: @escaping (A) -> [B],
  _ g: @escaping (B) -> [C]
  ) -> ((A) -> [C]) {

  return { a in
    fatalError() // упражнение для читателя
  }
}

И если бы мы использовали ТИПЫ Promise или Future, то также могли бы использовать этот оператор для “композиции” функций, которые возвращают Promise:

func >=> <A, B, C>(
  _ f: @escaping (A) -> Promise<B>,
  _ g: @escaping (B) -> Promise<C>
  ) -> ((A) -> Promise<C>) {

  return { a in
    fatalError() // an exercise for the viewer
  }
}

Мы видим эту форму оператора снова и снова. В некоторых языках с очень мощной системой ТИПОВ возможно определить этот оператор один раз и получить все его реализации незамедлительно. Swift пока еще не обладает такой способностью, так что мы должны заново определять его для всех новых ТИПОВ. Мы можем создать такого рода оператор на интуитивном уровне для многих ТИПОВ. Если мы видим оператор >=>, то мы должны знать, что это цепочка некоторого рода “побочных эффектов”.

Скрытые входы.

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

func greetWithEffect(_ name: String) -> String {
  let seconds = Int(Date().timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

greetWithEffect("Blob")
// "Hello Blob! It's 14 seconds past the minute."

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

assertEqual(
  "Hello Blob! It's 32 seconds past the minute.",
  greetWithEffect("Blob")
)


Это особенно плохой “боковой эффект”. В предыдущем случае мы могли хотя бы написать тестовые утверждения assertions для проверки хоть каких-то выходов, хотя, конечно, мы не могли тестировать их все. В этом случае мы не можем написать никаких тестовых утверждений потому, что выход функции все время меняется.

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

Давайте посмотрим, сможем ли мы использовать то же самое решение для этого “побочного эффекта”. Ранее мы преобразовали “побочный эффект” print в ЯВНО возвращаемое значение функции compute, а здесь мы можем преобразовать “побочный эффект” Date ЯВНО в аргумент функции.

func greetWithEffect(_ name: String) -> String {
  let seconds = Int(Date().timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(seconds) seconds past the minute."
}

greetWithEffect("Blob")
// "Hello Blob! It's 14 seconds past the minute."

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

assertEqual(
  "Hello Blob! It's 39 seconds past the minute.",
  greet(at: Date(timeIntervalSince1970: 39), name: "Blob")
)


Мы восстановили тестируемость с помощью некоторого шаблона. Тот, кто будет вызывать эту функцию, должен передать дату date ЯВНО, что может оказаться необязательным за пределами наших тестов. Мы можем соблазниться и скрыть эту деталь реализации, определяя аргументу date значение по умолчанию и ввести зависимость от текущей даты Date() в нашу функцию, чтобы убрать небходимость явного указания аргумента date.

func greet(at date: Date = Date(), name: String) -> String {
  let s = Int(date.timeIntervalSince1970) % 60
  return "Hello \(name)! It's \(s) seconds past the minute."
}

greet(name: "Blob")

Это читается прекрасно, но у нас появилась большая проблема: мы опять сломали “композиционность”.

Наша первая функция greetWithEffect имела прекрасную форму (String) -> String и могла “композироваться” с другими функциями, которые возвращают строку String и функциями, которые имеют в качестве входа String.

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

func uppercased(_ string: String) -> String {
  return string.uppercased()
}

Она прекрасно “композируется” с функцией greetWithEffect по обе стороны.

uppercased >>> greetWithEffect
greetWithEffect >>> uppercased

Мы можем через “конвейер” |> подать имя name на эти композиции и получить различное поведение этих “композиций”.

"Blob" |> uppercased >>> greetWithEffect
// "Hello BLOB! It's 56 seconds past the minute."
"Blob" |> greetWithEffect >>> uppercased
// "HELLO BLOB! IT'S 56 SECONDS PAST THE MINUTE."

Тем не менее наша функция greet не может участвовать в “композиции” функций.

"Blob" |> uppercased >>> greet
"Blob" |> greet >>> uppercased
// Cannot convert value of type '(Date, String) -> String' 
// to expected argument type '(_) -> _'

// Не могу преобразовать значение ТИПА '(Date, String) -> String'
// в ожидаемый ТИП аргумента '(_) -> _'

Она берет два входа, так что нет никакого способа составить “композицию” выхода функции с ее входом. Если мы проигнорируем вход Date, мы можем увидеть, что эта функция имеет форму (String) -> String. Фактически, это небольшая хитрость, с помощью которой мы можем “вытолкнуть” Date за пределы этой сигнатуры: мы можем переписать greet таким образом, что она берет Date как вход, а возвращает совершенно новую функцию (String) -> String, которая управляет действительной логикой приветствия:

func greet(at date: Date) -> (String) -> String {
  return { name in
    let s = Int(date.timeIntervalSince1970) % 60
    return "Hello \(name)! It's \(s) seconds past the minute."
  }
}

Теперь мы можем вызывать нашу функцию greet с определенной датой date и получать совершенно новую функцию (String) -> String.

greet(at: Date()) // (String) -> String

Эта функция может участвовать в “композиции”!

uppercased >>> greet(at: Date()) // (String) -> String
greet(at: Date()) >>> uppercased // (String) -> String

И мы также можем подавать на них значения с помощью “конвейерного” оператора |>!

"Blob" |> uppercased >>> greet(at: Date())
// "Hello BLOB! It's 37 seconds past the minute."
"Blob" |> greet(at: Date()) >>> uppercased
// "HELLO BLOB! IT'S 37 SECONDS PAST THE MINUTE."

Мы восстановили “композиционность” функции и продолжаем сохранять ее тестируемость.

assertEqual(
  "Hello Blob! It's 37 seconds past the minute.",
  "Blob" |> greet(at: Date(timeIntervalSince1970: 37))
)

Итак, мы столкнулись с “побочным эффектом”, который не позволяет нам проводить тестирование, но смогли контролировать его, переместив этот контекст на вход функции, что явилось схожей версией “побочного эффекта”, через который мы прошли ранее. Наш первый “побочный эффект” достигал ВНЕШНИЙ МИР и производил там изменения, что трактовалось нами как СКРЫТЫЙ ВЫХОД, в то же время наш второй “побочный эффект” зависил от некоторого состояния ВНЕШНЕГО МИРА, который трактовался нами как СКРЫТЫЙ ВХОД! Все “побочные эффекты” проявляются таким образом.

Изменчивость (Mutation)

Давайте посмотрим на очень специфический “побочный эффект” и проанализируем его: “изменчивость” (mutation). Нам всем приходится иметь дело с “изменчивостью” (mutation) в коде и это приводит к существенной сложности. К счастью, Swift обеспечивает нас на уровне ТИПОВ некоторой возможностью  контроля этой “изменчивости” (mutation) и правильным пониманием того, как и где это может происходить.

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

let formatter = NumberFormatter()

func decimalStyle(_ format: NumberFormatter) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func currencyStyle(_ format: NumberFormatter) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func wholeStyle(_ format: NumberFormatter) {
  format.maximumFractionDigits = 0
}

У нас имеется NumberFormatter из фреймворка Foundation и несколько функций, которые конфигурируют числовые форматоры специальными стилями. Для использования этих стилизующих функций мы можем просто напрямую применить их к нашему форматору formatter.

decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6) // "1,235"

currencyStyle(formatter)
formatter.string(for: 1234.6) // "$1,234"

Если еще раз применить первое множество форматеров, то у нас возникнет проблема:

decimalStyle(formatter)
wholeStyle(formatter)
formatter.string(for: 1234.6) // "1,234"

Выход изменился с “1,235” на “1,234“. В чем причина? “Изменчивость” (mutation). Изменения, производимые функцией currencyStyle, затрагивают использование других наших форматоров, приводя к ошибке, которую в большом контексте очень трудно отследить.

Это пример того, почему “изменчивость” (mutation) так трудно отследить. Невозможно узнать, в какой строке это происходит до тех пор, пока мы не изучим каждую строку кода, выполняемую до этой критической строки. “Изменчивость” (mutation) – это проявление обоих “побочных эффектов”, с которыми мы сталкивались до этого, когда изменяемые передаваемые между функциями данные являются одновременно и СКРЫТЫМ ВХОДОМ и СКРЫТЫМ ВЫХОДОМ!

Причина, по которой мы рассматриваем именно эту “изменчивость” (mutation), заключается в том, что NumberFormatter – это “Reference” ТИП. В Swift классы classes являются “Reference” ТИПами. экземпляр “Reference” ТИПа – это единственный объект, который может изменяться любой частью кода, содержащего на него ссылку. Нет легкого способа определить, какая часть кода, имеющая ссылку на один и тот же объект, могла привести к множеству противоречий, если имеет место “изменчивость” (mutation). Если наш код использовался бы в приложении и на его основе была бы написана какая-нибудь новая функциональность, опирающаяся на этот formatter, то такие незаметные ошибки могли бы расползтись и по всему новому коду.

У Swift есть также “Value” ТИПЫ. Это и есть ответ Swift на управление “изменчивостью” (mutation). Когда вы присваиваете значение “Value” ТИПу, то вы получаете совершенно новую копию для работы внутри заданного контекста. Все “изменения” (mutations) являются локальными и если есть что-то еще ссылающееся на то же самое значение “выше по течению”, то оно не “видит” этих изменений.

Давайте сделаем рефакторинг кода и используем “Value” ТИПЫ.

Мы начнем со структуры struct, которая является “оберткой” вокруг конфигурации, которую мы делаем для NumberFormatter.

struct NumberFormatterConfig {
  var numberStyle: NumberFormatter.Style = .none
  var roundingMode: NumberFormatter.RoundingMode = .up
  var maximumFractionDigits: Int = 0

  var formatter: NumberFormatter {
    let result = NumberFormatter()
    result.numberStyle = self.numberStyle
    result.roundingMode = self.roundingMode
    result.maximumFractionDigits = self.maximumFractionDigits
    return result
  }
}

У этой структуры есть прекрасные значения по умолчанию и вычисляемая переменная formatter, которую можно использовать для получения новых “настоящих” NumberFormatters. На что будут похожи обновления наших “стилизирующих” функций, если вместо класса NumberFormatter использовать структуру NumberFormatterConfig ?

func decimalStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

func currencyStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.numberStyle = .currency
  format.roundingMode = .down
  return format
}

func wholeStyle(_ format: NumberFormatterConfig) -> NumberFormatterConfig {
  var format = format
  format.maximumFractionDigits = 0
  return format
}

Каждая “стилизирующая” функция берет NumberFormatterConfig, копирует его, используя ключевое слово var и изменяет локальную копию прежде, чем вернуть ее вызвавшей функции или методу.
Использование этих “стилизирующих” функций выглядит немного по-другому.

let config = NumberFormatterConfig()

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// "1,235"

currencyStyle(config)
  .formatter
  .string(for: 1234.6)
// "$1,234"

wholeStyle(decimalStyle(config))
  .formatter
  .string(for: 1234.6)
// "1,235"

В этом случае, каждый раз, когда мы передаем config в “стилизующую” функцию, мы получаем ее совершенно новую копию, и наша ошибка уходит!

Мы могли бы сделать что-то подобное и с ссылочными (“Reference“) ТИПАМИ, используя метод copy для классов, которые реализуют протокол NSCopying и ЯВНО возвращают эту копию:

func decimalStyle(_ format: NumberFormatter) -> NumberFormatter {
  let format = format.copy() as! NumberFormatter
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
  return format
}

К сожалению,в этой ситуации компилятор не дает нам гарантий, что мы не изменяем оригинальный format. Более того, вызывающая функция (caller) ожидая копию, может свободно проводить дальнейшие изменения и сложность начнет возрастать именно отсюда!

Из-за того, что “Reference” ТИПЫ не копируются автоматически, они имеют некоторые преимущества в производительности. К счастью, Swift снабжает нас прекрасным семантическим способом изменения значений “по месту” с помощью ключевого слова inout .

Давайте модифицируем наши “стилизующие” функции с использованием inout.

func inoutDecimalStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .decimal
  format.maximumFractionDigits = 2
}

func inoutCurrencyStyle(_ format: inout NumberFormatterConfig) {
  format.numberStyle = .currency
  format.roundingMode = .down
}

func inoutWholeStyle(_ format: inout NumberFormatterConfig) {
  format.maximumFractionDigits = 0
}

Это очень похоже на наши первоначальные функции с NumberFormatter. Мы можем напрямую выполнять изменения и не беспокоиться о копировании значений или об их возврате. Давайте попытаемся использовать эти “стилизующие” функции тем же самым способом, каким мы использовали наши оригинальные функции.

let config = NumberFormatterConfig()

inoutDecimalStyle(config)
inoutWholeStyle(config)
config.formatter.string(from: 1234.6)

Мы получаем ошибку компиляции!

Cannot pass immutable value as inout argument: 'config' is a 'let' constant
 (Не могу передать неизменяемое значение в качестве inout аргумента: 'config' - это 'let' константа)

Swift даже предлагает исправить эту ошибку, заменяя let на var. Но этого недостаточно. И мы опять получаем ошибку компиляции, но другую!

Passing value of type 'NumberFormatterConfig' to an inout parameter requires explicit '&'
(Передача значения ТИПА 'NumberFormatterConfig' inout параметру требует ЯВНОГО '&')

Swift требует от нас аннотации inout параметра с помощью символа ‘&‘ при вызове, что говорит о том, что мы согласны на изменение этих данных.

inoutDecimalStyle(&config)
inoutWholeStyle(&config)
config.formatter.string(from: 1234.6) // "1,235"

Мы можем продолжать вызывать наши изменяющие “стилизующие” функции тем же самым способом.

inoutCurrencyStyle(&config)
config.formatter.string(from: 1234.6) // "$1,234"

inoutDecimalStyle(&config)
inoutWholeStyle(&config)
config.formatter.string(from: 1234.6) // "1,234"

И наша ошибка опять возвращается, но на этот раз наш код просто “кричит”: “Изменение (mutation)”, и такого рода ошибку теперь уже значительно легче обнаружить.

Это здорово, что Swift снабжает нас решением проблем “изменчивости” (mutation) на уровне ТИПОВ, которая управляет тем, когда может происходить и как далеко может распространяться “изменчивость” (mutation). Но у нас все еще есть проблемы, которые мы должны решить, если хотим и дальше использовать этот механизм.

“Стилизирующие” функции, которые мы использовали ранее и которые возвращают совершенно новые копии, имеют замечательную форму:

(NumberFormatterConfig) -> NumberFormatterConfig

У них один и тот же вход и выход, а это означет, что они могут составлять “композиции” друг с другом и с любыми другими функциями, которые возвращают или берут в качестве входа NumberFormatterConfig!

decimalStyle >>> currencyStyle
// (NumberFormatterConfig) -> NumberFormatterConfig

Теперь у нас есть совершенно новая “стилизующая” функция, которую мы можем составить из отдельных маленьких кусочков.

Между тем, наши inout функции не имеют эту форму: их входы и выходы не подходят друг к другу и они не могут составлять “композиции” со многими другими похожими функциями. Однако эти функции имеют ту же самую логику, так что должен существовать мост между МИРОМ inout и МИРОМ обычных функций.

Оказывается, мы можем определить функцию с именем toInout, которая преобразует функцию с одним и тем же ТИПОМ входа и выхода в inout функцию.

func toInout<A>(
  _ f: @escaping (A) -> A
  ) -> ((inout A) -> Void) {

  return { a in
    a = f(a)
  }
}

Мы можем также определить дуальную функцию, fromInout, которая выполнят обратное преобразование.

func fromInout<A>(
  _ f: @escaping (inout A) -> Void
  ) -> ((A) -> A) {

  return { a in
    var copy = a
    f(&copy)
    return copy
  }
}

То, что мы здесь видим, – это естественное соответсвие между (A) -> A функциями и (inout A) -> Void функциями. Функции из МИРА (A) -> A “композируются” очень хорошо, так что, имея это соответствие, мы можем надеяться, что функции из МИРА (inout A) -> Void могли бы также хорошо разделять их “композиционные” способности.

Представление оператора <> (“бриллиантовый” оператор).

Хотя мы видим, что (A) -> A функции “композируются” с использованием оператора >>>, нам не следует повторно использовать этот оператор, потому что у него слишком много степеней свободы. Мы рассмотрим более ограниченную “композицию” ТИПА самого с собой. Давайте определим новый оператор и начнем с группы приоритета precedencegroup SingleTypeComposion.

precedencegroup SingleTypeComposition {
  associativity: left
  higherThan: ForwardApplication
}

Теперь давайте определим наш новый оператор <>.

infix operator <>: SingleTypeComposition

Забавное имя для этого оператора <> – “diamond” (“бриллиантовый”) оператор.

Мы можем определить оператор <> для сигнатуры (A) -> A достаточно просто:

func <> <A>(
  f: @escaping (A) -> A,
  g: @escaping (A) -> A)
  -> ((A) -> A) {

  return f >>> g
}

Это может показаться глупым: простое “оборачивание” одного оператора другим оператором, но мы ограничили его применение одним и тем же ТИПОМ для входа и выхода и перекодировали его значение: теперь, если вы видите оператор <>, то понимаете, что имеете дело с единственным ТИПОМ!

Давайте определим оператор <> для inout функций:

func <> <A>(
  f: @escaping (inout A) -> Void,
  g: @escaping (inout A) -> Void)
  -> ((inout A) -> Void) {

  return { a in
    f(&a)
    g(&a)
  }
}

Наша предыдущая “композиция” работает.

decimalStyle <> currencyStyle

Более того, мы можем выполнять “композицию” наших inout “стилизирующих” функций!

inoutDecimalStyle <> inoutCurrencyStyle

Что произойдет, если мы начнем с помощью “конвейера” |> подавать значения на эти “композиции”?

config |> decimalStyle <> currencyStyle
config |> inoutDecimalStyle <> inoutCurrencyStyle

Наша inout версия выдаст ошибку.

Cannot convert value of type '(inout Int) -> ()' to expected argument type '(_) -> _' (Не могу преобразовать значение ТИПА '(inout Int) -> ()' в ожидаемый аргумент ТИПА '(_) -> _' )

Ошибка произошла потому, что оператор |> пока не работает в МИРЕ inout, но мы можем определить его “перегрузку” (overload), которая будет это делать.

func |> <A>(a: inout A, f: (inout A) -> Void) -> Void {
  f(&a)
}

Теперь мы можем свободно с помощью “конвейерного оператора” |> подавать значения внутрь этих изменяемых “композиций”.

config |> inoutDecimalStyle <> inoutCurrencyStyle

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

Мы решили эту проблему ценой еще одного дополнительного оператора <>, так что пришло время проверить его, удовлетворяет ли он необходимым требованиям.

  1. Существует этот оператор в Swift? Нет, так что нет никаких потенциальных возможностей для конфликта.
  2. Существует ли этот ператор в других языках – прототипах? Да. Он существует в языках Haskell, PureScript и других языках с сильным функциональным сообществом, которое его приняла. У него замечательная форма, которая указывает в обе стороны и является своего рода сигналом объединения вместе.
  3. Является ли этот оператор универсальным или служит исключительно местным специфическим проблемам? До сих пор мы определили этот оператор только для функций с сигнатурой (A) -> A и (inout A) -> Void, но, оказывается, оператор <> в самом общем случае используется для комбинации двух вещей того же самого ТИПА в одну, что является наиболее фундаментальным элементов вычислений вообще . Мы будем сталкиваться с этим оператором повсюду.

В чем смысл?

Пришло время спросить себя: “В чем смысл?” Мы столкнулись со множеством эффектов, которые усложняют наш код и делают его трудным для тестирования. Мы решили исправить эту ситуацию, выполнив некоторую предварительную работу по ЯВНОМУ представлению “побочных эффектов” в ТИПАХ, как входных, так и выходных данных, но сломали при этом “композиционность” этих ТИПОВ. Затем мы ввели операторы, которые помогли нам восстановить “композиционность”, специальным образом выполняя “композицию” “побочных эффектов”. Стоило ли это делать?

Мы говорим, что да! Мы смогли “подтянуть” наш код с “побочными эффектами”, который был нетестируемым и трудным для изоляции, до МИРА, где “побочные эффекты” представлены ЯВНО и где мы можем тестировать и понимать каждую строку без необходимости понимания предшествующих строк. Мы сделали все это, не сломав при этом “композиционность” этих функций. И это действительно мощно!

Между тем, наша дополнительная заблаговременная работа должна сохранить нам неимоверное количество времени, которое мы бы затратили на отладку кода со сложной паутиной “изменчивости” (mutation), времени на исправление ошибок в “побочных эффектах”, которые разносятся повсюду, времени перепрыгивания через обручи, чтобы сделать код тестируемым.
“Побочные эффекты” – это громадная тема, и мы лишь слегка ее коснулись.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.