Whythefunc cover

Why the Func: 왜 함수형 프로그래밍을 해야 하나요?

객체 지향(Object-Oriented, 줄여서 OO로 표기)

[receiver doThis];

제가 작성한 코드입니다. 많은 사람들이 사용하고 있는 코드이기도 하죠. 우리는 OO의 관점에서 세상을 바라보고 있기 때문에 리시버에 메시지를 보내는 방식을 사용하여 코드를 작성합니다. 메시지는 다음과 같이 인자를 포함할 수도 있습니다.

[receiver do: This];

인자를 표현하기 위해 콜론을 사용했고 멀티 콜론을 사용해 추가적인 인자를 전달할 수 있습니다.

[receiver do: This with: that];

우리는 스몰 토크 세상에서 출발했기 때문에 오랫동안 이같은 방식으로 코드를 작성해왔으며 OO 세상은 이처럼 리시버에 메시지를 보내는 방식으로 동작합니다.

Objective-C와 함수 호출

Objective-C에서는 []: 구문을 사용하며, 리시버에 메시지를 보내면 내부적으로는 objc_msgSend() 메서드가 호출됩니다.

[receiver do: this
        with: that];
        
objc_msgSend();

Objective C는 우리를 대신하여 이 함수를 호출하며, 개발자는 리시버에 메시지를 전달하는 구문을 규칙에 맞게 사용하기만 하면 됩니다.

OO에서는 각각의 객체 안에 속성들이 존재하고, 상태들이 저장됩니다. 이러한 구조는 상태들을 직접 조작하는 것을 방지합니다. C 프로그래밍에서는 상태들을 직접 조작했었죠.

함수는 우리 생각만큼 자연스럽지 않습니다.

OO - is for us

OO세상에서는 호출 가능한 메서드들을 진입 장소(entry point)로 제공하여, 직접적인 상태 변경은 피하면서도 상태를 변경할 수 있는 방법을 제공해왔습니다. 당신은 리시버에 메시지를 보낼 수 있습니다. 이것이 OO의 세상입니다.

자동차가 있고 자동차를 움직이길 원하는 상황이라고 가정해보죠. 그러기 위해서는 car에게 메시지를 보내야 하겠죠. 조향 장치를 이용해 특정 방향으로 가라고 지시하는 메시지를 car에게 보낼 수도 있습니다. Swift에서는 한발 더 나아가 아이패드를 이용해 로봇을 조정할 수도 있겠네요.

자동차나 로봇들 역시 OO의 관점으로 설계했다면, 이들 객체들에 메시지를 보낼 수 있습니다. 로봇의 경우라면 다른 방법을 이용했을지도 모르겠네요.

중요한 것은 우리가 함수를 사용하고 있다는 것이죠.

하지만, 함수가 우리가 생각하는 것처럼 자연스러운 형태는 아닙니다. 그동안 우리가 객체 지향 관점으로 세상을 바라보았다면, 함수의 관점으로도 세상을 볼 수 있지 않을까요? “Hello, World.” 예제를 통해 함수란 무엇인지 한번 알아보겠습니다.

Swift로 작성한 아래의 예제는 함수가 무엇인지 알고 있는 사람들에게는 상당히 부자연스럽게 느껴질 것입니다.

func hello() {
    print("Hello, World!")
}

인자로 아무것도 받지 않고 리턴값도 없는 함수를 표현한 예제 코드는 우리가 일반적으로 생각하는 함수 정의와는 많이 다른 것 같습니다. 함수란 어떤 입력값에 대해 특정 출력값이 있는 것을 말합니다.

“Hello, Daniel”

func hello(_ name: String) {
    print("Hello, \(name)!")
}

Swift에서는 _을 이용해 인자의 레이블을 생략할 수 있기 때문에, 예제 코드를 위와 같이 조금 변경한 다음, hello("Daniel")와 같은 형태로 hello 함수를 호출하면 “Hello, Daniel!”이라는 메시지가 콘솔화면에 출력될 것입니다. String타입의 문자열을 hello 함수의 인자로 전달했지만 여전히 아무런 값도 리턴하고 있지 않습니다.

학교에서 수학을 배운 적이 있다면 예제에 사용된 함수의 구조가 이상하게 느껴질 것입니다. 수학에서 함수란 도메인(Domain) 내의 인자가 범위(Range) 안의 인자와 매핑되는 것을 말합니다.

