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

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

func multiplyBy2(_ value: Int) -> Int {
    return value * 2
}

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

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

Скажем, я пишу приложение для обмена сообщениями в чате, чтобы сломить доминирование WhatsApp на рынке. Это будет сложно, поэтому давайте сначала убедимся, что наши основы верны. Для этого мне нужен способ представления сообщений в моей программе. Мы начнем с определения структуры данных, описывающей основной строительный блок приложения:

struct ChatMessage {
    let userName: String
    let message: String
    let creationDate: Date
}

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

func getLastDaysChatMessages() -> [ChatMessage]? {
  guard let rawChatMessages =
      UserDefaults.standard.array(forKey: "chatMessages") as? [RawChatMessage] else { return nil }

  let yesterday = Calendar.current.date(byAdding: .day,
                                        value: -1,
                                        to: Date())!

    return rawChatMessages
        .compactMap(transformDictToChatMessage)
        .filter { $0.creationDate > yesterday }
}

Несмотря на то, что эта функция выполняет свою работу, у нас есть несколько проблем. Мы столкнулись с нашим первым типом побочного эффекта. UserDefaults.standard, Calendar.current и Date() — все это скрытые входные данные. UserDefaults.standard и Calendar.current — это синглтоны, которые могут быть изменены другими частями нашей программы в любое время. Кроме того, поскольку мы получаем дату только во время вызова, эта функция не будет давать предсказуемых результатов при многократном вызове, что снижает возможность тестирования.

Давайте посмотрим, сможем ли мы восстановить предсказуемость и устранить внешнее влияние, чтобы восстановить нашу способность локально рассуждать об этом фрагменте кода:

func getChatMessages(rawChatMessages: [RawChatMessage], filter: (ChatMessage) -> Bool) -> [ChatMessage] {
    return rawChatMessages
        .compactMap(transformDictToChatMessage)
        .filter(filter)
}

// Usage
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!

getChatMessages(rawChatMessages: rawChatMessages,
                filter: { $0.creationDate > yesterday })

В приведенном выше решении мы использовали внедрение зависимостей, чтобы полностью удалить UserDefaults.standard и Calendar.current из уравнения. Наша функция больше не заботится об этих концепциях, что значительно упрощает написание тестов. Мы также добавили параметр filter. Теперь можно ввести дату. Это позволит нам получать предсказуемые результаты при многократном вызове функции, поскольку дата не будет меняться при каждом вызове.

Теперь, когда у нас есть простой способ получать сообщения чата, как насчет сохранения новых? Начнем со следующего подхода:

func saveChatMessages(_ chatMessages: [ChatMessage]) {
    chatMessages.forEach {
        AnalyticsManager.shared.recordChatMessage($0)
    }

    let rawChatMessages = chatMessages.map(transformChatMessageToDict)
    UserDefaults.standard.set(rawChatMessages, forKey: "chatMessages")
}

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

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

typealias ProcessMessage = (ChatMessage) -> Void

func saveChatMessages(_ chatMessages: [ChatMessage], processMessage: ProcessMessage?) -> [RawChatMessage] {
    if let processMessage = processMessage {
        chatMessages.forEach(processMessage)
    }

    return chatMessages.map(transformChatMessageToDict)
}

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

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

Сталкивались ли вы с другими типами побочных эффектов в своем коде? Если да, дайте мне знать в Твиттере @siddarthkalra. Любые другие формы обратной связи также приветствуются. Хорошего дня и респект вам за прочтение до конца!

Первоначально опубликовано на https://siddarthkalra.github.io 6 апреля 2019 г.