[번역] Point-Free #1 함수

본문 링크

첫 번째 에피소드는 함수에 대한 내용을 다룹니다! 함수의 어떤 점이 함수를 특별하게 만드는지 얘기하고 보통 우리가 사용하는 방법과 비교해볼 예정입니다. 그리고 연산자와 합성에 대해서 탐험적인 얘기도 나눠보겠습니다.

소개

Point-Free의 첫 번째 에피소드에 오신 것을 환영합니다! 저희는 앞으로 다양한 함수형 프로그래밍의 개념을 알아볼 예정입니다. 함수란 무엇일까요? 함수란 입력과 출력이 있는 계산이라고 할 수 있습니다.

예시로 Int를 받아서 하나 더한 Int를 반환하는 함수를 정의해보겠습니다.

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

이 함수를 호출하기 위해 값을 넣어보겠습니다.

incr(2) // 3

이번엔 정수를 제곱하는 함수인 square를 정의해보겠습니다.

func square(_ x: Int) -> Int {
  return x * x
}

당연히 앞에서 했던 것처럼 호출할 수 있습니다.

square(2) // 4

먼저 호출한 결과를 받아서 제곱하고 싶다면 중첩해서 사용해야 합니다.

square(incr(2)) // 9

두 함수는 꽤 간단하지만 Swift에서는 그렇게 흔한 함수는 아닙니다. 일반적으로 메소드는 최상단 프리 펑션으로 만들지 않기 때문입니다.

그러니 Int 타입에 incrsquare를 정의해보겠습니다.

extension Int {
  func incr() -> Int {
    return self + 1
  }

  func square() -> Int {
    return self * self
  }
}

이렇게 만든 incr 메소드는 직접 호출해서 사용할 수 있습니다.

2.incr() // 3

이 결과에 대해서 제곱 연산을 하고 싶다면 체이닝을 하면 됩니다.

2.incr().square() // 9

프리 펑션으로 만들었을 땐 안에서 바깥으로 나가면서 연산을 해야 했다면, 위 방식은 왼쪽에서 오른쪽으로 읽으면 되기 때문에 훨씬 보기 좋습니다. 그리고 함수를 중첩해서 사용하는 경우엔 square를 사용하기 전에 incr를 호출한다는 사실을 인식하기 위해 더 많은 생각을 해야 합니다. 이러한 이유로 Swift에서 프리 펑션이 많지 않은 것 같습니다. 복잡하게 중첩된 함수일수록 해석하기가 어렵다고 생각할 수 있습니다. 오히려 간단한 표현식일수록 전통적인 중첩 방식으로 표현하기 어렵습니다. 메소드엔 이러한 문제가 존재하지 않습니다.

파이프 연산자(|>)를 소개합니다

프리 펑션을 사용하지만 가독성을 지키는 방법에는 infix 연산자를 함수 어플리케이션에 사용하는 것이 있습니다. 이는 다른 언어도 많이 지원하고 있으며 Swift는 우리만의 연산자를 정의할 수 있게 해주기 때문에 다음과 같이 새로운 연산자도 만들 수 있습니다.

infix operator |>

여기선 직접 정의했지만, F#, Elixir, Elm과 같은 언어에선 이미 기본적으로 함수 어플리케이션에 사용하고 있는 “pipe-forward” 연산자라고 합니다.

이 연산자를 정의하기 위해 함수를 다음과 같이 작성하겠습니다.

func |> <A, B>(a: A, f: (A) -> B) -> B {
  return f(a)
}

이 함수는 AB라는 제네릭 타입을 받습니다. 왼손엔 A 타입을 가지는 값을, 오른손엔 A에서 B로 가는 함수를 가집니다. 그리고 결과적으로는 왼손의 값에 오른손의 함수를 적용한 결과인 B를 반환합니다.

연산자가 완성됐으니 이제 프리 펑션에도 값을 읽기 좋게 넣을 수 있게 되었네요.

2 |> incr // 3

그리고 프리 펑션의 결과를 또 다른 프리 펑션에 넘기는 것이 가능해야 합니다.

2 |> incr |> square

하지만 에러가 발생할 것입니다.

Adjacent operators are in non-associative precedence group 'DefaultPrecedence'