작년, 미적분학 수업시간에 칠판에 적었던 내용을 보여드리죠: f: Domain -> Range. 이것은 “도메인의 모든 요소들은 범위 안의 특정한 1개 요소와 관련이 있다”는 것을 말합니다. 함수는 도메인에서 1개의 요소를 가져와 범위 안에서 이것과 연관된 것을 찾아 당신에게 돌려줍니다.

매핑

함수는 동일한 입력값이 제공된다면 항상 동일한 출력값이 나와야 합니다. 3을 입력해서 1이라는 결과가 나왔는데 다음번에 3을 입력했을 때 1이 아닌 다른 값이 나와서는 안된다는 뜻입니다. 이것이 함수에 대한 정의입니다. 하나가 다른 것과 매핑되는 것입니다. 따라서, 다음과 같은 구조는 함수가 아닙니다.

this is not the function

왼쪽의 첫 번째 인자가 오른쪽의 첫 번째가 될 때도 있고 가운데 인자와 매핑될 때도 있어서는 안 됩니다.

this is the function

위처럼 두 개의 다른 인자가 같은 값에 매핑되는 것은 괜찮습니다. 아래처럼 function machine을 이용해 함수에 대해 설명하는 선생님도 있었습니다.

function machine

function machine을 갖게 된다면 우리의 hello world 예제 코드는 아래와 같은 모습일 것입니다.

func hello(_ name: String) -> String {
    return "Hello, \(name)!"
}

입력값이 있고 출력값도 있는 구조네요. 콘솔화면에 어떤 것을 출력하는 대신, String타입을 입력값으로 받아 String 타입을 돌려줍니다. 이것이 입력값을 받아 출력값을 돌려주는 함수이며, hello("Daniel")과 같은 형태로 함수를 호출할 수 있습니다.

이런 개발 뉴스를 더 만나보세요

how to work the example code in the function machine

hello world 예제코드의 모습이 우리가 앞에서 언급한 함수의 정의와 일치하네요. 이것이 우리가 학창 시절 수학 시간에 배웠던 함수이며 사이드 이펙트가 없는 함수이기도 합니다.

테스트 가능한, 반복 가능한

알 수 없는 곳에서 상태가 변경되는 사이드 이펙트는 우리가 프로그래밍을 할 때 경계해야 할 요소 중 하나죠. 때문에 변경할 수 없다는 것은 테스트가 가능하고, 반복할 수 있다는 것을 의미합니다.

자동차 예제로 돌아가 볼까요? 차량을 이동하는 함수를 비함수적인(non-functional) 방식으로 작성할 경우 많은 문제가 발생할 수 있습니다.

func forward(_ amount: Int) {
    car.position += amount
}

그 중 한 가지는 car에 대해 광법위하게 접근할 수 있다는 것입니다. car 객체에 접근하면 position 변수에 접근할 수 있을 뿐만 아니라, position을 직접 증가시키거나 변경할 수 있기 때문이죠.

또한, car에 접근해서 같은 입력값을 실수로 두 번 입력한다면, 자동차가 내가 원하는 위치에 없겠죠.

이것은 테스트하기도 어렵습니다. 이를 해결하기 위한 Car 객체를 작성해보죠. Swift에서는 클래스 인스턴스로 Car를 생성하는 것보다 더 나은 방법이 있는데 바로 구조체를 이용하는 것입니다.

struct Car {
    var position: Int
    
    mutating func forward(_ amount: Int) {
        position += amount
    }
}

Car 객체를 만들었고 Car는 반드시 position이 있으며 var로 선언되었기 때문에 position을 변경할 수도 있습니다.

Mutating 함수

var 변수가 있고 mutation 함수가 있으며, 하나의 car 인스턴스에서 메서드를 호출할 수 있습니다. car.forward(someAmount)를 호출하여 someAmount에 해당하는 만큼 car를 이동할 수 있습니다. 이것이 Objective C에서 수년간 해왔던 방식이며 우리는 이 방식이 괜찮다고 생각합니다. 이 방법이 아니면 CGRect을 이용해서 작업해야 할 테니까요.

