- 저자
- 닐 포드
- 출판
- 한빛미디어
- 출판일
- 2016.07.01
전혀 새로운 프로그래밍 패러다임의 문제점은 새로운 언어를 배우는 것이 아니다. 이 글을 읽고 있는 모두가 이제껏 수도 없는 컴퓨터 언어를 배워오지 않았는가? 문법은 한낱 세부사항일 뿐이다. 어려운 점은 바로 다른 방식으로 사고하는 법을 배우는 것이다.
- 17p
시간이 갈수록 개발자는 지루한 일들을 언어나 런타임에 점점 더 맡기게 된다. 애플리케이션을 만들면서 직접 메모리를 제어하지 않는다는 것을 조금도 후회하지 않는다. 그런 일에 무관심해졌기 때문에 좀 더 중요한 문제들에 집중할 수 있다. 자바가 메모리 관리 작업을 쉽게 해줬다면, 함수형 프로그래밍 언어는 다른 빌딩블록들을 고수준 추상적 개념으로 대체해준다.
- 22p
명령형 프로그래밍이란 상태를 변형하는 일련의 명령들로 구성된 프로그래밍 방식을 말한다.
전형적인 for 루프가 명령형 프로그래밍의 훌륭한 예이다. 초기 상태를 설정하고, 되풀이할 때 마다 일련의 명령을 실행한다.
명령형 프로그래밍과 함수형 프로그래밍의 차이를 살펴보기 위해서, 통상적인 문제와 그에 대 한 명령형의 해답을 살펴보자. 어떤 이름 목록에서, 한 글자로 된 이름을 제외한 모든 이름을 대문자화해서 쉼표로 연결한 문자열을 구하려 한다고 해보자. 이 알고리즘을 구현한 자바 코드가 [예제 2-1]이다.
예제 2-1 전형적인 회사 프로세스(자바)
public class TheCompanyProcess {
public String cleanNames(List<String> listOfNames) {
StringBuilder result = new StringBuilder);
for (int i = 0 ‹ listOfNames. size(); i++) {
if (listOfNames.geti). length() > 1) {
result. append(capitalizeString(list0fNames.geti))).append(",");
}
return result. substring(0, result. length() - 1). toString();
public String capitalizeString (String s) {
return s. substring(0, 1). toUpperCase() + S. Substring(1, s. length());
}
}
목록 전체를 처리해야 하기 때문에, [예제 2-1]에서 가장 쉽게 이 문제를 푸는 방법은 명령형 루프를 사용하는 것이다. 각 이름마다 이름이 한 글자보다 긴가를 확인하고, 대문자화된 이름을 결과에 추가한다. 마지막 이름에는 쉼표를 포함하면 안 되므로, 마지막 결과에서 끝 글자를 잘라낸다.
- 28p
2.1.2 함수형 처리
함수형 프로그래밍은 프로그램을 수학 공식을 모델링하는 표현과 변형으로 기술하며, 불변 상 태를 지양한다. 함수형 프로그래밍 언어는 명령형 언어와는 다르게 문제를 분류한다. 앞에서 언급한 필터, 변형, 변환 등의 논리적 분류도 저수준의 변형을 구현하는 함수들이었다. 개발자 는 고계함수에 매개변수로 주어지는 함수1를 이용하여 저수준의 작업을 커스터마이즈할 수 있다. 따라서 [예제 2-2]처럼 의사코드를 사용하여 이 문제를 개념화할 수 있다.
listOfEmps
.filter { x -> x.length > 1 }
.transform{ x -> x.capitalize() }
.convert { x, y -> "$x,$y" }
함수형 언어는 이런 개념화된 해법을 세부사항에 구애받지 않고 모델링할 수 있게 해준다.
[예제 2-3]은 [예제 2-1]의 회사 프로세스'를 스칼라로 구현한 것이다.
[예제 2-3] 스칼라의 함수형 처리
val employees = List(
"neal",
"s",
"stu",
"j",
"rich",
"bob",
"aiden",
"j",
"ethan",
"liam",
"mason",
"noah",
"lucas",
"jacob",
"jayden",
"jack"
)
val result = employees
.filter(_.length > 1) // 한 글자 이름 제외
.map(_.capitalize) // 첫 글자 대문자로
.reduce(_ + "," + _) // 콤마로 합치기
println(result) // Neal,Stu,Rich,Bob,Aiden,Ethan,Liam,Mason,Noah,Lucas,Jacob,Jayden,Jack
스칼라로 짠 [예제 2-3]은 구현 세부사항만 추가되었을 뿐, 마치 [예제 2-2]의 의사코드처럼 읽힌다.
- 29p
사실 자바 8에도 동일한 기능이 있다. 이는 [예제 2-4]와 같이 스칼라 버전과 매우 유사하다.
[예제 2-4] 회사 프로세스의 자바 8 버전
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class Process {
public String cleanNames(List<String> names) {
if (names == null) {
return "";
}
return names.stream()
.filter(name -> name.length() > 1)
.map(this::capitalize)
.collect(Collectors.joining(","));
}
private String capitalize(String s) {
if (s == null || s.isEmpty()) {
return s;
}
return s.substring(0, 1).toUpperCase() + s.substring(1);
}
}
- 30p
이제까지 본 모든 언어들은 함수형 프로그래밍의 주요 개념을 포함하고 있다. 함수형 사고로의 전환은, 어떤 경우에 세부적인 구현에 뛰어들지 않고 이런 고수준 추상 개념을 적용할지를 배우는 것이다.
그렇다면 고수준의 추상적 사고로 얻는 이점은 무엇일까? 첫째로, 문제의 공통점을 고려하여 다른 방식으로 분류하기를 권장한다는 것이다. 둘째로, 런타임이 최적화를 잘할 수 있도록 해 준다는 것이다. 어떤 경우에서는, 결과가 변하지 않는 한, 작업 순서를 바꾸면 더 능률적이 된다(예를 들어 더 적은 아이템을 처리함으로써). 셋째로, 개발자가 엔진 세부사항에 깊이 파묻힐 경우 불가능한 해답을 가능하게 한다. 일례로 [예제 2-1]의 자바 코드를 여러 스레드에 나눠서 처리하게끔 할 때 해야 할 일을 상상해보라. 개발자가 저수준의 반복 과정을 제어해야 하기 때문에, 스레드 관련 코드가 문제 해결 코드에 섞여 들어가게 된다. 스칼라 버전에서는 [예 제 2-8]에서 보듯이 스트림에 par만 붙이면 된다.
- 33p
[예제 2-13] 함수형 자바를 사용한 자연수 분류기
package com.nealford.ft.number_classifier_functional_java;
import fj.F;
import fj.data.List;
import static fj.data.List.range;
public class NumberClassifier {
public List<Integer> factorsOf(final int number) {
return range(1, number + 1)
.filter(new F<Integer, Boolean>() {
@Override
public Boolean f(final Integer i) {
return number % i == 0;
}
});
}
public int aliquotSum(List<Integer> factors) {
return factors.foldLeft(fj.function.Integers.add, 0) - factors.last();
}
public boolean isPerfect(int number) {
return aliquotSum(factorsOf(number)) == number;
}
public boolean isAbundant(int number) {
return aliquotSum(factorsOf(number)) > number;
}
public boolean isDeficient(int number) {
return aliquotSum(factorsOf(number)) < number;
}
}
1. 함수형 자바의 범위는 경계를 포함하지 않는다.
2. 반복하기 대신 필터하기
3. 반복하기 대신 폴드하기
- 41p
aliquotSum() 메서드는 함수형 자바에서 제공하는 List 클래스의 fordeft() 메서드를 사용한다. 이 경우 왼쪽 폴드가 뜻하는 바는 다음과 같다.
1. 초기 값(이 경우 0)을 목록의 첫 번째 요소와 결합한다.
2. 그 결과를 가지고 다음 요소와 같은 방법으로 결합한다.
3. 이 작업을 목록 끝까지 계속한다.
이것은 바로 자연수 목록의 합을 구하는 방법과 정확히 똑같다. 0부터 시작해서 첫 번째 요소 를 더하고, 그 결과에 두 번째 요소를 더하고, 목록이 끝날 때까지 계속한다. 함수형 자바는 고 계합수(이 예제에서는 Integer.add 함수)를 제공하고 그것을 적용해주기도 한다. 물론, 자바8 이전에는 고계함수가 없었다. 함수형 자바는 익명 내부 클래스를 사용해서 고계함수의 스타 일을 적어도 흉내는 낸다.
[예제 2-13]에서 또 하나의 흥미로운 메서드는 factorsOf()이다. 이 메서드는 '방법보다는 결과라는 나의 만트라를 잘 드러낸다. 자연수의 약수를 찾아내는 문제의 본질이 과연 무엇인가?
다시 말해서, 대상이 되는 수까지 모든 자연수 중에서, 어떤 수가 약수임을 어떻게 알 수 있을 까? 이 질문은 필터 작업을 생각나게 한다. 목록에 들어 있는 수에서 조건에 맞지 않는 것들을 제외하면 된다. 이 메서드는 이 설명을 읽듯이 읽을 수 있다. 자연수 1부터 대상의 수까지 (경계를 포함하지 않기 때문에 1을 더했다) f() 메서드의 코드를 사용해서 목록을 필터한다. 여기서 f() 메서드는 함수형 자바에서 특정한 자료형과 리턴 값을 가지는 클래스를 만드는 방법이다.
[예제 2-13]에서 나는 foldLeft() 메서드를 사용했다. 이 메서드는 목록의 요소를 첫 번째 요소를 향해서 왼쪽으로 접는다. 교환법칙이 성립하는 덧셈 연산에서 방향성은 중요하지 않다.
다시 말해 교환법칙이 성립되지 않는 연산을 사용하려면 foLdRight() 메서드를 사용할 수도 있다.
TIP 고수준의 추상화는 마찰을 없앤다.
함수형 자바 버전(예제 2-13)과 자바 8 버전(예제 2-12)의 차이가 단순한 문법 설탕이라고 생각할 수도 있지만, 사실상 그 이상이다. 하지만 문법적 편리함은 중요하다. 한 언어로 아이디어를 표현하는 방식이 곧 문법이기 때문이다.
- 41~42p
여러 가지 변형된 Counter 클래스 (익명 클래스나 제네릭을 사용한) 구현이 가능하지만, 어떤 경우에나 개발자가 직접 내부 상태를 관리해야 한다. 왜 클로저의 사용이 함수적 사고를 예시 하는지가 여기에서 분명해진다. 런타임에 내부 상태의 관리를 맡겨버리는 것이다. 직접 필드를 생성하고 그 상태를 관리하기보다는 (멀티스레드 환경에서는 훨씬 더 끔찍할 것이다) 언어나 프레임워크가 보이지 않게 그 상태를 관리할 수 있도록 놔두라.
TIP 언어로 하여금 상태를 관리하게 하라.
클로저는 지연 실행(deferred execution)의 좋은 예이다. 클로저 블록에 코드를 바인딩함으로써 그 블 록의 실행을 나중으로 연기할 수 있다. 이 기능은 여러모로 쓸모가 있다. 예를 들어 클로저 블 록을 정의할 때는 필요한 값이나 함수가 스코프에 없지만, 나중에 실행 시에는 있을 수가 있다. 실행 문맥을 클로저 내에 포장하면 적절한 때까지 기다렸다가 실행할 수 있게 된다.
명령형 언어는 상태로 프로그래밍 모델을 만든다. 그 좋은 예가 매개변수를 주고받는 것이다. 클로저는 코드와 문맥을 한 구조로 캡슐화해서 행위의 모델을 만들 수 있게 해준다. 이렇게 만 들어진 클로저는 마치 전통적인 자료구조처럼 주고받을 수도 있고, 적절한 시간과 장소에서 실 행할 수도 있다.
TIP 상태 대신 문맥을 잡으라.
- 66p
예제 3-21 그루비에서의 명령형 필터
// 리스트에서 조건(predicate)에 맞는 값만 골라 새 리스트로 반환
def filter(list, predicate) {
def newList = []
list.each { item ->
if (predicate(item)) {
newList << item // 일치하는 값 추가
}
}
return newList
}
// 짝수인지 판별하는 람다
def modBy2 = { n -> n % 2 == 0 }
// 1‥20 범위에서 짝수만 추출
def result = filter(1..20, modBy2)
println result // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
[예제 3-21]의 코드는 복잡하지 않다. 우선 보유하고 싶은 요소를 담을 보유 인수를 생성한다.
그리고 목록의 각 요소를 술어 조건으로 점검해서 필터된 요소들만 들어 있는 목록을 리턴한다. 필터 조건을 명시한 코드 블록은 filter() 함수에 제공한다.
이제 [예제 3-21]의 필터 메서드를 재귀적으로 구현하는 걸 고려해보자.
예제 3-22 그루비에서의 재귀적 필터
def filterR(list, pred) {
if (list.size() == 0) {
return list // 빈 리스트는 그대로 반환
}
if (pred(list.head())) { // 조건을 만족하면
return [list.head] + // 현재 요소를 포함해
filterR(list.tail(), pred) // 나머지 리스트 재귀 필터링
} else { // 조건을 만족하지 않으면
return filterR(list.tail(), pred) // 건너뛰고 재귀 진행
}
}
println "Recursive Filtering"
println filterR(1..20, { it % 2 == 0 }) // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
[예제 3-22]의 filter() 메서드에서 우선 주어진 목록의 길이를 점검하고 만약에 빈 목록이면 즉시 리턴한다. 그렇지 않으면, 목록의 머리를 필터 조건에 비교해서 통과하면 준비된 목록에 추가하고, 아니면 재귀적으로 꼬리를 필터한다.
[예제 3-21]과 [예제 3-22]의 차이점은 '누가 상태를 관리하는가? 중요한 질문을 조명한다.
명령형 버전에서는 개발자가 관리한다. 이름이 nem_list인 새 인수를 생성하고, 계속 추가해야 한다. 끝나면 그것을 리턴해야 한다. 재귀 버전에서는 언어가 메서드 호출 시마다 리턴 값을 스택에서 쌓아가면서 관리한다. [예제 3-22]의 filter() 메서드의 종료 경로는 항상 return 이고 이렇게 중간 값을 스택에 쌓는다. 개발자는 new_list에 대한 책임을 양도하고 언어 자체가 그것을 관리해준다.
재귀는 상태 관리를 런타임에 양도할 수 있게 해준다.
- 77~78
명령형에서 함수형 스타일로 바꾸면 얻는 것 중의 하나가 런타임이 효율적인 결정을 할 수 있게 된다는 점이다. 2장에서 본 회사 프로세스'의 자바 8 버전(예제 2-4)을 [예제 3-24에서 다시 살펴보자.
예제 3-24 회사 프로세스의 자바 8 버전
public String cleanNames(List<String> names) {
if (names == null) {
return "";
}
return names.stream()
.map(this::capitalize)
.filter(n -> n.length() > 1)
.collect(Collectors.joining(","));
}
기민한 독자들은 이 cleanNames() 버전의 작업 순서가 2장의 [예제 2-4)와는 달라졌음을 알아챘을 것이다. 여기서는 map() 작업이 Filter 6보다 먼저 실행된다. 명령형 사고로는 당연히 필터 작업이 맵 작업보다 먼저 와야 한다. 그래야 맵 작업의 양이 줄어든다. 하지만 함수형 언어 (자바 8이나 함수형 자바 프레임워크를 포함)에는 Stream이란 추상 개념이 정의되어 있다. Stream은 여러모로 컬렉션과 흡사하지만 바탕 값(backing value)이 없다. 대신 원천에서 목적지까지 값들이 흐르게끔 한다. [예제 3-24]에서 원천은 names 컬렉션이고 목적지 (종점)는 collect() 함수이다. 이 두 작업 사이에서 map()과 filter()는 게으른 함수이다. 다시 말하자면 이들은 실행을 가능하면 미룬다. 이들은 목적지에서 요구하지 않으면 결과를 내려고 시도 하지도 않는다.
영리한 런타임은 게으른 작업들을 재정렬할 수 있다. [예제 3-24]에서 런타임은 필터를 맵 작 업 전에 실행하여 게으른 작업을 효율적으로 재정렬할 수도 있다. 자바에 추가된 다른 많은 함수형 기능처럼 filter() 같은 함수에 주어진 람다 블록에 부수효과가 없어야 한다. 그렇지 않 으면 예측할 수 없는 결과를 초래하게 된다.
런타임에 최적화를 맡기는 것이 양도의 중요한 예이다. 시시콜콜한 세부사항은 버리고 문제 도메인의 구현에 집중하게 되는 것이다.
- 81p
함수형 프로그래밍은 런타임에 재사용 가능한 메커니즘을 만들어서 움직이는 부분을 최소화하는 데 주력한다. 메모이제이션은 프로그래밍 언어에 내장되어 반복되는 함수의 리턴 값을 자동으로 캐싱해주는 기능이다.
- 88
명령형 버전에서는 개발자가 코드를 소유하고 책임을 진다. 함수형 언어는, 가끔 특별한 상황을 위한 변형된 함수나 매개변수를 도입할 때도 있지만, 주로 표준에 맞는 구조에 적합한 일반적인 도구를 만든다. 함수가 언어의 기초적인 요소이기 때문에, 그 레벨에서 최적화가 된 고급 기능을 공짜로 얻는 셈이다.
- 92p
4.2 게으름
표현의 평가를 가능한 최대로 늦추는 기법인 게으른 평가는 함수형 프로그래밍 언어에서 많이 볼 수 있는 기능이다. 게으른 컬렉션은 그 요소들을 한꺼번에 미리 연산하는 것이 아니라, 필요에 따라 하나씩 전달해준다. 이렇게 하면 몇 가지 이점이 있다. 우선 시간이 많이 걸리는 연산 을 반드시 필요할 때까지 미룰 수 있게 된다. 둘째로, 요청이 계속되는 한 요소를 계속 전달하는 무한 컬렉션을 만들 수 있다. 셋째로, 맵이나 필터 같은 함수형 개념을 게으르게 사용하면 효율이 높은 코드를 만들 수 있다.
- 95~96
함수형 언어에서의 코드 재사용은 객체지향 언어와는 접근 방법이 다르다. 객체지향 언어는 주로 수많은 자료구조와 거기에 딸린 수많은 연산을 포함한다. 반면에 함수형 언어에는 적은 수의 자료구조와 많은 연산들이 있기 마련이다. 객체지향 언어는 클래스에 종속된 메서드를 만드 는 것을 권장하여 반복되는 패턴을 재사용하려 한다. 함수형 언어는 자료구조에 대해 공통된 변형 연산을 적용하고, 특정 경우에 맞춰서 주어진 함수를 사용하여 작업을 커스터마이즈함으 로써 재사용을 장려한다.
- 111p
OOP 세상에서는 특정한 메서드가 장착된 특정한 자료구조를 개발자가 만들기를 권장한다. 함수형 프로그래밍 언어에서는 이 같은 방식으로 재사용을 하려 하지 않는다. 대신 몇몇 주요 자료구조 (list, set, map)와 거기에 따른 최적화된 연산들을 선호한다. 이런 기계 장치에 자료구조와 함수를 끼워 넣어서 특정한 목적에 맞게 커스터마이즈하는 것이다.
- 112p
5.5 함수형 자료구조
자바에서는 언어 자체의 예외 생성 및 전파 기능을 사용하는 전통적인 방법으로 오류를 처리한다. 만약 구조적인 예외 처리 기능이 존재하지 않는다면 어떻게 될까? 대부분의 함수형 언어들은 예외 패러다임을 지원하지 않기 때문에 개발자는 다른 방법으로 오류 조건을 표현해야 한다.
예외는 많은 함수형 언어가 준수하는 전제 몇 가지를 깨뜨린다. 첫째, 함수형 언어는 부수효과가 없는 순수함수를 선호한다. 그런데 예외를 발생시키는 것은 예외적인 프로그램 흐름을 야기하는 부수효과다. 함수형 언어들은 주로 값을 처리하기 때문에 프로그램의 흐름을 막기보다는 오류를 나타내는 리턴 값에 반응하는 것을 선호한다.
함수형 프로그램이 선호하는 또 하나의 특성은 참조 투명성이다. 호출하는 입장에서는 단순한 값 하나를 사용하든, 하나의 값을 리턴하는 함수를 사용하든 다를 바가 없어야 한다. 만약 호출 된 함수에서 예외가 발생할 수 있다면, 호출하는 입장에서는 안전하게 값을 함수로 대체할 수 없을 것이다.
- 126p
5.5.2 Either 클래스
합수형 언어에서는 다른 두 값을 리턴해야 하는 경우가 종종 있는데 그런 행동을 모델링하는 자료구조가 Eitter 클래스이다. Either는 왼쪽 또는 오른쪽 값 중 하나만 가질 수 있게 설계되었다. 이런 자료구조를 분리합집합disjoint union이라고 한다. C에서 파생된 어떤 언어들은 여러 자료형의 인스턴스를 지닐 수 있는 union 자료형을 제공한다. 분리합집합은 두 자료형이 들어 갈 자리가 있지만, 둘 중 하나만 지닐 수 있다.
스칼라에는 [예제 5-16]에서 보듯이 Either가 포함되어 있다.
예제 5-16 스칼라의 Either 클래스
type Error = String
type Success = String
def call(url: String): Either[Error, Success] = {
val response = WS.url(url).get.value.get
if (valid(response))
Right(response.body)
else
Left("Invalid response")
}
[예제 5-16]에서처럼, Either는 오류 처리에서 주로 사용된다. Either는 스칼라의 전체 생태계에 자연스럽게 녹아든다. 자주 사용하는 경우 중의 하나는 [예제 5-17]에서처럼, Either의 인스턴스에 패턴 매칭을 적용하는 것이다.
예제 5-17 스칼라 Either와 패턴 매칭
getContent(new URL("http://nealford.com")) match {
case Left(msg) => println(msg)
case Right(source) => source.getLines.foreach(println)
}
자바에 내장되지는 않았지만, 제네릭을 사용하면 [예제 5-13)에서 보듯이 간단한 대용품 Either 클래스를 만들 수 있다.
예제 5-18 Ether 클래스를 통해 타입 세이프한 두 값을 리턴하기
package com.nealford.ft.errorhandling;
public class Either<A, B> {
private A left = null;
private B right = null;
private Either(A a, B b) {
left = a;
right = b;
}
// 왼쪽 값 생성
public static <A, B> Either<A, B> left(A a) {
return new Either<>(a, null);
}
// 오른쪽 값 생성
public static <A, B> Either<A, B> right(B b) {
return new Either<>(null, b);
}
// 게터들
public A left() { return left; }
public B right() { return right; }
public boolean isLeft() { return left != null; }
public boolean isRight() { return right != null; }
// 두 경우에 대한 처리
public void fold(F<A> leftOption, F<B> rightOption) {
if (right == null) {
leftOption.f(left);
} else {
rightOption.f(right);
}
}
}
[예제 5-18]에서 Either 클래스는 생성자가 private이기 때문에 실제 생성은 정적 메서드인 left(A a)와 left(B b)가 담당한다. 이 클래스의 나머지 메서드들은 클래스 멤버들을 조사하고 꺼내 오는 도우미들이다.
Either를 사용하면 타입 세이프티를 유지하면서 예외 또는 제대로 된 결과 값을 리턴하는 코드를 만들 수 있다. 함수형의 보편적인 관례에 따라 Either 클래스의 왼쪽이 예외, 오른쪽이 결과 값이다.
- 127~130p
[예제 5-19]는 Either의 사용법을 설명하기 위해서 만든 RomanNumeral이란 클래스다.
예제 5-19 자바로 단순하게 구현한 로마숫자
package com.nealford.ft.errorhandling;
public class RomanNumeral {
private static final String NUMERAL_MUST_BE_POSITIVE =
"Value of RomanNumeral must be positive.";
private static final String NUMERAL_MUST_BE_3999_OR_LESS =
"Value of RomanNumeral must be 3999 or less.";
private static final String DOES_NOT_DEFINE_A_ROMAN_NUMERAL =
"An empty string does not define a Roman numeral.";
private static final String NO_NEGATIVE_ROMAN_NUMERALS =
"Negative numbers not allowed";
private static final String NUMBER_FORMAT_EXCEPTION =
"Illegal character in Roman numeral.";
private final int num;
private static final int[] numbers = {
1000, 900, 500, 400, 100, 90,
50, 40, 10, 9, 5, 4, 1
};
private static final String[] letters = {
"M", "CM", "D", "CD", "C", "XC",
"L", "XL", "X", "IX", "V", "IV", "I"
};
/* 숫자 → 로마숫자 */
public RomanNumeral(int arabic) {
if (arabic < 1) {
throw new NumberFormatException(NUMERAL_MUST_BE_POSITIVE);
}
if (arabic > 3999) {
throw new NumberFormatException(NUMERAL_MUST_BE_3999_OR_LESS);
}
this.num = arabic;
}
/* 로마숫자 → 숫자 */
public RomanNumeral(String roman) {
if (roman.length() == 0) {
throw new NumberFormatException(DOES_NOT_DEFINE_A_ROMAN_NUMERAL);
}
if (roman.charAt(0) == '-') {
throw new NumberFormatException(NO_NEGATIVE_ROMAN_NUMERALS);
}
roman = roman.toUpperCase();
int pos = 0;
int arabicEquivalent = 0;
while (pos < roman.length()) {
int number = letterToNumber(roman.charAt(pos));
if (number < 0) {
throw new NumberFormatException(NUMBER_FORMAT_EXCEPTION);
}
pos++;
if (pos == roman.length()) {
arabicEquivalent += number;
} else {
int nextNumber = letterToNumber(roman.charAt(pos));
if (nextNumber > number) { // IV, IX, CM 등
arabicEquivalent += (nextNumber - number);
pos++;
} else {
arabicEquivalent += number;
}
}
}
if (arabicEquivalent > 3999) {
throw new NumberFormatException(NUMERAL_MUST_BE_3999_OR_LESS);
}
this.num = arabicEquivalent;
}
private int letterToNumber(char letter) {
switch (letter) {
case 'I': return 1;
case 'V': return 5;
case 'X': return 10;
case 'L': return 50;
case 'C': return 100;
case 'D': return 500;
case 'M': return 1000;
default: return -1;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
int remaining = num;
for (int i = 0; i < numbers.length; i++) {
while (remaining >= numbers[i]) {
sb.append(letters[i]);
remaining -= numbers[i];
}
}
return sb.toString();
}
public int toInt() {
return num;
}
}
RomanNumeral 클래스를 호출하는 RomanNumeralParser란 클래스도 만들어보았다. 예제 5-20에서 parseNumber() 메서드를 볼 수 있다.
예제 5-20 로마숫자 파싱하기
public static Either<Exception, Integer> parseNumber(String s) {
if (!s.matches("[IVXLXCDM]+"))
return Either.left(new Exception("Invalid Roman numeral"));
else
return Either.right(new RomanNumeral(s).toInt());
}
[예제 5-21]처럼 그 결과를 테스트로 확인해볼 수 있다.
예제 5-21 로마숫자 파싱 테스트
@Test
public void parsing_success() {
Either<Exception, Integer> result = RomanNumeralParser.parseNumber("XLII");
assertEquals(Integer.valueOf(42), result.right());
}
@Test
public void parsing_failure() {
Either<Exception, Integer> result = RomanNumeralParser.parseNumber("FOO");
assertEquals(INVALID_ROMAN_NUMERAL, result.left().getMessage());
}
예제 5-21에서 구현한 parseNumber() 메서드는 Either의 왼쪽에는 오류 조건, 오른쪽에는 결과 값을 넣어주는 눅습만큼 단순한 점검만 한다(단지 오류를 보여주기 위해서다). 유닛 테스트에 두 경우 모두를 포함한 것이다.
이전에는 Map 자료구조를 주고받던 것에 비하면 큰 발전이다. 우선 예외를 얼마든지 세분화할 수 밖에 없다. 적절한 메시지를 넣을 수 있다. 또한 제네릭을 통한 메서드 선언으로 오류를 분명하게 선언할 수 있어 코드의 무결을 높여준다. 일단 타입 단위로 값을 전파하므로 예외가 적 으면 여기저기 전해주어서 원하는 컨텍스트에서 실행하게 해주는, 일종의 고리역할이다.
게으른 파싱과 함수형 자바
Either 클래스는 함수형 알고리즘에 자주 사용되며, 따라서 함수형 자바 프레임워크에서는 [예제 5-18]과 [5-20]에 사용할 수 있는 Either가 구현되어 있다. 하지만 다른 함수형 자바 구조물과 같이 사용될 수 있게 만들어져있다. 따라서 Either와 함수형 자바의 P1 클래스를 조
합하여 사용하면 게으른 오류 평가를 구현할 수 있다.
함수형 자바에서 P1 클래스는 매개변수가 없는 _1()란 간단한 메서드의 단순한 래퍼이며(P2, P3 또는 다른 변종들은 다수의 메서드를 가진다). P1은 함수형 자바에서 코드 블록을 실행하지 않고 여기저기 전해주어서 원하는 컨텍스트에서 실행하게 해주는, 일종의 고계함수다.
자바에서 예외를 던지는 순간 그 객체가 만들어진다. 게으른 평가 메서드를 리턴하면 예외의 생성을 지연할 수 있다.
- 126 ~ 131p
『GOF의 디자인 패턴」(프로텍미디어, 2015)이 소프트웨어 엔지니어링 세계에서 매우 잘 팔리는 책 중 하나라는 것이 놀라운 일은 아니다.
이 책은 바로 (그림 6-2)와 같이 추출된 패턴들의 카탈로그인 셈이다. 패턴을 이용한 재사용이 워낙 널리 퍼져서, 다른 책들도 독특한 이름으 로 이렇게 추출된 패턴의 목록을 만들곤 한다. 디자인 패턴 운동은 소프트웨어 개발 업계에 명명법과 예제들을 제공하여 큰 성공을 거두었다. 하지만 디자인 패턴을 통한 재사용은 궁극적으로 작은 단위의 재사용이다. 한 가지의 해법 (예를 들어 플라이웨이트 패턴')은 다른 것 (메멘 토 패턴)과 전혀 상관이 없다. 디자인 패턴으로 해결할 수 있는 문제들은 아주 특정하고, 그런 특정한 문제들을 자주 발견한다면 요긴하게 사용할 수 있다. 하지만 패턴이 그 문제에만 적용 되기 때문에 그 사용 범위는 좁을 수밖에 없다.
함수형 프로그래머들도 코드를 재사용하고 싶어 하지만 그들은 다른 빌딩블록을 사용한다. 함수형 프로그래밍은 구조물들 간에 잘 알려진 관계 (커플링 coupling)를 만들기보다는, 큰 단위의 재사용 메커니즘을 추출하려 한다. 이런 노력은 객체 간의 관계(모피즘morphism)를 규정하는 수학의 한 분야인 카테고리 이론에 근거를 둔다. 대부분의 애플리케이션들은 목록을 사용한다.
그래서 함수형 접근 방법은 목록을 중심으로 재사용 메커니즘을 구축하며, 이때 사용되는 것이 상황에 따라 달라지고(contexualized) 이동 가능한(portable) 코드다. 함수형 언어들은 일급 함수(언어의 다른 구조물들이 사용되는 모든 곳에서 사용될 수 있는 함수)를 매개변수나 리턴 값으로 사용한다.
- 149~150p
6.2.1 템플릿 메서드
일급 함수를 사용하면 불필요한 구조물들을 없앨 수 있기 때문에 템플릿 메서드 디자인 패턴을 구현하기가 쉬워진다. 템플릿 메서드는 하나의 알고리즘의 뼈대를 미리 정의하고, 세부 절차는 하위 클래스가 주어진 알고리즘의 구조를 바꾸지 않고 정의하게끔 한다. [예제 6-1]에서는 그루비로 전형적인 템플릿 메서드를 구현했다.
예제 6-1 템플릿 메서드의 '표준' 구현
package com.nealford.ft.template
abstract class Customer {
def plan
def Customer() {
plan = []
}
def abstract checkCredit()
def abstract checkInventory()
def abstract ship()
def process() {
checkCredit()
checkInventory()
ship()
}
}
[예제 6-1]에서 process() 메서드는 checkCredit(), checkInventory(), ship()에 의존한다. 이들은 추상 메서드이기 때문에 하위 클래스가 그 정의를 제공해야 한다.
일급 함수는 다른 어느 자료구조처럼 사용할 수 있으므로, [예제 6-1]의 코드는 코드 블록을 사용하여 [예제 6-2]처럼 다시 정의할 수 있다.
예제 6-2 일급 함수를 사용한 템플릿 메서드
class CustomerBlocks {
def plan, checkCredit, checkInventory, ship
def CustomerBlocks() {
plan = []
}
def process() {
checkCredit()
checkInventory()
ship()
}
}
[예제 6-2]에서 알고리즘의 각 단계는 클래스에 할당할 수 있는 성질에 불과하다. 이것이 상세한 구현 방법을 언어의 기능으로 감추는 일례다. 함수들의 구현을 나중으로 미루고, 이 패턴을 한글로의 해묵은 생각으로 알 수 있다. 이 방법이 더 간단함을 알 수 있을 것이다.
이와 같은 해법은 동등하지 않다. 전통적인 [예제 6-1]의 템플릿 메서드는 하위 클래스가 추상 클래스를 확장하고 메서드를 구현해야 한다. 물론 하위 클래스에서 팀 메서드를 구현할 수도 있지만, 추상 메서드의 정의는 하위 클래스를 구현하는 개발자에게 알려주는 일종의 문서 역할을 한다. 좀 더 유동성이 요구되는 상황에서는 이렇게 고정화된 메서드 선언이 적합하지 않을 수도 있다. 예를 들어 어떤 메서드도 받아서 실행할 수 있는 Customer 클래스를 만들 수 있다.
코드 블록과 같은 기능을 제공하는 언어들은 개발자들이 이 자유형식의 방식을 사용할 수 있게 해준다. 하위 클래스를 구현하지 않고도 어떤 컨텍스트를 줄 수 있는 경우를 생각해보자. 그루비에는 객체가 널인지 확인한 후에 메서드를 실행하는 특별한 보호 접근자(?.)가 있다. [예제 6-3]에서 process()의 정의를 살펴보자.
예제 6-3 코드 블록을 호출 전에 보호하기
def process() {
checkCredit?.call()
checkInventory?.call()
ship?.call()
}
[예제 6-3]에서 checkCredit, checkInventory, ship()에 해당하는 함수를 구현하는 개발자는 이 함수들을 그냥 바꿔줄 수도 있다. ?. 같은 문법적 설탕 덕분에, 개발자들은 길게 얽혀진 if 블록을 반복적으로 읽기 어려운 코드들보다는 언어에 얹을 수 있고, 보일러플레이트 코드를 간편히 짧고 고도로 대체할 수 있다. ?.에는 전혀 함수형의 성질이 없지만, 골치 아픈 일들을 런타임에 맡기는 예제로는 안성맞춤이다.
고계함수가 있기 때문에 명령 패턴이나 템플릿 패턴 같은 고전적인 패턴에서 자주 사용되는 보일러플레이트 코드가 필요 업어진다.
- 151~153p
6.3 구조형 재사용과 함수형 재사용
1장에서 본 인용문을 다시 떠올려보자.
객체지향 프로그래밍은 움직이는 부분을 캡슐화하여 코드 이해를 돕고, 함수형 프로그래밍은 움직이는 부분을 최소화하여 코드 이해를 돕는다.
- 마이클 페더스
특정한 추상 개념을 매일 사용하면, 머릿속에 점차적으로 스며들어 문제를 해결하는 방법을 변화시킨다. 이 절에서는 리팩토링을 통한 코드 재사용과 거기에 따르는 추상 개념의 영향력을 알아보자.
객체지향의 한 가지 목적은 캡슐화와 상태 조작을 쉽게 하는 것이다. 그래서 객체지향형 추상 화는 문제 해결을 위해 주로 상태를 이용한다. 마이클 페더스가 언급한 '움직이는 부분'인 클래스와 클래스 간의 상호 관계를 주로 사용하게 된다.
함수형 프로그래밍은 구조물들을 연결하기보다는 부분들로 구성하여 움직이는 부분을 최소화하려고 노력한다. 객체지향 언어의 경험만 있는 개발자들은 이 미묘한 개념의 차이를 쉽게 보지 못한다.
- 160p
예제 6-19 리팩토링으로 간단해진 소수 찾기
public class PrimeBeta extends FactorsBeta {
public PrimeBeta(int number) {
super(number);
}
public boolean isPrime() {
Set<Integer> primeSet = new HashSet<Integer>() {{
add(1); add(number);
}};
return getFactors().equals(primeSet);
}
}
리팩토링 시 number 변수의 접근 방식을 어떻게 정하는 이 문제에서는 커플링된 여러 개의 클래스를 다뤄야 한다. 이것은 문제의 부분들을 분리해주기 때문에 종종 이롭기도 하지만, 상위 클래스를 바꾸면 하위 클래스에도 영향을 주는 문제가 있다.
이것이 커플링(coupling)을 통한 코드 재사용의 일례이다. 상위 클래스의 number 변수와 getFactors() 메서드를 공유함으로써 두 클래스를 묶어버리는 방법이다. 다시 말하면, 언어에 내장되어 있는 커플링 법칙을 사용하여 작동한다. 객체지향은 커플링된 상하작용 스타일을 지향한다. 예컨대 코드 상속을 통해 멤버 변수에 접근하는 것을 들 수 있다. 따라서 커플링 방식은 한쪽을 리팩토링하기 어렵게 만들고, 특정 규칙들은 동작을 일관되게 유지하는 데 도움이 될 수 있다. 상속을 사용하는 것은 반드시 좋다고 가정하는 것은 아니다. 이 방식이 객체지향 언어에서 더 나은 성질을 가진 다른 추상 개념 대신에 남용되고 있다는 뜻이다.
구성 사용한 코드 재사용
[예제 6-20]은 2장에서 자바로 짠 조금 더 함수형인 자연수 분류기(예제 2-11).
package com.nealford.ft.number_classifier;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public class NumberClassifier {
public static boolean isFactor(final int candidate, final int number) { // (1)
return number % candidate == 0;
}
public static Set<Integer> factors(final int number) { // (2)
Set<Integer> factors = new HashSet<>();
factors.add(1);
factors.add(number);
for (int i = 2; i < number; i++)
if (isFactor(i, number))
factors.add(i);
return factors;
}
public static int aliquotSum(final Collection<Integer> factors) {
int sum = 0;
int targetNumber = Collections.max(factors);
for (int n : factors) { // (3)
sum += n;
}
return sum - targetNumber;
}
public static boolean isPerfect(final int number) { // (4)
return aliquotSum(factors(number)) == number;
}
public static boolean isAbundant(final int number) {
return aliquotSum(factors(number)) > number;
}
public static boolean isDeficient(final int number) {
return aliquotSum(factors(number)) < number;
}
}
① 모든 메서드는 number를 매개변수로 받아야 한다. 그 값을 유지할 내부 상태는 없다.
② 모든 메서드는 순수함수이기 때문에 public static이다. 그렇기 때문에 자연수 분류라는 범위 밖에서도 유용하다.
③ 일반적인 합리적인 변수의 사용으로 함수 수준에서의 재사용이 쉬워졌다.
④ 이 코드는 캐시가 없기 때문에 반복적으로 사용하기엔 비능률적이다.
예제 6-21 함수형 소수 찾기
package com.nealford.ft.composition;
public class FPrime {
public static boolean isPrime(int number) {
Set<Integer> factors = Factors.of(number);
return number > 1 &&
factors.size() == 2 &&
factors.contains(1) &&
factors.contains(number);
}
}
명령형 버전에서 했던 것처럼 중복된 코드는 Factors 클래스로 추출한 버전이 [예제 6-22]이다.
factors() 메서드는 가독성을 위해 of()로 이름을 바꿨다.
예제 6-22 함수형으로 리팩토링한 Factors 클래스
public class Factors {
static public boolean isFactor(int number, int potential_factor) {
return number % potential_factor == 0;
}
static public Set<Integer> of(int number) {
HashSet<Integer> factors = new HashSet<>();
for (int i = 1; i <= sqrt(number); i++) {
if (isFactor(number, i)) {
factors.add(i);
factors.add(number / i);
}
}
return factors;
}
}
함수형 버전의 모든 상태는 매개변수로 주어지기 때문에, 이 추출한 클래스에는 공유된 상태가 없다.
일단 클래스를 추출해내면, 그것을 사용하여 함수형 분류기와 소수 찾기를 리팩토링할 수 있다.
예제 6-23 리팩토링한 자연수 분류기
public class FClassifier {
public static int sumOfFactors(int number) {
Iterator<Integer> it = Factors.of(number).iterator();
int sum = 0;
while (it.hasNext())
sum += it.next();
return sum;
}
public static boolean isPerfect(int number) {
return sumOfFactors(number) - number == number;
}
public static boolean isAbundant(int number) {
return sumOfFactors(number) - number > number;
}
public static boolean isDeficient(int number) {
return sumOfFactors(number) - number < number;
}
}
두 번째 버전은 결과를 내기 위해 특별한 라이브러리거나 언어를 사용하지 않았음을 주시하라.
보다 코드의 재사용을 위해 커플링 대신에 구성을 사용했다. [예제 6-22]와 [예제 6-23] 모두 Factors 클래스를 사용하지만, 그 사용은 전적으로 개별 메서드에 포함된다.
커플링과 구성의 차이점은 작지만 중요하다. 예외가 없는 간단한 경우에는 코드 구조물의 골격이 그대로 드러나는 것을 볼 수 있다. 하지만 복잡한 코드베이스를 리팩토링할 때는 객체지향 언어의 코드 재사용 방식이 어떤 커플링의 여지가 나타난다. 무성하게 커플링된 구조물들을 이해해야 하는 상황 때문에 객체지향 언어에서는 코드 재사용의 피해를 많이 입었다. 그 결과 객체 관계 매핑(ORM)이나 위젯 라이브러리처럼 특정 기술 도메인에서의 코드 재사용이 제약을 받게 되었다.
물론 IDE에서 상위 클래스와 하위 클래스를 protected 멤버로 커플링하는 기능을 제공하므로 이를 이용하여 명령형 버전을 개선할 수도 있었다. 하지만 나는 커플링을 도입하기보다 구성을 사용하고자 한다. 함수형 프로그래머로 생각한다는 것은 코딩의 모든 방법을 다르게 생각하는 것이다. 코드 재사용은 개발자에겐 당연한 목표이지만, 명령형 추상 개념은 함수형 프로그래머들이 사용하는 방법과는 다른 방법으로 이 문제를 해결하려 든다.
이 장 앞에서 디자인 패턴이 함수형 프로그래밍과 만나는 방법들의 윤곽을 살펴보았다.
첫째, 디자인 패턴은 언어와 런타임에 흡수될 수 있다. 팩토리, 전략, 싱글턴, 템플릿 메서드 패턴의 예를 통해 이 점을 설명하였다.
둘째, 패턴들은 그 의미를 보존하면서 다른 방법으로 구현될 수 있다. 플레이웨이트 패턴은 클래스와 메모리 제한을 사용하여 구현한 예로 그 점을 설명하였다.
셋째, 함수형 언어와 런타임은 같은 기능을 가질 수 있고, 그것들을 사용하여 같은 문제라도 완전히 다른 방식으로 풀어나갈 수 있다.
- 165~169p
'Book' 카테고리의 다른 글
[독서 기록] 커넥팅 (4) | 2024.12.22 |
---|---|
[독서 기록] 클루지 (0) | 2024.08.03 |
[독서 기록] 질문에 관한 질문들 (0) | 2024.08.03 |
[독서 기록] 애플에서는 단순하게 일합니다 (1) | 2024.07.27 |
[독서 기록] 늦깎이 천재들의 비밀 (0) | 2024.07.26 |