우리의 연산자가 한 줄에 여러 번 쓰였을 때, Swift는 어떤 방향에 있는 연산자를 먼저 계산해야할지 모를 것입니다. 왼쪽을 먼저 계산한다면 아래와 같은 부분을 먼저 계산할 것입니다.

2 |> incr

incr가 정수를 입력으로 받기 때문에 incr2를 넣는 것은 성립합니다. 오른쪽엔 무엇이 있는지 볼까요?

incr |> square

square는 함수가 아닌 정수를 입력으로 받기 때문에 square 함수에 incr 함수를 넣는 것은 말이 안 될 수 있습니다.

이럴 때 우리는 Swift에게 힌트를 줘서 어떤 연산을 먼저 할 지 알려줘야 합니다. 가장 간단한 방법은 괄호로 묶어서 우선 순위를 정하는 것이죠.

(2 |> incr) |> square // 9

이 방법은 당연히 작동하겠지만 식이 길어지고 복잡해질수록 코드에 괄호가 많아져서 추적하는 것만으로도 일이 될 것입니다. Swift에게 조금 더 괜찮은 힌트를 줘보는 것은 어떨까요?

Swift는 우리에게 연산자가 어디를 우선으로 계산할지 정의할 수 있게 해두었습니다. 함수 어플리케이션을 위한 우선순위 그룹을 정의해보겠습니다.

precedencegroup ForwardApplication {
  associativity: left
}

위 코드에서 우리는 associativity를 left로 정하면서 왼쪽 연산이 먼저 된다는 사실을 정해두었습니다.

이제 해야할 일은 우리의 연산자에 우선순위 그룹을 배정하는 것입니다.

infix operator |>: ForwardApplication

드디어 괄호 없이 코드를 작성할 수 있게 되었네요.

2 |> incr |> square // 9

결과로 나온 코드는 메소드 버전에서 작성한 코드랑 매우 닮았네요.

2.incr().square() // 9

막간을 이용한 연산자 이야기

중첩 함수가 가지고 있던 가독성 문제를 해결했지만 커스텀 연산자라는 새로운 문제가 생겼습니다. 커스텀 연산자는 흔하지 않습니다. 사실 대부분은 커스텀 연산자에 대한 좋지 않은 명성으로 인해 오히려 피하는 편입니다. 커스텀 연산자를 고려하는 일반적인 경우는 오버로드 연산자라는 아이디어에서 파생됐습니다.

C++에서는 새로운 연산자를 정의할 수 없지만 언어가 제공하는 기존 연산자를 오버로드할 수 있습니다. 만약 C++에서 벡터 라이브러리를 작성해야 한다면 + 연산자를 두 벡터를 합치도록 할 수 있고, * 연산자를 두 벡터를 합성하도록 만들 수 있습니다. 그러면 * 연산자는 두 벡터의 곱집합을 의미하게 되는데, 이는 * 연산자를 사용하는 것에 있어서 어떠한 지식이 필요해지기 때문에 혼란을 일으킵니다.

함수형 어플리케이션은 곱셈을 오버로드하는 것을 절대 추천하지 않습니다.

2 * incr // 도대체 무슨 뜻이야?!

위 예시는 실제 코드 베이스에서 만나기도 힘들 뿐만 아니라 무슨 의미인지 이해하기도 어렵습니다.

