함수형 프로그래밍 정리
2019년 6월 15일 정리한 포스트입니다.
함수형은 함수를 이해하는 게 절반이다
함수형에서 이야기하는 함수는 수학에서 다루는 함수이다. 수학에서 다루는 함수는 입력값을 계산하고 출력값을 반환한다. 그리고 입력값은 항상 같은 출력값에 대응된다. 함수형에서 이야기하는 함수도 동일하게 동작한다. 함수는 인자라는 입력값을 받아 계산하고 반환값이라는 출력값을 반환한다.
자바스크립트는 수학에서 다루는 함수가 아닌 다른 형태의 함수를 만들 수 있다. 함수 밖에 정의한 변수를 외부 변수라고 하는 데, 자바스크립트는 함수내에서 외부변수를 접근가능하다. 함수내에서 외부 변수를 접근 가능하기 때문에 함수형에서 이야기하는 함수가 아닌 다른 형태의 함수를 정의할 수 있다. 하지만 함수형에서는 외부 변수를 접근하지 않는 것을 추구한다. 즉, 함수형의 함수를 이해하고 외부 변수를 접근하지 않는 함수를 정의하는 것이 절반이다.
이제 함수형에서 이야기하는 함수는 함수라고 이야기 하겠다.
함수를 활용하면 역할을 부여한 함수를 선언할 수 있다. 역할을 부여한 함수를 선언형 함수라고 부르는 데, 선언형 함수를 조립해서 높은 수준의 동작을 만든다. 그리고 인자에 항상 동일한 반환값을 반환하기 때문에 실행 시점을 변경할 수 있다. 실행 시점을 변경해도 같은 값을 반환하기 때문이다.
이제 자세한 설명과 예를 통해 알아보자.
함수란 무엇인가
함수는 하나의 동작 단위이다.
- 계산하는 동작
- 값을 대신 전달하는 동작
- 함수를 대신 실행하는 동작
이 모든 동작을 함수에게 역할을 부여할 수 있다.
함수의 구성은 세 가지가 있다. 동작을 의미하는 함수명, 함수의 외부에서 내부로 전달하는 인자, 함수의 내부에서 외부로 전달하는 반환값으로 이뤄진다. 함수는 외부 변수를 접근하지 않는 코드로 구현된다. 그리고 항상 반환값은 존재한다.
인자와 반환값
인자와 반환값은 연관관계가 있다. 인자를 기준으로 반환값은 항상 대응된다. 인자는 두 개 이상의 반환값에 대응되면 안된다. 함수가 실행 될때 마다 다른 반환값을 가지는 것은 외부 변수를 접근하는 것이다. 즉, 해당 함수는 함수라고 할 수 없다.
인자와 반환값은 함수를 조립하기에서 설명할 함수 조립과 연관있다. f
, g
라는 두 함수가 있다고 가정하겠다. f
는 x
를 인자로 받고 y
라는 반환값을 반환한다. 그리고 g
는 y
를 인자로 받고 z
라는 반환값을 반환한다. 이때 f
의 반환값과 g
의 인자가 같으므로 g(f(x))
로 표현이 가능하다. 이러한 표현을 함수 조립이라고 한다.
함수 조립의 예이다.
y = f(x)
z = g(y)
즉, z = g(f(x)) 가능하다.
프로시저와 함수
프로시저는 정해진 절차에 따라 내부 변수를 변경하는 동작 단위를 의미한다. 내부 변수를 변경하는 동작은 자율적으로 존재할 수 없다. 즉, 프로시저는 객체에 존재한다. 프로시저는 계산의 결과를 직접 내부 변수를 변경한다. 그래서 반환값을 반환하지 않는 다. 반환값을 반환하지 않기 때문에 명령(Command)라는 다른 이름도 가진다.
프로시저는 내부 변수를 변경한다.
const counter = {
count: 0,
up () {
this.count += 1
},
down () {
this.count -= 1
}
}
counter.up() // count 1
counter.up() // count 2
counter.down() // count 1
반면에 함수는 변수를 직접 변경하지 않는 다. 그리고 함수는 항상 반환값을 반환한다. 즉, 직접 변수를 수정하지 않고 반환값을 통해 외부에서 변수를 수정하게 한다. 그래서 항상 반환값을 반환하기 때문에 쿼리(Query)라는 다른 이름도 가진다.
함수는 반환값을 통해 외부에서 변수를 수정하게 한다.
const up = count => count + 1
const down = count => count - 1
let count = 0
count = up(count) // count 1
count = up(count) // count 2
count = down(count) // count 1
순수함수와 비순수함수
함수는 순수함수이다. 순수함수는 실행시점이 변경 되도 동일한 반환값을 반환한다. 항상 동일한 반환값을 반환하기 때문에 다루기 쉬운 함수가 된다.
순수함수는 외부변수를 접근하지 않는 다.
const add = (a, b) => a + b
const result = add(1, 2) // result 3
프로시저는 비순수함수이다. 외부 변수를 사용 또는 변경한다. 그래서 실행시점에 따라 다른 효과를 가지기 때문에 실행시점을 미세하게 다뤄야 한다.
비순수함수는 외부변수에 접근한다.
let result
const add = (a, b) => {
result = a + b
}
add(1, 2) // result 3
함수를 조립하기
함수를 조립하는 것은 함수의 반환값을 다른 함수에 인자로 전달할 때 변수를 사용하지 않고 전달하는 것이다. 예를 들면 f
함수의 반환값을 g
함수의 인자에 변수에 담아 전달할 수 있다.
y = f(x)
z = g(y)
이 때 f
함수의 반환값 y
와 g
함수의 인자가 일치한다. 함수를 조립해서 표현하면 이렇게 표현 할 수 있다.
z = g(f(x))
이렇게 변수를 최소화하고 함수를 나열하여 원하는 결과를 얻는 것을 함수를 조립하는 것이다.
함수 조립이 가능한 이유는 두 가지있다. 첫 번째는 함수의 인자와 반환값이 항상 대응하기 때문이다. 두 번째는 함수는 외부 변수를 수정하지 않기 때문이다. 이 두 가지의 특징을 가지기 때문에 함수를 조립할 수 있다.
함수를 조립하는 방법은 세 가지를 소개한다. 첫 번째는 함수로만 조립하는 것이다. 두 번째는 함수로만 이뤄진 조립을 읽기 쉽게 기술한 Compose가 있다. 세 번째는 Compose와 반대 순서로 동작하는 Pipe이다.
함수로만 조립하기
함수로만 조립하는 것은 일렬로 함수를 나열하여 동작을 작성하는 방법이다. 예를 들어 f
, g
, h
함수가 있다고 가정하겠다. f
의 반환값이 g
의 인자가 같고 g
의 반환값이 h
의 인자가 같을 때 h(g(f(x)))
로 작성이 가능하다. 이렇게 작성하는 것이 함수로만 조립하는 방법이다.
아래와 같은 코드를 함수 조립을 바꾸면
const extract = ({a, b}) => [a, b]
const add = ([a, b]) => a + b
const isEven = num => num % 2 === 0
const obj = {a: 2, b: 4}
const nums = extract(obj)
const total = add(nums)
isEven(total) // true
아래와 같이 작성한다.
const extract = ({a, b}) => [a, b]
const add = ([a, b]) => a + b
const isEven = num => num % 2 === 0
const obj = {a: 2, b: 4}
isEven(add(extract(obj))) // true
Compose로 조립하기
Compose는 함수를 조립하는 도구 중 하나이다. Compose의 구성은 함수 타입의 인자와 인자를 주입받을 수 있는 함수를 반환값으로 한다. 반환값이 실행되면 Compose의 인자들을 모두 실행하여 계산 결과를 반환한다.
Compose의 마지막 인자를 시작해서 첫번째 인자 순서로 함수를 실행한다. 코드로 표현하면 아래와 같다.
const compose = (f1, f2, f3) => value => {
return f1(f2(f3(value)))
}
함수로만 조립하기에서 사용했던 예제를 Compose를 사용해서 바꾸면 아래와 같이 작성한다.
const extract = ({a, b}) => [a, b]
const add = ([a, b]) => a + b
const isEven = num => num % 2 === 0
const isEvenObj = compose(isEven, add, extract)
const obj = {a: 2, b: 4}
isEvenObj(obj) // true
Pipe로 조립하기
Pipe는 함수를 조립하는 도구 중 하나이다. Compose와 동일한 구성이다. 하지만 다른점이 하나있다. Pipe은 Compose와 다르게 첫번째 인자를 시작해서 마지막 인자 순서로 함수를 실행한다. 코드로 표현하면 아래와 같다.
const pipe = (f1, f2, f3) => value => {
return f3(f2(f1(value)))
}
함수로만 조립하기에서 사용했던 예제를 Pipe를 사용해서 바꾸면 아래와 같이 작성한다.
const extract = ({a, b}) => [a, b]
const add = ([a, b]) => a + b
const isEven = num => num % 2 === 0
const isEvenObj = pipe(extract, add, isEven)
const obj = {a: 2, b: 4}
isEvenObj(obj) // true
Compose와 Pipe 비교
필자는 함수를 조립할 때 Compose보다 Pipe를 사용한다. 한글을 읽는 방향과 동일한 방향으로 함수를 정의할 수 있기 때문이다. Pipe로 정의하면 왼쪽에서 오른쪽 또는 위에서 아래로 읽는 다. 하지만 Compose로 정의하면 오른쪽에서 왼쪽 또는 아래에서 위로 읽는 다. 가독성에서 차이가 있기 때문에 Compose보다 Pipe를 선호한다.
Pipe로 정의하면 왼쪽에서 오른쪽 또는 위에서 아래로 해석할 수 있다.
// 가로 정의
const isEvenObj = pipe(extract, add, isEven)
// 세로 정의
const isEvenObj = pipe(
extract,
add,
isEven
)
Compose로 정의하면 오른쪽에서 왼쪽 또는 아래에서 위로 해석해야 한다.
// 가로 정의
const isEvenObj = compose(isEven, add, extract)
// 세로 정의
const isEvenObj = compose(
isEven,
add,
extract
)
함수로 만들수 있는 도구
함수로 만들 수 있는 도구는 대표적으로 세 가지 소개하려고 한다. 세 가지는 클로저, 커리, 부분 적용이 있다. 세 가지 중 커리와 부분 적용은 클로저의 응용으로 만들어 졌다. 그럼 클로저부터 구체적으로 하나씩 알아보자.
클로저
클로저는 나중에 사용할 목적으로 인자를 캡쳐하는 함수다. 클로저가 캡쳐한 변수를 자유 변수라고 부른다. 클로저는 비공개 접근을 제공할 뿐만 아니라 추상화 기법도 제공한다. 예를 들어 클로저를 이용해서 생성 시에 캡쳐되는 어떤 설정에 따라 다른 함수를 만들 수 있다.
클로저를 add 함수를 예를 들면,
const add = a => b => a + b
add를 실행하면 b => a + b
가 반환값이 된다. 이 반환값이 클로저이다.
pluck 함수를 통해 클로저의 사례를 알아보자.
pluck 는 첫 번째 함수에서 키값을 주입받는 다. 두 번째 함수에서 객체를 주입받는 다. 그리고 키값으로 객체를 조회한 값을 반환한다.
const pluck = key => collection => collection[key]
const extractTitle = pluck('title')
const extractThird = pluck(2)
extractTitle({title: 'My Book'}) // My Book
extractThird([0, 1, 2]) // 2
커리
커리는 두개 이상의 인자로 구성된 함수에 사용된다. 커리는 함수의 각 인자를 대응하는 클로저를 반환하는 함수이다. f : (X ⋅ Y) → Z
함수가 주어질 때 커링은 새로운 함수 h : X → (Y → Z)
를 만든다. h는 X의 원소를 받아 Y가 Z에 매핑하는 함수를 반환한다. h(x)(y) = f(x, y)
이와 같이 정의되며curry(f) = h
이렇게도 사용된다.
두개의 인자를 받는 함수를 대응하는 커리를 예를 들면 아래와 같다.
const curry = fn => a => b => fn(a, b)
그리고 두개의 인자로 받는 함수가 있다.
const add = (a, b) => a + b
add(1, 2) // 3
curry를 사용해서 add함수를 새로 정의하면 아래와 같이 사용할 수 있다.
const curriedAdd = curry(add)
curriedAdd(1)(2) // 3
부분 적용
부분 적용은 부분적으로 실행을 마친 다음에 나머지 인자와 함께 즉시 실행한 상태가 되는 함수다. 부분 적용는 첫 번째 인자에 함수를 주입받고, 두 번째 인자 이후에 기억할 인자를 받는 다. 그리고 부분 적용의 반환값은 클로저이다. 클로저를 실행하면 부분 적용의 첫 번째 함수에 나머지 인자들을 주입하여 실행한다.
부분 적용은 partial라는 이름으로 사용한다. partial를 예를 들면 아래와 같다.
const partial = (fn, x) => y => fn(x, y)
두개의 인자를 받는 add함수를 partial를 사용한 예다.
const add = (a, b) => a + b
const add10 = partial(add, 10)
add10(5) // 15
함수형의 본질은 무엇인가
함수형이란 시스템을 함수처럼 여긴다. 함수를 이용해 시스템을 구성한다. 인자와 반환값을 통해 함수들을 조합해 고차원의 행동을 구성한다. 고차원의 행동이란 함수들을 조립하여 새로운 행동을 만드는 것이다. 함수형은 자료 구조를 새로 만들지 않고 자바스크립트 언어의 기본 자료 구조를 이용한다. 기본 자료 구조를 이용하여 고차원의 행동을 적용한다.
일급함수
일급함수는 함수를 값으로 취급할 수 있는 것이다. 함수를 값으로 취급할 수 있기 때문에 이와 같은 특징을 가진다.
변수 또는 배열, 객체에 담을 수 있다.
const fn = () => {}
const arr = [() => {}, () => {}]
const obj = {
fn: () => {}
}
인자로 함수를 주입할 수 있다.
const partial = (fn, x) => y => fn(x, y)
const add = (a, b) => a + b
const add10 = partial(add, 10)
add10(5) // 15
함수의 반환값으로 할 수 있다.
const curry = fn => a => b => fn(a, b)
이와 같은 특징을 통해 고차원의 동작을 하는 고차함수를 구현할 수 있다.
함수형은 일급함수을 기본으로 한다. 자바스크립트는 일급 함수를 지원한다. 때문에 함수를 조합하여 새로운 함수를 만들 수 있다. 뿐만 아니라 클로저를 구현할 수 있다. 클로저를 구현할 수 있기 때문에 커리와 부분 적용을 할 수 있는 것이다.
컨테이너 패턴
잠재적으로 위험한 코드 주위에 안전망(컨테이너)를 설치하는 것이다. 값을 컨테이너화 하는 행위는 함수형 프로그램의 기본 디자인 패턴이다. 값을 안정적으로 다루고 불변성을 지키기 위해 직접 접근을 차단하는 것이다. 이렇게 감싼 값에 접근하는 유일한 방법은 연산을 컨테이너에 매핑하는 것이다.
함수 조립과 컨테이너을 비교해보자.
먼저 함수 f
, g
가 있다고 가정하겠다.
const f = x => x + 2
const g = x => x * x
함수 f
와 g
를 조립하면 이렇게 작성한다.
g(f(2)) // 16
하지만 인자를 전달하지 않으면 어떻게 될까. 결과는 의도하지 않는 반환값을 가진다.
g(f()) // NaN
의도하지 않은 결과가 나왔을 때 어떤 오류를 발생시킬 지 예측하기 힘들다.
그럼 컨테이너를 통해 해결해보자. 자바스크립트의 Array로 컨테이너를 만들 수 있다. 그리고 함수 조립은 map 메소드를 통해 가능하다.
[2].map(f).map(g) // [16]
컨테이너에 인자를 전달하지 않으면 어떻게 될까. 결과는 아무 효과도 발생하지 않는 다.
[].map(f).map(g) // []
map 메소드는 Array에 값이 있을 때만 동작한다. 그래서 값이 없을 때 동작하지 않는 다. 즉, 함수로만 조립하는 것보다 예측하기 쉬운 조립이 가능하다.
상태변화
함수형은 상태 변화를 완전히 제거하는 게 아니라 변화가 발생하는 지역을 최소화하는 것을 목표로 한다. 상태 변화는 값을 담을 수 있는 변수, 배열, 객체가 변경되는 것을 의미한다. 자바스크립트에서는 DOM, Ajax 처럼 부수효과를 조작하기도 한다. 이 부수효과는 완전히 제거할 수 없다. 그래서 부수효과가 발생하는 부분과 발생하지 않는 부분을 분리하는 게 상태 변화의 핵심이다.
관심분리
부수효과가 발생하는 부분과 발생하지 않는 부분을 분리하는 것은 관심분리라고 한다. 관심이란 소프트웨어의 기능이나 목적을 뜻한다. 관심을 분리하는 것은 각 관심과 관련된 코드를 모으는 것이다. 관련된 코드를 모아 독립된 모듈을 만든다. 독립된 모듈을 통해 다른 모듈과 분리하는 게 관심분리이다.
그래서 함수형 프로그래밍은 어떻게 하는 걸까
함수형 프로그래밍은 추상화 단위를 함수로 한다. 함수들을 조립해서 고수준의 동작을 구현한다. 자료구조를 새로 만들어 어떤 요건을 충족시키는 게 아니라, 배열/객체/문자열 등의 흔한 자료구조를 이용해 문제를 해결한다.
함수형 프로그래밍에서 함수는 수학적 함수를 의미한다. 수학적 함수는 입력과 출력이 모두 존재해야하고, 입력에 따른 출력은 항상 동일하게 대응되어야 한다.
실용적인 함수형 프로그래밍은 어떤 시스템에서 상태 변화를 완전히 제거하는 것이 아니라 변이가 발생하는 지역을 가능한 최소화하는 것을 목표로 한다.
const add = (a, b) => a + b // 수학적 함수 O
const rand = a => Math.random(a) // 수학적 함수 X
수학적함수
함수형 프로그래밍에서 다루는 수학적함수를 순수함수라고 부른다. 순수함수는 동일한 인자에 상응하는 동일한 리턴값을 가지는 함수이다. 그러므로, 평가시점이 변경이 되더라도 동일한 결과를 리턴하기 때문에 다루기 쉬운함수가 된다. 순수함수는 객체의 변경이 필요할 경우 새로운 객체를 생성해서 리턴한다.
외부변수를 사용하거나 외부변수를 변경하면 순수함수가 아니다. 비순수함수는 평가시점에 따라 다른 결과값을 가지기 때문에 평가시점을 미세하게 다뤄야 한다.
// 순수함수
const add = (a, b) => a + b;
const add1 = (obj, b) => ({val : obj.val + b})
// 비순수함수
const add2 = (a, b) => a + b + c;
const add3 = (a, b) => {
c = b;
return a + b;
};
const add4 = (obj, b) => {
obj.val += b;
};
모든 것을 함수로 생각
함수형 프로그래밍은 애플리케이션, 함수의 구성요소, 더 나아가서 언어 자체를 함수처럼 여기도록 만들고, 이러한 함수 개념을 가장 우선순위에 놓는다. 함수형 사고방식은 문제의 해결 방법을 동사(함수)들로 구성(조합)하는 것.
// 함수를 가장 우선순위에 놓는 것
moveLeft(dog);
moveRight(duck);
moveLeft({ x: 5, y: 2});
moveRight(dog);
// 데이터(객체)를 우선순위에 놓는 것
duck.moveLeft();
duck.moveRight();
dog.moveLeft();
dog.moveRight();
값 대신 함수를 사용
우선 가장 간단한 함수인 repeat부터 살펴보자. repeat는 횟수와 값을 받아서 중복된 값을 횟수만큼 갖는 배열을 만드는 함수이다.
const repeat = (times, value) => {
return _.map(_.range(times), () => value)
}
독립적으로 동작하는 repeat를 구현하는 것도 괜찮지만 반복성
이라는 일반성을 가지도록 repeat를 구현할 수 있다면 더 좋을 것이다. 즉, 어떤 숫자만큼 값을 반복하는 것도 괜찮지만 어떤 동작을 특정 횟수만큼 반복한다면 더 좋다.
const repeatedly = (times, fun) => _.map(_.range(times), fun)
repeatedly(3, () => Math.floor(Math.random() * 10) + 1)
repeatedly 함수는 함수형 스타일로 생각하면 어떤 효과를 거둘 수 있는지 잘 보여 준다. 값 대신 함수를 사용함으로써 반복성
이라는 새로운 가능성이 열렸다.