모나드

홍석현·2022년 8월 10일
0
post-thumbnail

함수형 프로그래밍이라는 것이 단순히 고차함수를 이용한다든가, 함수를 일급 객체로 사용한다든가, 순환(재귀)함수를 사용한 로직을 구현한다는 등의 특정 기능에 국한되는 것은 아니지만 모나드와 관련된 개념을 익혀두면 깊이 있게 함수형 프로그래밍을 이해할 수 있을 것입니다.

💡 모나드?
값이 있을 수도 잇고 없을 수도 있는 컨텍스트를 갖는 함수객체 타입

💡 함수객체(Functor)?
포장된 값에 함수를 적용할 수 있다.
고차함수인 map을 적용시킬 수 있는 컨테이너 타입

💡 모나드와 함수객체는 특정 기능이 아닌 디자인 패턴 혹은 자료구조라고 할 수 있습니다.

모나드는 수학의 범주론에서부터 시작을 하는데 프로그래밍에서 사용하는 모나드는 범주론의 모나드의 의미를 완벽히 구현하지 않아 성질을 완벽히 갖추기 못했기 때문에(하지만 대부분의 성질은 갖추어서) 프로그래밍에서의 모나드를 모나딕이라고 표현합니다.

프로그래밍에서 모나드가 갖춰야 하는 조건

  • 타입을 인자로 받는 타입(특정 타입의 값을 포장)
    • 스위프트에서는 제네릭이라는 기능을 통해 타입을 인자로 받을 수 있습니다.
    • 열거형의 연관값, 옵셔널은 스위프트에서 가장 기본적이면서도 유용한 모나드입니다.
  • 특정 타입의 값을 포장한 것을 반환하는 함수(메서드)가 존재
  • 포장된 값을 변환하여 같은 형태로 포장하는 함수(메서드)가 존재

모나드를 이해하는 출발점은 값을 어딘가에 포장한다는 개념을 이해하는 것에서 출발합니다. 스위프트에서 모나드를 사용한 예 중 하나가 바로 옵셔널입니다.

컨텍스트

컨텍스트의 사전적 정의: 맥락, 전후 사정

이번 파트에서 컨텍스트는 ‘콘텐츠를 담은 그 무엇인가’를 뜻합니다.

ex. 물컵에 담긴 물 → 물: 콘텐츠, 물컵: 컨텍스트

만일, 2라는 숫자를 옵셔널로 둘러싸면, 컨텍스트 안에 2라는 콘텐츠가 들어가는 모양새로 ‘컨텍스트는 2라는 값을 가지고 있다’고 말할 수 있습니다.

만일 addThree(_:)라는 Int 타입의 값을 전달받아 3을 더하여 반환하는 함수를 소개합니다.

func addThree(_ num: Int) -> Int {
	return num + 3
}

함수의 전달인자로 컨텍스트에 들어있지 않은 순수 값인 2를 전달하면 정상적으로 함수를 실행할 수 있습니다. addThree(_:) 함수는 매개변수로 일반 Int 타입값을 받기 때문입니다. 만일 Optional이라는 컨텍스트에 둘러싸인 2를 보낸다면 오류가 발생합니다. 순수한 값이 아닌 컨텍스트로 둘러싸여 전달되었기 때문입니다.

addThree(Optional(2)) // 오류! 

함수객체

맵(Map)에 대해 살펴봅시다. 맵은 컨테이너(컨테이너는 다른 타입의 값을 담을 수 있으므로 컨텍스트의 역할을 수행할 수 있습니다.)의 값을 변형시킬 수 있는 고차함수입니다.

💡 히스켈에서 함수객체

class Functor<T> {
	func fmap<U>(_ transform: T -> U) -> Functor<U>
}
var value: Int? = 2 // Optional(2)
value.map { $0 + 3 } // Optional(3)
value = nil
value.map { $0 + 3 } // nil

맵을 언급한 이유는 ‘함수객체(Functor)란 맵을 적용할 수 있는 컨테이너 타입’이라고 할 수 있기 때문입니다. Array, Dictionary, Set 등등 스위프트의 많은 컬렉션 타입이 함수객체입니다.

그럼 다음 코드를 확인해봅시다.

Optional(2).map(addThree) // Optional(5)

이 코드는 어떻게 실행되는 것일까요 ??? 분명 addThree(_:)의 매개변수는 Int였었는데 말입니다. 이때 Optional의 map을 살펴봅시다.

extension Optional {
    func map<U>(f: (Wrapped) -> U) -> U? {
        switch self {
        case .none:
            return .none
        case .some(let wrapped):
            return f(wrapped)
        }
    }
}

옵셔널의 map(_:) 메서드를 호출하면 옵셔널 스스로 값이 있는지 없는지 switch 구문으로 판단하고 값이 있다면 전달받은 함수에 자신의 값을 적용한 결괏값을 다시 컨텍스트에 넣어 반환하고, 그렇지 않다면 함수를 실행하지 않고 빈 컨텍스트를 반환합니다.

모나드

함수객체 중에서 자신의 컨텍스트와 같은 컨텍스트의 형태로 맵핑할 수 있는 함수객체를 닫힌 함수객체라고 합니다. 모나드는 닫힌 함수객체입니다.

함수객체는 포장된 값에 함수를 적용할 수 있었습니다. 그래서 모나드도 컨텍스트에 포장된 값을 처리하여 포장된 값을 컨텍스트에 다시 반환하는 함수(맵)를 적용할 수 있습니다. 이 매핑의 결과가 함수객체와 같은 컨텍스트를 반환하는 함수객체를 모나드라고 할 수 있으며, 이런 맵핑을 수행하도록 플랫맵(flatMap)이라는 메서드를 활용합니다.