car.forward를 호출하여 car를 이동시킬 수 있지만 car.forward를 다시 한번 호출한 경우 첫 번째 호출과는 다른 결괏값을 얻게 됩니다. var 대신 let 을 사용하고 mutating 함수 대신 새로운 Car 인스턴스를 리턴하면 어떨까요? forward 함수를 이용해 car를 이동하는 대신, forward를 호출하면 새로운 위치의 car를 얻게 되는 거죠.

struct Car {
    let position: Int
    
    func forward(_ amount: Int) -> Car {
        return Car(position:  position + amount)
    }
}

forward 함수를 호출하면, 새로운 위치 값을 갖는 새로운 Car 인스턴스를 생성합니다. 사람들에게 이것이 세상이 동작하는 방식이라고 이해시켜야 한다는 사실을 제외한다면 저는 이 코드가 맘에 듭니다. 이제 car를 움직일 때마다 새로운 car를 얻게 될 것입니다.

CarView의 경우는 어떨까요?

class CarView: UIView {
    private var car: Car
    // some init
    
    override func draw(_ rect: CGRect) {
        // code to draw the car
    }
}

CarView는 car에 대한 시각적인 표현입니다. 따라서 CarView라는 이름의 클래스를 생성하였고, 그 안에는 car를 담고 있습니다. car는 자신이 어느 위치에 있는지 알고 있기 때문에 이 car를 그리는 것만 신경 쓰면 됩니다. car의 위치와 모습을 그릴 수 있게 되었고, 비함수적인 요소(CarView) 안에 함수적인 요소(car)를 보유한 구조를 갖게 되어 매우 기쁘게 생각합니다.

순수 함수나 순수 OO 형태만을 고집하지 않는다면 저는 이 구조가 매우 좋다고 생각합니다.

combination of the functional and OO

함수와 OO가 결합된 구조를 객체와 속성 간의 관계로 생각하는 분도 있을 것 같은데 다른 관점에서 바라보면 어떨까 싶습니다. 외부에서는 CarView를 맘껏 변경(mutating)할 수 있지만 내부에서는 변경을 허락하지 않는 함수적인 요소가 유지되고 있습니다.

덕분에 내부는 테스트하기 매우 용이한 구조가 되었습니다. car를 생성하고 움직이면 새로운 위치에 있는 새로운 car를 얻게 됩니다. 이 car를 가지고 다시 새로운 위치로 움직이면 또 다른 car를 얻게 됩니다. 똑같은 위치로 car를 움직이면 항상 해당 위치에 있는 car를 얻게 되고 이것은 테스트 가능하고 반복 가능한 결과물입니다. 외부는 변경 가능한 부분이고 다른 것들과 관계를 맺는 부분이기도 합니다. 이것이 이 구조가 동작하는 방식이며 MVC 구조와도 유사합니다.

interaction with a combo

뷰 컨트롤러들끼리 서로 통신하지만 뷰 컨트롤러는 뷰와의 통신은 숨깁니다. 또한, 뷰 컨트롤러는 모델과의 통신도 숨기는데 이것은 우리 코드를 구성하는 또 다른 방법일 뿐입니다. 함수적인 방법과 OO가 우리를 대신해서 이러한 것들을 가능하게 도와주며, 이것은 어떤 문제에 대한 해결책이 상황에 따라 다를 수 있다는 것을 보여줍니다.

변경되지 않는 함수와 이것을 둘러싼 변경 가능한 환경을 갖고 있는 구조를 갖게 되었는데 더 좋은 소식은 Monads가 필요 없다는 것입니다. 하스켈(Haskell)은 왜 Monads가 필요할까요? Haskell은 변경이 허용되지 않기 때문에 외부 상태, 내부 상태를 캡쳐하고 변경하고 통신할 수 있는 수단이 필요했습니다. 이를 위해 하스켈은 Monads를 사용했지만, Swift는 순수 함수 언어가 아니기 때문에 그럴 필요가 없습니다.

Monads는 필요 없습니다.

우리는 하스켈, 스칼라, 함수형 프로그래밍으로부터 Monads가 어떤 역할을 하는지 충분히 알 수 있습니다. 하지만 Swift 언어에서는 Monads가 꼭 필요한 것은 아닙니다. Swift에는 클래스, 구조체가 있으며, 이들의 역할을 명확하게 나눌 수 있고 외부와 언제 통신할 것인지 알고 있다면 클래스와 구조체를 적절하게 사용할 수 있습니다.

