선언적 프로그래밍과 람다
함수형 프로그래밍 시리즈인 람다로 사고하기의 네 번째 편입니다. 이전 편에서는 부분 적용과 커링을 활용해 다수의 인자를 받는 함수들을 조합하는 방법을 살펴보았습니다.
작은 기능 단위를 만들어 내고 이를 조합하다 보면, 일반적인 산술 연산, 비교, 논리 연산 및 제어 구조에 대한 래퍼 함수를 자주 작성하게 됩니다. 이 과정은 반복적일 수 있지만, 람다는 이러한 문제를 해결할 수 있는 도구를 제공합니다.
명령형과 선언형
프로그래밍 스타일은 다양한 방식으로 나눌 수 있습니다. 정적 타입과 동적 타입, 해석형과 컴파일형, 저수준과 고수준 등이 대표적입니다.
또 다른 분류는 명령형과 선언형입니다.
명령형 프로그래밍은 컴퓨터에게 "어떻게" 작업을 수행할지 지시하는 방식입니다. 대부분의 일상적인 코드에서 사용하는 제어 구조(if, for, while), 산술 연산(+, -, *, /), 비교 연산(===, >, <), 논리 연산(&&, ||, !) 모두 명령형의 예입니다.
반면 선언형 프로그래밍은 "무엇을" 하고 싶은지를 표현하고, 컴퓨터가 그 결과를 스스로 도출하도록 합니다. 대표적인 선언형 언어로는 프롤로그(Prolog)가 있으며, 이 언어는 사실과 추론 규칙을 기반으로 질문에 답하는 방식으로 동작합니다.
함수형 프로그래밍은 선언형의 한 종류로, 함수를 정의한 후 이를 조합하여 프로그램의 흐름을 구성합니다.
선언형 코드에서도 여전히 산술, 비교, 논리, 제어 흐름 같은 기본 요소는 필요하지만, 그 표현 방식이 달라져야 합니다.
선언적 대체 구문
자바스크립트는 명령형 언어이므로 일반적인 코드에서는 표준 연산자를 사용해도 무방합니다. 하지만 함수형 변환 파이프라인을 사용할 때는 명령형 구문이 잘 맞지 않습니다.
람다에서는 이러한 문제를 해결할 수 있도록 다양한 함수를 제공합니다.
산술 연산
이전 편에서 파이프라인을 통해 산술 변환을 구현한 바 있습니다.
const multiply = (a, b) => a * b;
const addOne = x => x + 1;
const square = x => x * x;
const operate = pipe(multiply, addOne, square);
operate(3, 4); // ((3 * 4) + 1)^2 = 169
람다는 add, subtract, multiply, divide 등의 함수를 제공하여 표준 연산자를 대체할 수 있게 해줍니다. 또한 add는 커링되므로 add(1)처럼 사용 가능하며, square는 multiply(x, x)로 다시 정의할 수 있습니다.
const square = x => multiply(x, x);
const operate = pipe(multiply, add(1), square);
add(1)은 증가 연산자(++)와 유사하지만, ++는 상태를 변경하므로 불변성 원칙에 어긋납니다. 따라서 inc, dec 함수를 사용해 증감을 표현하는 것이 더 적절합니다.
const operate = pipe(multiply, inc, square);
subtract는 이항 - 연산자와 대응되며, 부호 반전을 위한 단항 - 연산자는 negate 함수로 대체할 수 있습니다.
비교 연산
이전 편에서 투표 가능 여부를 판단하는 함수를 작성했습니다.
const isOver18 = person => person.age >= 18;
const isCitizen = either(wasBornInCountry, wasNaturalized);
const isEligibleToVote = both(isOver18, isCitizen);
람다에서는 equals로 ===을, gte로 >=을, gt로 >, lt로 <, lte로 <=를 대체할 수 있습니다.
const isOver18 = person => gte(person.age, 18);
const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY);
주의할 점은 gte, gt 등 함수들이 데이터를 마지막에 받는 람다의 관례("data-last")에 어긋난다는 것입니다. 따라서 파이프라인에서 사용할 때는 __ 플레이스홀더나 flip을 함께 사용해야 합니다.
또한 isEmpty는 빈 문자열이나 배열을 확인할 때, isNil은 null 또는 undefined를 검사할 때 유용합니다.
논리 연산
both, either, complement는 &&, ||, !의 함수형 버전입니다. 이들은 동일한 값에 대해 작동할 때 유용합니다.
다른 경우, 즉 서로 다른 값을 조건적으로 처리할 때는 and, or, not 함수를 사용합니다. 이들은 값 자체를 다루는 반면, both 등은 함수를 다룹니다.
기본값 설정 시 ||는 falsy 값을 기준으로 동작하므로, 0 같은 유효한 값이 무시될 수 있습니다. 이때 defaultTo 함수를 사용하면 보다 안전하게 기본값을 설정할 수 있습니다.
const lineWidth = defaultTo(80, settings.lineWidth);
defaultTo는 두 번째 인자가 null이나 undefined인지 확인하고, 그렇지 않으면 그 값을 반환합니다.
조건부 처리
함수형 코드에서도 조건문은 필요할 수 있습니다.
ifElse
나이가 21 이상이면 21을 유지하고, 그렇지 않으면 1씩 증가시키는 함수를 생각해봅시다.
const forever21 = age => age >= 21 ? 21 : age + 1;
이 코드는 조건과 두 가지 분기를 함수로 표현할 수 있습니다. ifElse 함수는 if...then...else 문의 함수형 버전입니다.
const forever21 = age => ifElse(gte(__, 21), always(21), inc)(age);
여기서 __는 플레이스홀더로, 인자를 생략하고 이후에 전달되는 값을 의미합니다. lt를 사용해도 되지만, gte와 __ 조합이 더 직관적입니다.
always
상수 함수는 매우 유용하며, 람다에서는 always 함수로 간편하게 표현할 수 있습니다.
const forever21 = age => ifElse(gte(__, 21), always(21), inc)(age);
또한 T와 F는 각각 always(true)와 always(false)의 축약형입니다.
identity
나이가 16세 미만이면 16을 반환하고, 그렇지 않으면 그대로 반환하는 함수를 생각해봅시다.
const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), a => a)(age);
a => a는 입력을 그대로 반환하는 함수로, identity 함수라고 불립니다. 람다에서는 identity 함수를 제공합니다.
const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), identity)(age);
identity는 여러 인자를 받을 수 있지만 항상 첫 번째 인자를 반환합니다. 다른 값을 반환하고 싶다면 nthArg를 사용할 수 있으나 드물게 쓰입니다.
when과 unless
조건 분기 중 하나가 identity인 경우, when과 unless을 사용할 수 있습니다.
when(condition, fn)은 조건이 참일 때만fn을 실행합니다.unless(condition, fn)은 조건이 거짓일 때만fn을 실행합니다.
const alwaysDrivingAge = age => when(lt(__, 16), always(16))(age);
또는 조건을 반대로 하면:
const alwaysDrivingAge = age => unless(gte(__, 16), always(16))(age);
cond
다수의 조건을 처리할 때는 cond 함수를 사용할 수 있습니다. 이는 switch 문이나 긴 if...else 체인을 대체할 수 있습니다.
const water = temperature => cond([
[equals(0), always('water freezes at 0°C')],
[equals(100), always('water boils at 100°C')],
[T, temp => `nothing special happens at ${temp}°C`]
])(temperature);
이 함수는 조건 리스트를 순차적으로 평가하고, 처음으로 참이 되는 조건에 해당하는 함수를 실행합니다. 마지막 항목은 T로 항상 참이므로 기본 처리를 제공합니다.
마무리
람다는 명령형 코드를 선언형 함수형 코드로 전환할 수 있도록 다양한 도구를 제공합니다. 이제 우리는 함수 조합을 더욱 깔끔하게 표현할 수 있게 되었습니다.
다음 편에서는 이러한 패턴을 더 깔끔하게 만들 수 있는 점 없는 스타일(점프리 스타일)에 대해 다룰 것입니다.