[번역] Point-Free #10 두 flatMap 이야기
Swift 4.1부터 flatMap
의 기능 중 일부가 deprecate 됐습니다. 왜 그런 일이 일어났을까요? 오늘은 기본적인 flatMap
에 대해 알아보고, 없어진 기능에 대해 이해한 후 관련해서 유용한 코드를 작성해보도록 하겠습니다!
시작하며
Swift 4.1에 제안된 내용은 flatMap
에 치중돼있는 기능을 작게 나눠서 새로운 이름을 부여하자는 것이었습니다. 이 제안 자체는 받아들여졌지만 커뮤니티 피드백에 의해 이름은 다르게 바뀌었습니다.
먼저, 왜 이런 변화가 제안됐는지와 처음 제안된 이름이 바뀌어야 했는지 알아야 합니다. 앞으로 이름 짓는 것이 얼마나 중요한지와 예상하지 못한 방법으로 이전에 익혔던 직관을 활용할 수 있다는 것을 보여드리고자 합니다.
두 flatMap 이야기
Swift 초기에는 map
, filter
, reduce
그리고 flatMap
과 같은 함수를 표준 라이브러리에서 추가하는 등 함수형 프로그래밍 패턴에 대한 지원을 해줬습니다.
Swift 1.0의 flatMap
은 우리에게 친근한 방식인 Array
에 정의되어 있었습니다.
// extension Array {
// func flatMap<B>(_ f: @escaping (Element) -> [B]) -> [B] {
// }
// }
이런 함수는 배열을 반환하기 때문에 연산자를 연결할 수 있다는 점에서 뛰어납니다.
예를 들어, 다음과 같이 쉼표와 줄 바꿈으로 이루어진 값의 목록이 있다고 해보겠습니다.
let csv = """
1,2,3,4
3,5,2
8,9,4
"""
이 문자열의 쉼표 사이에 있는 값을 모두 추출하고 싶다면 어떻게 해야 할까요?
먼저, 줄 바꿈을 기준으로 자릅니다.
csv
.split(separator: "\n")
// ["1,2,3,4", "3,5,2", "8,9,4"]
이제 배열의 각 원소로 가서 쉼표를 기준으로 나눠보겠습니다.
배열을 수정할 때는 우리에게 익숙한 map을 사용합니다.
csv
.split(separator: "\n")
.map { $0.split(separator: ",") }
// [["1", "2", "3", "4"], ["3", "5", "2"], ["8", "9", "4"]]
위 코드는 중첩된 배열을 반환하지만, 우리가 원하는 결과물은 평평한(flat) 배열입니다.
이것이 정확히 flatMap
이 제공하는 기능입니다. flatMap
은 주어진 함수를 각 요소에 적용한 결과 배열을 평평하게(flatten) 만들어줍니다.
csv
.split(separator: "\n")
.flatMap { $0.split(separator: ",") }
// ["1", "2", "3", "4", "3", "5", "2", "8", "9", "4"]
배열에서 flatMap
이 소개된지 얼마 지나지 않아 Optional
에도 flatMap
이 생기게 되었습니다. 명세는 다음과 같습니다.
// extension Optional {
// func flatMap<B>(_ f: @escaping (Element) -> B?) -> B? {
// }
// }
Optional
에 flatMap
이 추가돼서 좋은 점은 옵셔널을 그냥 반환하는 것이 아니라 연산자를 연결할 수 있게 되었다는 점입니다.
예를 들어, String
은 다음과 같이 데이터를 받아서 옵셔널 문자열을 반환하는 실패가 가능한 이니셜라이저를 가지고 있습니다.
String(data: Data(), encoding: .utf8)
// Optional("")
배열의 경우, 옵셔널로 감싸인 값을 변환하기 위해 map
을 사용할 수 있습니다. 옵셔널 문자열을 정수로 변환하고 싶은 경우, 코드는 다음과 같을 것입니다.
String(data: Data(), encoding: .utf8)
.map(Int.init)
// Optional(nil)
빈 문자열로 Int
이니셜라이저를 호출했기 때문에 nil
을 반환받을 것입니다. 그런데 약간 이상한 점이 있습니다. 반환된 결과의 타입이 Optional<Int>
일까요?
_: Int? = String(data: Data(), encoding: .utf8)
.map(Int.init)
// 옵셔널 타입인 `Int??`의 값이 unwrap되지 않았습니다.
아닙니다! map
을 사용한 결과는 이중 옵셔널인 Int??
입니다. 우리가 한 일은 옵셔널이 결과로 나올 수 있는 곳에 옵셔널을 반환하는 이니셜라이저를 입력으로 전달한 것입니다. 이 행동은 결국 중첩 옵셔널로 이어졌습니다. 여기서 flatMap
을 사용하면 모든 것이 해결됩니다.
_: Int? = String(data: Data(), encoding: .utf8)
.flatMap(Int.init)
// nil
드디어 반환값이 잘 처리됐네요. 계속 nil
을 반환하고 있으니 다른 데이터를 넣어봅시다.
String(data: Data([55]), encoding: .utf8)
.flatMap(Int.init)
// Optional(7)
flatMap
과 함께라면 옵셔널 문자열을 받아서 껍데기를 벗긴 후 함수를 적용하고 거기서 반환된 옵셔널 결과를 flatten 한 후 단일 옵셔널 정수로 반환할 수 있습니다.
Array
에는 기존 flatMap
의 의도를 모호하게 만드는 세 번째 flatMap
이 있습니다. 이 flatMap
은 배열 요소를 받아서 변환 작업을 통해 옵셔널을 반환합니다. 문자열로 이루어진 배열을 예시로 들어보겠습니다.
["1", "2", "buckle", "my", "shoe"]
각 문자열을 정수로 변환하고 싶다면 어떻게 해야 할까요? map
을 사용하면 다음과 같은 결과를 얻게 될 것입니다.
["1", "2", "buckle", "my", "shoe"]
.map(Int.init)
// [{some 1}, {some 2}, nil, nil, nil]
변환이 성공한 경우의 옵셔널로 감싸인 값과 실패했을 경우의 nil
로 이루어진 배열을 결과로 받을 것입니다.
map
대신 flatMap
을 사용하면 nil
을 무시하기 때문에 안전하게 정수의 옵셔널 껍데기를 벗길 수 있게 됩니다.
["1", "2", "buckle", "my", "shoe"]
.flatMap(Int.init)
// [1, 2]
정말 유용하지 않나요? 하지만 이러한 종류의 flatMap
은 옵셔널의 품질에 따라 배열의 품질을 결정하기 때문에 기존과는 다른 느낌을 선사합니다.
두 flatMap
을 동시에 쓰면 느낌이 더 강해집니다. CSV 예제를 받아서 각 값을 정수로 만든 후 모두 더해보곘습니다.
csv.split(separator: "\n")
.flatMap { $0.split(separator: ",") }
.flatMap { Int($0) }
.reduce(0, +)
// 41
첫 번째 flatMap
은 쉼표로 각 값을 분리하고 flatten 해서 하나의 배열로 반환합니다. 두 번째 flatMap
은 문자열에서 Int
를 생성하려고 시도하고 실패하면 해당 경우를 무시합니다. 이 두 연산은 아주 다르다고 할 수 있는데 이름은 같습니다. 게다가 코드를 한 눈에 봤을 때 바로 알아보기는 힘들 것입니다.
우리는 타입에 대해 생각하는 것에 시간을 많이 쏟습니다. 특히 함수의 모양에 관해서요. 때로는 이 과정이 메소드의 정의에서 길을 잃게 만듭니다. 컨테이너 타입이 정의에서 없어지기도 하고, 함수와 변수 이름이 흐릿해지기도 합니다. 그럴 땐 좀 더 쉽게 이해할 수 있게 함수 서명을 타입으로만 고정시켜봅시다. 프리 펑션 문법인 (Configuration) -> (Data) -> ReturnValue
를 사용해보겠습니다.
// flatMap : ((A) -> [B]) -> ([A]) -> [B]
// flatMap : ((A) -> B?) -> ( A?) -> B?
// flatMap : ((A) -> B?) -> ([A]) -> [B]
튀는 형태가 하나 있지 않나요? 위쪽의 두 flatMap
은 Array
나 Optional
중 하나의 컨테이너 타입을 가지고 연산합니다. 하지만 세 번째 flatMap
연산은 두 컨테이너를 모두 사용합니다.
편의를 위해 생략했던 내용을 다시 채워보면 다음과 같을 것입니다.
// flatMap : ((A) -> Array<B>) -> ( Array<A>) -> Array<B>
// flatMap : ((A) -> Optional<B>) -> (Optional<A>) -> Optional<B>
// flatMap : ((A) -> Optional<B>) -> ( Array<A>) -> Array<B>
각 컨테이너 타입을 제네릭 타입으로 표현하면 어떨까요? 각 타입의 이름을 다음과 같이 바꿔보겠습니다.
// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>
// flatMap : ((A) -> M<B>) -> (M<A>) -> M<B>
// flatMap : ((A) -> N<B>) -> (M<A>) -> M<B>
앞의 두 서명은 정확히 동일한 모양을 가지네요! 하지만 세 번째 서명은 두 개의 서로 다른 제네릭 컨테이너 타입을 가지고 의미적으로 더 혼란스럽기도 합니다. 예를 들면 M
을 생성하기 위해 N
이 정확히 하는 일은 무엇인가? 와 같이요. 입력받는 함수에 대해 변환을 거쳐서 뒤쪽의 결과가 나오는 것처럼 생각할 수도 있지만 그런 경우에도 N
에 대해 잘 설명되진 않습니다.
이해하기 쉽게 만드려면 다음과 같이 구체 타입을 사용하는 방법이 있습니다.
// flatMap : ((A) -> B?) -> (M<A>) -> M<B>
이는 여전히 혼란스럽습니다. 모든 M
이 Optional
과 같이 사용할 수 있을까요? 이 세 번째 flatMap
은 다른 것에 비해 상대적으로 덜 제네릭합니다.
Optional Promotion
Optional
을 반환하는 연산을 위해 Array
에 flatMap
을 덮어씌우는 것에는 또 다른 이슈가 있는데요, 바로 Optional Promotion
입니다. 옵셔널 파라미터를 필수로 하는 경우, Swift는 값을 자동으로 Optional
을 감쌉니다. 이는 코드를 더욱 간결하게 만들어주고 엔지니어에게 주어진 보일러플레이트 코드에 대한 부담을 줄여줍니다. 보통 이 결과는 .some
이 명시적으로 감싸져서 나옵니다. 하지만 타입 추론은 양날의 검이라서 옵셔널을 반환하는 클로저의 경우엔 어떻게 해도 같은 결과가 나오는 공평한 상황이 생깁니다.
다음과 같은 코드가 있다고 해보겠습니다.
[1, 2, 3]
.flatMap { $0 + 1 }
// [2, 3, 4]
이 코드는 컴파일, 실행 그리고 결과 생성까지 잘 됩니다. 하지만 의미적으로는 이상합니다. 우리는 클로저에서 옵셔널을 반환하지 않습니다. 그 이유는 다음과 같이 컴파일러가 자동으로 값을 감싸주기 때문입니다.
[1, 2, 3]
.flatMap { .some($0 + 1) }
// [2, 3, 4]
값을 수동으로 감싸는 로직은 약간 불편해 보입니다. 옵셔널은 항상 .some
을 반환해야 하고 실패할 경우 nil
을 반환해야 합니다. 하지만 이 연산은 절대 실패할 수 없기 때문에 그냥 map
을 사용하겠습니다.
[1, 2, 3]
.map { $0 + 1 }
// [2, 3, 4]
옵셔널 프로모션이 존재하기 때문에 map
과 작업하는 어떠한 연산자든 flatMap
과도 작동할 수 있습니다. 보통 map
을 사용하는 것을 피하려고 하고 flatMap
을 사용하려고 하는 것이 바로 flatMap
은 대부분 결과를 반환하는데 성공하지만 map
은 그렇지 않을 수 있기 때문입니다. 그런데 이렇게 flatMap
을 모든 곳에 쓰게 된다면 flatMap
의 의미적인 내용이 많이 모호해집니다. 그래서 flatMap
과 map
을 사용할 때 실패할 수 있는지 여부를 명확하게 문서화해야 합니다.
더 최악인 경우는 컴파일 타임 오류가 날거라고 예상하는 타입에 변화를 만들었지만 중첩된 flatMap
과 옵셔널 프로모션으로 인해 컴파일은 성공하고 런타임에 원하지 않는 행동을 하는 경우입니다.
예를 들어, 옵셔널 값인 name을 가지는 User
구조체가 주어졌고 이를 배열로 관리한다고 해보겠습니다.
struct User {
let name: String?
}
let users = [User(name: "Blob"), User(name: "Math")]
유저의 배열이 주어졌을 경우, 그들의 이름을 뽑아내기 위해 map
을 사용할 것입니다.
users
.map { $0.name }
// [{some "Blob"}, {some "Math"}]
하지만 이 배열은 옵셔널 값을 가지는 배열입니다. 우리가 실제로 해야 하는 것은 flatMap
을 사용하는 것이죠.
users
.flatMap { $0.name }
// ["Blob", "Math"]
이러한 코드는 실제 상황에도 많이 만나는 경우입니다. 그런데 만약 User
타입이 name 값을 필수로 바꾼다면 어떨까요? 우리의 강력한 타입 시스템 덕분에 우리는 옵셔널 껍데기를 벗길 수 있게 될테니 다음과 같을 것입니다.
struct User {
let name: String
}
컴파일은 당연히 될 것입니다. 그러면 결과는 어떨까요?
users
.flatMap { $0.name }
// ["B", "l", "o", "b", "M", "a", "t", "h"]
예상치 못한 결과네요! 이러한 이상한 친구의 이름이 새로 바뀐다니 너무 좋습니다.
compactMap과 filterMap
앞에서 보셨듯이 몇몇 flatMap
은 다른 flatMap
과는 다른 행동을 하는 것을 보셨을 것입니다. 이게 바로 이름을 바꾸게 된 계기입니다. 배열과 옵셔널에 변환을 적용해서 같은 타입을 반환하게 하는 flatMap
이 사라진 것이 아닙니다! 이름이 바뀐건 오직 튀는 친구들 뿐입니다.
처음에 제안된 이름은 배열을 돌면서 nil
값을 무시하고 map한다는 의미의 filterMap
입니다. 정의는 다음과 같았습니다.
extension Array {
func filterMap<B>(_ transform: (Element) -> B?) -> [B] {
var result = [B]()
for x in self {
switch transform(x) {
case let .some(x):
result.append(x)
case let .none:
continue
}
}
return result
}
}
약간의 바이크쉐딩(덜 중요한 논의)을 통해 이름은 compactMap
으로 결정됐습니다. 이미 Ruby에서도 compact
라는 메소드가 배열의 nil
값을 걸러주는 의도로 사용돼고 있었기 때문에 선조의 지혜를 따르기로 했습니다. 그럼 compactMap
을 정의해볼까요?
extension Array {
func compactMap<B>(_ transform: (Element) -> B?) -> [B] {
var result = [B]()
for x in self {
switch transform(x) {
case let .some(x):
result.append(x)
case let .none:
continue
}
}
return result
}
}
함수 이름 말곤 아무것도 바뀌지 않았으니 비교하지 않으셔도 됩니다.
filterMap의 일반성
compactMap
이라는 이름이 가지는 불리한 점 중 하나는 이름이 배열에서 하는 행동과 강하게 엮여 있다는 것입니다. compactMap
은 배열의 nil
을 제거해서 더 작은 배열로 “압축(compacting)”합니다. 이는 compactMap
이 가진 가능성을 오히려 막는 역할을 합니다.
하지만 filterMap
이라는 이름의 좋은 점 중 하나는 꽤 괜찮은 일반성을 가진다는 것입니다.
(A) -> Bool
를 입력으로 받아서 (A) -> A?
라는 함수로 유도해보겠습니다.
func filterSome<A>(_ p: @escaping (A) -> Bool) -> (A) -> A? {
return { p($0) ? .some($0) : .none }
}
(A) -> Bool
의 값이 true
라면 .some
을 씌운 값을 반환할 것이고 그게 아니면 .none
일 것입니다.
filterSome
이 있으니 배열에 대한 filter
를 다시 작성할 수 있겠네요.
func filter<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> [A] {
return { $0.filterMap(filterSome(p)) }
}
정수에 통하는 필터도 만들 수 있습니다.
filter { $0 % 2 == 0 }
// ([Int]) -> [Int]
그리고 이 필터를 배열에 연결하면 다음과 같은 결과가 나올 것입니다.
Array(0..<10)
|> filter { $0 % 2 == 0 }
// [2, 4, 6, 8, 10]
이 필터는 좋아보이지만, 실제로 쓸만한 곳은 딱히 찾을 수가 없네요. Swift 표준 라이브러리가 만들어둔 기존의 필터를 사용할 수 있습니다. 그런데도 이런 filter
를 이런 방식으로 바라보는 것은 filter
가 더 나은 일반화를 이끌어낼 수 있다고 생각하기 때문입니다.
이런 사고 방식은 이전에도 있었습니다. 바로 Optional<A>
를 일반화해서 Either<A, B>
로 표현한 것이죠.
enum Either<A, B> {
case left(A)
case right(B)
}
A
의 부재를 nil
로 모델링하는 것 대신, 그 자리에 B
타입의 다른 값을 넣을 수 있습니다.
Either
에 대한 filterSome
같은 함수를 만든다면 다음과 같을 것입니다.
func partitionEither<A>(_ p: @escaping (A) -> Bool) -> (A) -> Either<A, A> {
return { p($0) ? .right($0) : .left($0) }
}
이 함수는 주어진 (A) -> Bool
에 대해서, 타입 (A)
의 값을 Either<A, A>
의 두 경우 중 하나로 나눈(partition) 결과를 반환합니다.
이제 filterMap
을 partition과 Either
에 대한 지식을 바탕으로 일반화해보겠습니다.
extension Array {
func partitionMap<A, B>(_ transform: (Element) -> Either<A, B>) -> (lefts: [A], rights: [B]) {
var result = (lefts: [A](), rights: [B]())
for x in self {
switch transform(x) {
case let .left(a):
result.lefts.append(a)
case let .right(b):
result.rights.append(b)
}
}
return result
}
}
filterMap
에서 filter
를 유도한 것처럼 partitionMap
에서 partition
을 유도해볼까요?
func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
return { $0.partitionMap(partitionEither(p)) } // error
}
위 코드는 튜플 이름에 대해 타입 시스템에 문제가 있지만, 약간의 작업이 들어가면 충분히 해결 가능합니다.
func partition<A>(_ p: @escaping (A) -> Bool) -> ([A]) -> (`false`: [A], `true`: [A]) {
return {
let (lefts, rights) = $0.partitionMap(partitionEither(p))
return (lefts, rights)
}
}
partition
은 표준 라이브러리에 존재하지 않지만 filter
와 filterMap
의 관계와 Optional
를 Either
로 일반화하는 과정을 알아보다보니 저절로 밝혀졌습니다.
여기서 두 개의 평행된 이야기가 나올 수 있겠네요.
-
(A) -> Bool
라는 명세를 optional을 반환하는 함수인(A) -> A?
로 만드는 것은filterMap
함수로 이어지고, 여기서 유도할 수 있는filter
는 우리에게 이미 친숙한 존재입니다. -
(A) -> Bool
라는 명세를 either를 반환하는 함수인(A) -> Either<A, A>
로 만드는 것은partitionMap
함수로 이어지고, 여기서partition
함수를 유도할 수 있습니다.
여기에 functional setters와 같은 이전 에피소드에서 만든 다른 장치들을 조합할 수도 있습니다. 이 에피소드에서는 아주 작지만 매우 복잡한 방식으로 합쳐져있는 제네릭 세터(generic setters)를 정의했습니다. 게다가 프리 펑션과 잘 맞는 것을 확인했으니 partitionMap
의 프리 펑션 버전을 정의해보겠습니다.
func partitionMap<A, B, C>(_ p: @escaping (A) -> Either<B, C>) -> ([A]) -> (lefts: [B], rights: [C]) {
return { $0.partitionMap(p) }
}
같이 쓸만한 함수도 하나 정의하겠습니다.
let evenOdds = { $0 % 2 == 0 ? Either.left($0) : .right($0) }
// (Int) -> Either<Int, Int>
이제 이 함수를 partitionMap
과 함께 사용해봅시다.
partitionMap(evenOdds)
// ([Int]) -> (lefts: [Int], rights: [Int])
새로운 함수가 만들어졌네요. 이 함수는 정수로 이루어진 배열을 받아서 짝수는 왼쪽, 홀수는 오른쪽으로 나뉜 튜플을 반환합니다.
Array(1...10)
|> partitionMap(evenOdds)
// ([2, 4, 6, 8, 10], [1, 3, 5, 7, 9])
예상한대로 작동하는군요. 게다가 이전 에피소드에서 살펴본 tuple composable setters도 사용할 수 있습니다. 짝수만 골라내서 제곱해봅시다.
Array(1...10)
|> partitionMap(evenOdds)
|> (first <<< map)(square)
// ([4, 16, 36, 64, 100], [1, 3, 5, 7, 9])
이제 단 몇 줄만으로 우리는 배열을 짝수와 홀수 둘로 나누고 한 쪽을 제곱할 수 있게 되었습니다.
그래서 요점이 무엇인가요?
이번 에피소드에선 이름 짓기란 무엇인지와 flatMap
의 이름 변경에서 볼 수 있듯이 얼마나 이름 짓기가 빠르게 의미 없는 논쟁이 될 수 있는지에 대해서도 얘기해봤습니다. 그렇다면 이름을 바꾸는 것은 의미가 있었다고 할 수 있을까요? 코드를 deprecate하는 것은 많은 코드 베이스에 흙탕물을 뿌리는 것입니다. 대부분의 사람들은 자신의 코드 베이스가 기존의 flatMap
과 잘 작동하도록 만들어뒀을테니 Swift 4.1로 올린 후 이런 의문이 들 것입니다. “왜 이름을 바꾼거지?” 이름을 바꾸는 것이 그만큼의 작업을 감수해야할 정도로 의미있는 일일까요?
무언가를 볼 때 한 발짝 뒤에서 조금 더 추상적인 방식으로 보고 흥미로운 방식으로 엮을 수 있다는 것은 정말 놀랍습니다. 우리는 flatMap
의 모양만 보고 어떤 것이 튀는지와 다른 두 flatMap
이 직관적으로 같은지에 대해 알 수 있었습니다.
그리고 튀는 flatMap
을 filterMap
으로 이름을 바꾸면서 이전에 보지 못했던 방식으로 일반화해볼 수 있는 기회도 있었습니다.
이름 짓는 것은 정말 논쟁 거리가 되기 쉽습니다! 이름을 통해 관련된 개념 사이의 강력한 유대를 가질 수 있습니다. 이 유대는 우리로 하여금 흥미로운 발견을 할 수 있게 해줍니다. filter
와 filterMap
의 관계에서 partition
과 partitionMap
의 관계를 이끌어 낸 것 처럼요!
이번에 flatMap
의 튀는 기능의 이름이 compactMap
으로 바뀌었으니, 나중엔 더 많은 타입에 대해 알아볼 기회도 생길 것 같습니다.
본문에 연습문제와 참고 자료가 있습니다. 여기에서 확인해주세요!