어떤 사람은 Swift에는 이미 Monads가 있지 않냐고 물을 수도 있겠네요. Optionals이 Monads이기 때문이죠. 중요한 것은 Monads를 가지고 있느냐가 아니라 Monads가 필요 없다는 사실입니다. Swift 환경에서는 Monads가 필요하지 않습니다. 또한, Swift가 함수적인 패러다임을 지원하기 때문에 함수적 패러다임의 방법으로 코드를 작성하는 것도 가능하지만 순수 함수형 언어가 아니기 때문에 반드시 그렇게 해야 하는 것은 아닙니다.

우리는 구조차와 클래스를 모두 사용할 수 있으며, letvar, 변경 불가능한 것과 변경 가능한 것을 함께 사용할 수 있습니다. 중요한 사실은 Swift는 둘 중 가장 좋은 것을 사용한다는 것이지요. Swift는 함수적인 요소를 지원하지만 강제하지는 않습니다.

물론, 프로그래밍 언어를 대할 때는 감상에 빠져서는 안 됩니다. Objective C에서도 이러한 것들을 private형태로 카테고리나 클래스 extension을 이용해서 구현할 수 있지만 보다 중요한 것은 가능한 이것들을 분리해서 유지해야 한다는 것입니다. 또한, Monads가 필요 없다는 것도 잊지 마세요.

Swift를 이용한 함수형 언어 교육 및 학습

함수형과 Swift를 이용한 함수형 프로그래밍 교육과 학습에 관해 좀 더 이야기해보도록 하죠. 많은 분들이 for 루프는 안 좋기 때문에 map이 필요하다는 말을 들어봤을 것 같네요. 하지만, for 루프는 나쁘지 않습니다.

또한, map이 for 루프를 대체하는 것이라고 많은 사람들이 생각하고 있는데 이것 역시 사실이 아닙니다. 대부분의 예제들에서 map을 배열(array)과 함께 사용했기 때문에 이러한 오해가 발생한 것 같습니다. 배열과 함께 사용하는 map은 그 안에 for 루프가 숨겨져 있습니다.

여기 함수 하나가 있습니다.

public func string(from rawString: String) -> String {
    // returns the search string for iTunes store
}

// apply the function to a single String
string(from: "food and cooking")

아이튠스 스토어에서 검색어를 문자열로 입력하면 실제로 검색에 사용되는 단어들을 돌려주는 함수입니다. string(from: "food and cooking")과 같은 형태로 함수를 호출하면 food+and+cooking과 같은 결괏값을 돌려줄 것입니다. 아이튠스 스토어에서 검색해 보셨다면 검색 단어 사이에 공백이 +로 대체되는 것을 본 적 있을 겁니다.

이 함수의 타입은 String -> String입니다. 이 함수를 이용해 map을 구성하는 방법은 다음과 같습니다. [String]을 넘겨줄 때, String -> String 타입의 함수를 [String] -> [String] 타입의 함수로 변형합니다.

extension Array where Element == String {
    func searchStrings(using f: (Element) -> String) 
                                                  -> [String] {
        var result = [String]()
        for entry in self {
            result.append(f(entry))
        }
        return result
    }
}

Swift 3.1에서는 where Element == String과 같은 표현이 가능해졌습니다. 오랫동안 기다렸던 기능이죠. 이같은 문법 지원 덕분에, 위의 코드는 Array의 요소가 String일 때만 반응합니다. [String]이 넘어오면 String -> String 타입의 함수를 인자로 받는 searchStrings에게 전달되고, 이것은 다시 [String]을 받아 [String]을 리턴하는 함수에게 전달됩니다. 여기에서 일어나는 일은 [String]안의 각각의 요소들을 매핑하는 것이며 이것이 바로 map이 우리를 대신해서 하는 일입니다.

제가 좋아하는 것들을 검색어로 추가한 배열을 구성하고, searchStrings 함수를 호출해볼까요?

let danielsFaves = ["food and cooking", "programming", "math"]
danielsFaves.searchStrings(using: string)
danielsFaves.searchStrings{string(from: $0)}
// ["food+and+cooking", "programming", "math"]