map 메서드와 flatMap 메서드의 가장 큰 차이는, 플랫맵은 맵과 다르게 컨텍스트 내부의 컨텍스트를 모두 같은 위상으로 평평(flat)하게 펼쳐준다는 차이가 있습니다. 즉, 포장된 값 내부의 포장된 값의 포장을 풀어서 같은 위상으로 펼쳐준다는 뜻입니다.

Optional 타입에 사용하였던 flatMap 메서드는
Sequence 타입이 Optional Element를 포장한 경우에는 compactMap이라는 이름으로 사용합니다. 추후 분명한 뜻을 나타내기 위해 Swift4.1 버전에서 flatMap에서 compactMap으로 이름이 변경되었습니다.
즉, 배열에서 Optional 컨텍스트에서 꺼내(서 같은 위상으로 통일 시키)는 것은 CompactMap입니다.

let array = [1, 2, nil, 4]
let mapped = array.map { $0 }
let compactMapped = array.compactMap { $0 }

print(mapped) // [Optional(1), Optional(2), nil, Optional(4)]
print(compactMapped) // [1, 2, 4]

삼중 컨테이너에 중첩된 맵과 컴팩트맵을 사용해보았습니다.

let multiContainer = [[1, 2, nil], [3, nil, 5], [nil, 7, 8, 9], [nil], [nil, 11]]

let mappedMultiContainer = multiContainer.map { $0 }
let flatMappedMultiContainer = multiContainer.flatMap { $0 }
let compactMappedMultiContainer = multiContainer.compactMap { $0 }
 
let mappedMappedMultiContainer = multiContainer.map { $0.map { $0 } }
let flatFlatMappedMultiContainer = multiContainer.flatMap { $0.compactMap { $0 } }
// 'flatMap' is deprecated: Please use compactMap(_:) for the case where closure returns an optional value
// flatMap에서 compactMap으로 바뀜 
let compactCompactMappedMultiContainer = multiContainer.compactMap { $0.compactMap { $0 } }

print(mappedMultiContainer)
// [[Optional(1), Optional(2), nil], [Optional(3), nil, Optional(5)], [nil, Optional(7), Optional(8), Optional(9)], [nil], [nil, Optional(11)]]
print(flatMappedMultiContainer)
// [Optional(1), Optional(2), nil, Optional(3), nil, Optional(5), nil, Optional(7), Optional(8), Optional(9), nil, nil, Optional(11)]
print(compactMappedMultiContainer)
//[[Optional(1), Optional(2), nil], [Optional(3), nil, Optional(5)], [nil, Optional(7), Optional(8), Optional(9)], [nil], [nil, Optional(11)]]

print(mappedMappedMultiContainer)
// [[Optional(1), Optional(2), nil], [Optional(3), nil, Optional(5)], [nil, Optional(7), Optional(8), Optional(9)], [nil], [nil, Optional(11)]]
print(flatFlatMappedMultiContainer)
// [1, 2, 3, 5, 7, 8, 9, 11]
print(compactCompactMappedMultiContainer)
// [[1, 2], [3, 5], [7, 8, 9], [], [11]]

위에서 확인할 수 있듯이 flatMap은 내부의 값을 1차원적으로 펼쳐놓는 작업을 하기 때문에, 값을 꺼내주고, 모두 동일한 위상으로 펼쳐(이제 이건 compactMap으로 사용해야 합니다.)놓습니다.

그래서 값을 일자로 평평하게 펼친다(flatten)고 해서 플랫맵으로 불리는 것입니다.

func stringToInt(_ string: String) -> Int? {
    return Int(string)
}

func intToString(_ int: Int) -> String? {
    return String(int)
}
var optionalString: String? = "2"

let mappedResult = optionalString
		.map(stringToInt(_:))

let flattedResult = optionalString.flatMap(stringToInt(_:))
    .flatMap(intToString(_:))
    .flatMap(stringToInt(_:))
    .flatMap(intToString(_:)) // 무한히 가능

print(mappedResult) // Optional(Optional(2))
print(flattedResult) // Optional("2")

맵을 사용하면 옵셔널의 옵셔널 형태이고
플랫맵을 사용하면 옵셔널 형태인 이유는 무엇일까?

플랫맵은 함수의 결괏값에 값이 있다면 추출해서 평평하게 만드는 과정을 내포하고, 맵은 그렇지 않기 때문입니다. 즉, 플랫맵은 항상 같은 컨텍스트를 유지할 수 있으므로 이같은 연쇄연산도 가능한 것입니다.

옵셔널의 맵과 옵셔널의 플랫맵의 정의를 살펴봅시다.

func map<U>(_ transform: (Wrapped) throws -> U) rethrows -> U?
func flatMap<U>(_ transform: (Wrapped) throws -> U?) rethrows -> U?

정의를 기준으로 이전의 코드를 살펴봅시다

    • transform == stringToInt(_:)
    • U == Int?
    • return Int??
  • 플랫맵
    • transform == stringToInt(_:)
    • U? == Int?
    • 즉, U == Int
    • return Int?

지금까지 알아본 것과 같이 옵셔널 체이닝, 옵셔널 바인딩, 플랫맵 등은 모나드와 관련된 연산입니다. 스위프트의 기본 모나드 타입이 아니더라도 플랫맵 모양의 모나드 연산자를 구현하면 사용자 정의 타입도 모나드로 사용할 수 있습니다.

모나드는 너무 어렵다 ... 이해하려다가 멘탈 나갈 법 ..
이 글이 잘 정리 잘 해놓은 것 같은데 이 분도 이해하는데 2년이 걸리셨다고 ... 다음에 기회가 된다면 .. ㅎㅎ

자료 출처: 야곰 스위프트 프로그래밍 3판

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."
profile
iOS 개발자입니다.

0개의 댓글