운좋게도 저희는 이러한 문제를 가지고 있지 않습니다. 왜냐하면 우리에겐 Swift는 몰랐던 |>라는 새로운 연산자가 있기 때문이죠. 누군가는 Swift가 모르면 Swift 개발자도 모른다고 말할 수 있겠지만, 이미 선배 언어(F#, Elixir, 그리고 Elm)에서는 쓰고 있는 방식입니다. Swift는 선배들이 했던 방식으로 발전하고 있기 때문에 이러한 연산자도 곧 익숙해질 것이라 예상합니다. 그리고 생긴 모양도 딱이라고 생각합니다! 파이프(|)는 Unix부터 시작된 프로그램의 출력을 다른 프로그램의 입력으로 넣을 때 사용하던 방식입니다. 그리고 오른쪽에 있는 화살표(>)는 왼쪽에서 오른쪽으로 읽는 우리에게 아주 큰 가독성을 제공합니다. 이 연산자의 사용법을 다시 한 번 살펴보시죠.

2 |> incr

이 연산자에 익숙하지 않더라도 우리는 이 코드에서 어떤 일이 일어나는지 어느정도 추측할 수 있습니다.

앞으로도 Point-Free에선 연산자를 아주 많이 쓸 예정이니, 저희가 책임을 지고 이 연산자를 정당화해보겠습니다. 새로운 연산자를 소개할 때 꼭 체크하는 몇 가지 사항이 있습니다.

  1. 이미 존재하는 연산자에 새로운 의미를 부여하지 않는다.

  2. 연산자가 가장 좋은 “모양”을 가지도록 해서 모양만으로 의미를 이해할 수 있게 합니다. |> 연산자의 경우 왼쪽에서 오른쪽으로 값을 넘긴다는 것을 모양만으로 설명합니다.

  3. 도메인 특화된 문제를 해결하기 위해 새로운 연산자를 발명하지 않습니다. 새로운 연산자는 매우 일반적으로 사용할 수 있어야 하고 재사용 또한 용이해야 합니다.

|> 연산자를 모든 체크리스트에 부합하네요.

자동 완성도 가능할까요?

연산자가 가독성을 높여준다고 해도 여전히 메소드에 비하면 모자란 기능이 있습니다. 바로 자동 완성이죠.

Xcode에선 값에 대고 마침표를 입력하면 사용 가능한 모든 메소드 목록이 제공됩니다.

그리고 몇 글자 입력해서 나오는 목록을 줄일 수 있습니다.

이 점은 코드 발견성(discoverability)에 있어서 아주 좋은 점이고 메소드의 승리라고 할 수 있습니다. 하지만 자동 완성이 메소드에 실제로 하는 일은 아무것도 없습니다. 자동 완성은 프리 펑션과도 잘 작동합니다.

아직 우리의 IDE는 값과 |>를 제공했을 때 자동 완성을 해줄 수 있는 기능을 가지고 있지 않습니다. 미래의 Xcode는 이 기능이 더 나아지기를 바랍니다.

>>>를 소개합니다

한편, 메소드 세계에선 일어날 수 없던 일이 프리 펑션 세계에서는 가능합니다. 바로 함수 합성입니다. 함수 합성은 하나의 출력이 다른 하나의 입력과 동일한 두 개의 함수를 받아서 둘을 붙인 완전 새로운 함수를 만듭니다. 함수 합성을 위한 새로운 연산자를 소개하겠습니다.

infix operator >>>

위 연산자는 “전위 합성(forward compose)” 또는 “오른쪽 화살표(right arrow)” 연산자로 알려져있습니다. 이제 정의해보겠습니다.

func >>> <A, B, C>(f: @escaping (A) -> B, g: @escaping (B) -> C) -> ((A) -> C) {
  return { a in
    g(f(a))
  }
}

A, B 그리고 C 총 세 개의 제네릭 파라미터를 받는 제네릭 함수입니다. 그리고 A에서 B로 가는 함수와 B에서 C로 가는 함수를 받아서 A에서 C로 가는 함수를 반환합니다.

이제 incr 함수와 square 함수에 적용해보겠습니다.

incr >>> square

이제 우리에겐 받은 값에 1을 더하고 제곱하는 (Int) -> Int 함수가 생겼습니다.

순서를 바꾸면 값을 받아서 제곱하고 1을 더하는 새로운 함수도 생깁니다.

square >>> incr

이 함수는 당연히 전통적인 방식으로 호출이 가능합니다.

(square >>> incr)(3) // 10

가독성이 좋진 않지만 |> 연산자를 쓰면 훨씬 나아질 것입니다.

2 |> incr >>> square

불행히도 또 다른 에러가 발생합니다.

Adjacent operators are in unordered precedence groups 'ForwardApplication' and 'DefaultPrecedence'

우리는 두 연산자를 섞어서 사용했는데 Swift는 둘 중 어느 연산자를 먼저 사용할지 모릅니다. 우리의 함수는 값을 적용하기 전에 합성돼야 합니다. 값이 먼저 적용되고 합성한다면 다른 결과가 나올 것입니다.

우선순위 그룹을 사용해서 괄호 없이 이 문제를 해결해보겠습니다. 함수 합성을 위해 새로운 우선순위 그룹을 정의해봅시다.

precedencegroup ForwardComposition {
  associativity: left
  higherThan: ForwardApplication
}

이제 ForwardApplication보다 더 높은 우선순위를 가진다고 정의했으니 먼저 호출될 것입니다. 이제 화살표 연산자에 적용만 하면 되겠습니다.

infix operator >>>: ForwardComposition

이제 연산자는 깔끔하게 잘 작동할 것입니다.

2 |> incr >>> square // 9

새로운 연산자에 기뻐하기 전에 이전에 확인했던 것처럼 체크리스트를 한 번 봅시다.

  1. Swift에 존재하지 않아서 혼란을 일으키지 않는 연산자입니다.

  2. Haskell, PureScript 등 다른 선배 언어들과 그들의 거대한 커뮤니티가 이미 사용하는 개념입니다. 게다가 모양 자체가 왼쪽에서 오른쪽으로 가기 때문에 합성의 방향과도 어울립니다.

  3. 이 연산자는 꽤 일반적이라고 할 수 있는 세 개의 제네릭 타입을 입력받고, 함수 합성이란 것은 매우 보편적인 사항이니 일반적인 문제를 해결한다고 할 수 있겠네요.

>>> 연산자도 모든 체크리스트를 통과하네요!

메소드 합성

메소드 세계의 함수 합성은 어떻게 생겼을까요? 메소드를 합성하려면 그 둘을 합성하는 새로운 메소드를 만드는 방법 밖에 없습니다.

extension Int {
  func incrAndSquare() -> Int {
    return self.incr().square()
  }
}

실제로 쓸 때는 새로운 메소드를 호출하면 됩니다.

2.incrAndSquare() // 9

작동은 하지만 이 방법을 사용하기 위해 코드를 다섯 줄이나 작성했습니다! 그리고 제일 중요한 부분이라 생각되는 square().incr() 는 코드의 아주 적은 부분을 차지합니다. 합성을 하기 위한 노력과 보일러 플레이트 코드가 많이 들 경우, 자기 자신에게 물을 필요가 있습니다. 이렇게까지 할 만큼 가치있는 일인가?

그에 비해 함수 합성은 코드 몇 자가 전부입니다.

incr >>> square

게다가 함수 합성은 코드를 가능한 단위로 잘라서 재사용도 가능합니다. 만약의 경우 함수 합성의 일부분을 지우더라도 프로그램이 유효하지 않아지는 경우는 없습니다.

2 |> incr >>> square
// 어떤 부분을 잘라내도 여전히 컴파일됩니다
2 |> incr
2 |> square
incr >>> square

메소드를 사용하는 경우 값이 없다면 합성 자체가 성립이 되지 않습니다.

// 유효함
2.incr().square()

// 유효하지 않음
.incr().square()
incr().square()

이러한 이유로 메소드는 기본적으로 재사용에 강한 편은 아닙니다.

보통 우리는 함수보다는 메소드를 더 많이 쓰는 것 같다고 생각하지만, Swift에서는 의식하지 못 할 정도로 매일 함수를 사용하고 있습니다.

매일 쓰는 가장 보통의 함수 중 하나는 바로 이니셜라이저입니다! 이니셜라이저는 값을 생성하는 글로벌 함수입니다. 그리고 Swift의 모든 이니셜라이저는 함수 합성이 가능합니다. 아래와 같이 이전의 합성을 받아서 String 이니셜라이저에 forward-compose할 수 있습니다.

incr >>> square >>> String.init
// (Int) -> String

그리고 값을 넣으면 스트링으로 된 결과가 나올 것입니다.

2 |> incr >>> square >>> String.init // "9"

메소드 세상에선 이니셜라이저에 결과를 체이닝할 수 없습니다. 이니셜라이저를 메소드와 함께 쓰려면 메소드를 통째로 감싸야해서 왼쪽에서 오른쪽으로 읽는 방향이 부자연스러워집니다.

String(2.incr().square())

그리고 이니셜라이저가 제공하는 프리 펑션도 많습니다. 또한 표준 라이브러리에는 프리 펑션을 입력으로 받는 함수들이 많습니다. 예를 들어, Array에는 map이라는 메소드가 존재합니다.

[1, 2, 3].map
// (transform: (Int) throws -> T) rethrows -> [T]

이 메소드는 배열의 원소를 받아서 T 타입으로 바꿔버리는 프리 펑션을 받아서 모든 원소가 T 타입을 가지는 배열로 변환합니다.

보통 여기엔 애드혹 함수를 넘깁니다. 예를 들면 increment나 square가 있곘네요.

[1, 2, 3].map { ($0 + 1) * ($0 + 1) } // [4, 9, 16]

메소드만 사용하는 경우는 재사용성을 같이 가져가기가 힘듭니다. 하지만 우리는 함수를 사용하고 있으니 바로 재사용 가능하게 만들어보겠습니다.

[1, 2, 3]
  .map(incr)
  .map(square)
// [4, 9, 16]

여기서 우리는 새로운 애드혹 함수를 만들거나 인자를 지정하지 않았는데요, 이러한 방식을 “point-free” 스타일이라고 합니다. 함수를 정의하고 인자를 지정할 때($0도 인자입니다), 이 인자들을 “points”라고 봅니다. “point-free” 방식의 프로그래밍은 함수와 합성을 강조하기 때문에 데이터가 어떻게 바뀌는지는 알 필요가 없습니다. 이 사이트의 이름도 여기서 따왔죠!

그리고 배열에 square를 적용하고 그 결과에 incr을 적용하는 것과 함수 합성의 결과가 동일합니다. 그러면 우리는 squareincr을 forward-compose할 수 있겠네요.

[1, 2, 3].map(incr >>> square) // [4, 9, 16]

정말 끝내줍니다! 메소드에서는 보기 힘든 합성 연산자와 map사이의 관계가 보이시나요? 여기선 map>>> 합성에 의해 분배됐다고 볼 수 있습니다. 두 함수에 적용된 map에 의한 합성이 하나로 합쳐져서 map으로 들어갔습니다. 이러한 패턴은 앞으로도 많이 볼 것이니 기대해주세요!

그래서 요점은 무엇인가요?

잠시 멈춰서 자기 자신에게 물어보는 시간을 가져봅시다. 그래서 요점은 무엇인가요? 왜 이러한 것들을 하는걸까요? 이 에피소드에선 두 개의 커스텀 연산자를 소개하고 프리 펑션으로 전역 예약어를 오염시켰습니다. 우리 모두가 잘 알고있고 사랑하는 메소드를 왜 그만 사용해야 할까요?

오늘 작성한 “메소드가 할 수 없는 함수 합성”에 관한 코드가 함수를 실제 업무에 도입할 수 있는 큰 동기가 되었기를 바랍니다. 메소드로 기능을 합성하는 것은 많은 노력과 보일러 플레이트 코드가 필요합니다. 그리고 나중에 다시 코드를 봤을 때 해석하는 시간이 필요합니다. 몇 개의 연산자를 이어주기만 하면 이전에는 없었던 함수 합성의 세계가 열리고 여기서 기대되는 가독성 증대는 엄청날 것입니다!

또 괜찮은 점 중 하나는 Swift에는 걱정해야 할 “전역 예약어”가 없다는 것입니다. 문제가 생기면 여러가지 방법으로 스코프를 지정해서 피해갈 수 있습니다.

  • 파일에서 프라이빗하게 함수를 정의할 수 있습니다.

  • 구조체와 Enum의 스태틱 멤버인 함수를 정의할 수 있습니다.

  • 모듈에 스코프가 걸린 함수를 정의할 수 있습니다. 같은 이름을 가진 함수를 정의하는 라이브러리들을 보신 적이 있을 것입니다. 하지만 그들은 모두 모듈 이름으로 다름을 보장받기 때문에 문제가 생기지 않습니다.

이렇게 말할 수 있겠네요. “함수를 무서워하지 마세요.”

Point-Free 시리즈에선 함수를 아주 많이 사용할 예정입니다. 아마도 프리 펑션이 없는 에피소드는 상상할 수 없는 수준일 것입니다. 저희의 계획은 함수와 합성으로 이루어진 아주 복잡한 시스템을 만드는 것입니다. 모든 부분 부분이 다같이 작동하는 방식을 보면 매우 아름답고 흥미로울 것입니다. 함수 합성은 우리가 지금까지 보지 못했던 것들을 볼 수 있게 도와줄겁니다.

이번 에피소드는 여기까지입니다. 다음 시간에 계속됩니다!