searchStrings(using: string)과 같은 형태로 호출할 수도 있고 트레일링 클로저(trailing closure) 문법을 이용해 $0와 같은 형태로 호출할 수도 있습니다. 어떤 방식을 사용하든지 배열의 각각의 요소들을 string(from:) 함수와 매핑한 결과를 얻게 될 것입니다.

배열에 대한 map

배열에 대한 map을 정리하자면 다음과 같습니다. A -> B 타입의 함수가 있고,

f: A -> B

이 함수를 [A]를 전달받아 [B]를 돌려주는 것으로 바꾸고 싶습니다. [A] -> [B]map(f)로 표현할 수 있습니다.

map(f): [A] -> [B]

좀 더 일반적으로 말하면 map(f)A[A]로 바꾼 것(map(A)로 표현됨)을 전달받아 B[B]로 바꾼 것(map(B)로 표현됨)으로 변형하는 것을 말합니다.

map(f): map(A) -> map(B)

강연 중에 위의 내용을 반복적으로 언급할 것입니다. 이 과정에서 map에 대해 좀 더 확실히 이해할 수 있길 바랍니다.

danielsFaves.map(string)
danilesFaves.map{string(from: $0)}

map은 Swift 스탠다드 라이브러리에 포함되어 있기 때문에 위의 예제에 사용된 map 함수 호출 결과는 동일합니다. 다시 한번 말하지만 for 루프는 나쁜 것이 아니며, map은 for 루프를 대체하기 위한 것이 아닙니다. 이를 증명하기 위해 map과 optional을 살펴보도록 하죠.

Map과 Optionals

let danielsFaves = ["food and cooking", "programming", "math"]
let kimsFaves = [String]()

제가 좋아하는 것과 Kim이 좋아하는 것을 배열로 구성하였습니다. Kim은 아직 아무것도 좋아하지 않기 때문에 빈 배열로 구성했습니다. danielsFaves에서 첫 번째 요소를 얻고 싶다고 가정해보죠. 배열의 첫 번째 요소를 요청하면 첫 번째 요소가 없을 수도 있기 때문에 optional 타입의 결괏값을 얻게 됩니다.

// The result of danielsFaves.first is optional
string(from: danielsFaves.first)

위의 코드는 동작하지 않습니다. 왜냐하면 string(from:) 함수에 전달되는 인자는 String 타입인데 String? 타입을 전달했기 때문이죠. 이를 해결하려면 let을 이용한 optional 바인딩이 필요합니다.

if let first = danielsFaves.first {
   string(from: first) 
}

String?을 언랩해서 first 변수에 저장했기 때문에 위의 코드는 에러 없이 동작합니다. 만약 KimsFaves에 대해 optional 바인딩을 적용하면 현재는 배열에 아무 값도 저장되지 않았기 때문에 nil이라는 결괏값을 얻게 되고 if문 블럭은 건너뛰게 됩니다.

// it's nil
if let first = KimsFaves.first {
   string(from: first) 
}

String -> String 타입의 함수를 String? -> String? 타입의 함수로 변환하고 싶습니다. 우리는 StringInt타입으로 변환하는 것처럼 어떤 타입을 다른 타입으로 매핑하는 함수를 만들고자 합니다. IntDouble로 매핑하는 함수를 만들 수도 있습니다. Int를 전달받아 Int를 결과로 내놓는 구조를 갖는 파이프라인을 상상해볼까요? 파이프라인 안에서는 함수를 합성할 수도 있습니다. 파이프라인 안에 StringInt로 바꾸는 함수를 구성하면 어떤 함수든지 String -> Int 타입으로 구성되어 있다면 이 파이프라인을 사용할 수 있습니다.

String 대신 [String]을 사용해서 결괏값으로 [Double] 타입을 돌려주는 것도 가능합니다. map 안에서 함수들을 합성하여 여러 가지 조합을 만들어 낼 수 있으며, 이것이 map이 하는 일의 전부입니다. map은 어떤 타입에서 다른 타입으로 변형하는 것을 도와줍니다.

Optionals

extension Optional where Wrapped == String {
    func searchString(using f:(Wrapped) -> String) 
                                            -> String? {
        switch self {
        case .none:
            return nil
        case .some(let value):
            return .some(f(value))
        }
    }
}

array 예제처럼 Optional에도 map을 적용해보았습니다. array에서 그랬던 것처럼, 여기서도 String -> String 타입의 함수를 String? -> String? 타입의 함수로 변환했습니다.

switch 구문을 살펴볼까요? nil이 전달되면 nil을 반환합니다. nil이 아닌 것을 전달하면 nil이 아닌 어떤 것을 반환합니다.

String?을 전달했을 때, String?이 nil이면 nil을 반환하고, nil이 아니라면 매핑이 반복적으로 실행됩니다. .some에 해당하는 값이 전달되면 f(value)를 계산하고, 계산 결과를 .some 안에 싸서(wrap) optional 타입 형태로 반환합니다.

danielsFaves.first.searchString(using: string)
// Optional("food+and+cooking")

optional에 대해 extension으로 정의한 searchString 함수를 호출하면 결괏값으로 Optional("food+and+cooking")을 얻게 됩니다. array에서 for 루프를 숨겼던 것처럼 여기서는 switch 구문을 숨겼습니다. map이 for 루프를 대체하는 것이 아니라는 것을 확실히 보여주고 있네요. map은 어떤 상태를 다른 것으로 변형하는 작업을 숨기는 것이라고 할 수 있습니다.

kimsFaves.first.searchString(using: string)
// nil

kimsFaves은 빈 배열이기 때문에 none에 해당하여 nil을 반환합니다. optional에 대한 map은 배열에 대한 map과 정확히 똑같은 일을 하고 있습니다.

저는 f: A -> B 함수를 map(f): A? -> B?, 더 일반적으로 말하면 map(f): map(A) -> map(B)로 변형했습니다. 아시겠죠?

danielsFaves.first.map{string(from: $0)}
// Optional("food+and+cooking")

때문에 앞의 예제를 map을 이용한 코드로 변경할 수 있습니다. kimsFaves에 대해 호출할 경우 nil이라는 결괏값을 반환합니다.

kimsFaves.first.map{string(from: $0)}
// nil

이 모든 것을 종합해서 요약하자면 f: A -> Bmap(f): map(A) -> map(B)로 바꾼 것입니다.

나만의 map 함수 만들기

map이 무엇인지 이해했다면 이제 여러분만의 map을 작성할 수 있을 것입니다. Result 타입을 참고해서 자신만의 map 함수를 작성하는 방법을 배워보도록 하죠.

enum Result<Value, Error> {
    case success(Value)
    case failure(Error)
}

Swift에 Error 타입이 도입되기 전에는 에러 처리를 위해 많은 사람들이 Result 타입을 사용했는데 지금도 여전히 사용하고 있습니다. Result 타입은 두 개의 case 문으로 구성된 enum 타입입니다. 각각의 case는 Value를 갖거나 Error를 갖고, success에 해당되는 경우 관련된 Value가 success case에 전달되며, failure 상황이면 failure case에 Error가 포함되어 전달될 것입니다.

Result 타입은 Optional과 그 구조가 유사합니다. success case는 some case에 해당하고 failure case는 none case에 해당한다고 볼 수 있습니다. 어떤 Result 타입에서 다른 Result 타입으로 매핑하는 코드를 작성한다면 Result<A, Error> -> Result<B, Error>와 같은 구조일 것입니다.

양쪽이 모두 nil인 경우도 있듯이, 양쪽 모두가 Error 타입일 수도 있습니다. 이들 간의 차이점은 f: A -> BResult<A, Error> -> Result<B, Error>로 변형한 것입니다.

Result 타입의 extension을 작성해봅시다. Result의 ValueError는 제너릭이므로, f: A -> Bf: (Value) -> TargetValue과 같은 형태로 표현할 수 있으며, 반환하는 타입은 Result<TargetValue, Error>와 같은 모습일 것입니다.

extension Result {
    func map<TargetValue>(_ f: (Value) -> TargetValue) 
                                -> Result<TargetValue, Error> {
        switch self {
        case .failure(let error):
            return .failure(error)
        case .success(let value):
            return .success(f(value))
        }
    }
}

Optional에서 했던 것처럼 self에 대한 switch 구문을 사용했습니다. failure case일 때는 nil에서 했던 것처럼 error를 매핑시킵니다. value는 f(value)와 바인딩해서 결괏값을 얻는 데 사용했습니다. Result 타입을 빠르게 살펴봤는데요, 나머지 부분은 여러분의 연습을 위해 남겨두겠습니다.

map의 문제점은 이것이 항상 특정 타입의 요소를 포함하고 있는 컨테이너라는 점입니다. [A], A?, Result<A, Error>처럼 말이죠.

컨테이너가 아닙니다.

컨테이너가 아닌 map의 예제를 보여드리죠.

struct DoubleFunc<A> {
    let theDouble: (Double) -> A
}

Double을 다른 것과 매핑하는 구조체입니다. DoubleString으로 만드는 함수를 만들 수 있고, DoubleURL로 만드는 함수를 만들 수도 있으며, Double을 어떤 타입으로든 변형할 수 있는 제너릭한 구조입니다. theDouble 속성은 Double을 제너릭 타입으로 변형하는 함수라고 생각하면 됩니다.

인스턴스를 생성해보겠습니다.

let timesOneThroughFour = DoubleFunc<[Double]> {
    x in 
    return [x * 1.0, x * 2.0, x * 3.0, x * 4.0] 
}

[Double] 타입을 반환하는 timesOneThroughFour를 생성했고, 배열에 정의된 네 가지 요소들과 매핑된 값이 리턴될 것입니다.

다음과 같이 호출하면,

timesOneThroughFour.theDouble(5.2)
// [5.2, 10.4, 15.6, 20.8]

5.21.0부터 4.0까지 차례대로 곱한 값의 결과가 배열에 저장되어 리턴됩니다. 다른 예제를 살펴볼까요?

let stringRepresentation = DoubleFunc<String>{ 
    x in 
    return x.description
}

이번에는 description을 출력하는 것을 구현하고 싶네요. 이를 위해, Double -> String 타입의 함수를 입력받아 x.description 형태로 리턴값을 출력했습니다.

stringRepresentation.theDouble(5.2)
// 5.2

이제, 이 타입을 위한 map 함수를 작성해볼까요? 함수의 타입은 Double -> 특정 타입입니다. 이전에 했던 것처럼 동일한 규칙(함수 lift)을 적용할 수 있습니다. 컨테이너가 아니라는 사실만 다를 뿐이죠.

We lift f: A -> B to map(f): map(A) -> map(B)

map 함수는 다음과 같습니다.

extension DoubleFunc {
    func map<B>(_ f: @escaping (A) -> B) -> DoubleFunc<B> {
        return DoubleFunc<B> {x in f(self.theDouble(x))}
    }
}

다른 map 함수에서 했던 것처럼, A -> B 타입의 함수를 인자로 전달합니다. 다른 map 함수들도 A -> B 타입의 함수를 인자로 받았었죠. 여기서, 기억해야 할 사실이 하나 있습니다. 그것은 바로 Double -> A 타입의 함수로 시작해서 Double -> B 타입의 함수가 결과물로 나와야 한다는 것입니다. 이것은 함수 합성을 통해서 만들 수 있습니다. Double을 받아 A을 얻은 다음, 함수 f를 통해 AB로 바꾸면 됩니다. 이러한 합성은 map 함수 안에서 이루어집니다.

이 함수의 결과물은 Double -> B 타입이 될 것이며, 이를 위해 함수 fDoubleFunc<B>를 합성했습니다. (코드에서 DoubleFunc<B> {x in f(self.theDouble(x))}를 의미합니다.) A에서 B로 매핑하는 것이 항상 필요하기 때문에 [Double]String과 매핑하는 함수를 다음과 같이 작성했습니다.

func displaySum(_ array: [Double]) -> String {
    return array.reduce(0, +).description
}

위의 코드는 모든 Double 값들을 더한 다음, 그 결과를 출력합니다.

timesOneThroughFour.map{displaySum($0)}.theDouble(5.23)
// 52.3

아래의 규칙을 적용해서 우리만의 map 함수를 작성했습니다. 세부적인 변환 과정을 하나씩 살펴보도록 하죠.

f: A -> B to map(f): map(A) -> map(B)

우선 우리의 예제에서 A -> B는 다음과 같이 변환할 수 있습니다.

We lift f: [Double] -> String to map(f): map(A) -> map(B)

그렇다면, map(A)map([Double])로 표현할 수 있겠네요.

We lift f: [Double] -> String to map(f): map([Double]) -> map(B)

map([Double])이 의미하는 것은 무엇일까요? 이것은 바로 (Double) -> [Double] 타입의 함수입니다.

We lift f: [Double] -> String to map(f): ((Double) -> [Double]) -> map(B)

이제 String을 살펴보죠. A -> B[Double] -> String으로 변환되었기 때문에 map(B)map(String)으로 표현할 수 있겠네요.

We lift f: [Double] -> String to map(f): ((Double) -> [Double]) -> map(String)

map(String)은 무엇일까요? Double -> String 타입의 함수입니다.

We lift f: [Double] -> String to map(f): ((Double) -> [Double]) -> (Double) -> String

내부 변환 과정이 이상하게 보일지 모르겠지만, 결과적으로 우리는 map(f) : map([Double]) -> map(String)을 수행한 것입니다. Double -> [Double] 타입의 함수를 Double -> String으로 변환한 것이죠.

Write your own Map

map(A)Double[Double]로 변환하고, 함수 fDouble을 전달받아 String을 리턴합니다. 위의 다이어그램이 변환과정을 잘 보여주고 있습니다. 최종적인 변환 결과물은 대각선이 보여주고 있습니다. “음. 25분간 강의를 들었지만 지금 무슨 말을 하는지 모르겠다고 하실지도 모르겠네요.”

중요한 것은 map 함수를 직접 작성해보면 map이 그렇게 어려운 개념이 아니라는 것입니다. map은 변화 가능하고 유연한 외부(OO에서 제공하는)로부터 함수적인 핵심 기능을 내부에 숨길 수 있는 방법 중 하나입니다. 그렇기 때문에 함수가 필요한 것이죠.

질의응답

질문: map에 대한 강연을 하셨는데 이밖에도 reduce나 다른 기능들도 많이 있습니다. 이들에 대해서도 강연에서 언급한 개념들을 적용할 수 있는지 알고 싶습니다.

답변: 음, 우선 간단하게 답변하자면 그렇다고 할 수 있습니다. 구조체나 배열, optional이 있고 이들이 map을 지원한다면 당신은 functor를 가지고 있다고 말할 수 있습니다. 그래서 map은 특별합니다. 또한, 우리가 flatMap을 가지고 있다면, 우리는 이미 Monad를 가지고 있는 것입니다.

또한, 우리는 사용상의 편의를 제공하는 reduce, filter와 같은 함수들을 가지고 있습니다. 중요한 것은 이들 함수에 인자로 전달해도 그 인자를 변경하지 않는다는 것입니다. 어떤 것을 인자로 전달하면 변환과정을 거쳐 어떤 결과물이 나오지만 인자로 전달된 것을 변경하지는 않습니다. 배열의 sortsorted 메서드의 차이점을 생각해보세요. 당신이 필요한 것이 기존 배열 안에 저장된 요소들의 위치를 변경해서 정렬하는 것인가요? 아니면 기존 배열은 변경하지 않은 상태에서 정렬된 배열만 결과물로 얻고 싶은 것인가요?

이들 함수적인 요소들을 이용해서 여러분이 해야 할 일은 변경이 필요 없는 코드와 변경이 필요한 코드를 분리하는 것입니다. Gary Bernhardt의 강연에서 이와 관련된 추가 정보를 얻을 수 있을 것입니다. 만약 이와 같은 방식으로 코드 구조를 변경하면 훨씬 유지 보수하기 쉬운 코드가 될 것입니다. 어떤 사람들은 일종의 종교처럼 다음과 같이 말하곤 합니다. “더는 MVC가 필요 없어. MVVM이 답이야.”. 그렇지 않습니다. 그것은 단지 변경이 필요한 것과 그렇지 않은 것을 구분하는 방법일 뿐입니다. 용어가 다를 뿐이죠.

다음: Realm Obj-C와 Realm Swift의 새로운 기능을 소개합니다.

General link arrow white

컨텐츠에 대하여

2017년 6월에 진행한 AltConf 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Daniel Steinberg

Daniel은 ‘A Swift Kickstart’와 스탠포드 대학의 유명한 iTunes U 시리즈의 공식 서적인 ‘Developing iOS 7 Apps for iPad and iPhone’의 저자입니다. Dim Sum Thinking 사에서 iPhone, Cocoa 및 Swift 교육 및 컨설팅을 제공하고 있습니다. 또한 CocoaConf 팟캐스트의 호스트이기도 합니다.

4 design patterns for a RESTless mobile integration »

close