5장, 진화하라

2022.01.23

함수형 언어에서의 코드 재사용은 객체지향 언어와는 접근 방법이 다르다. 객체지향 언어는 클래스에 종속된 메서드를 만드는 것을 권장하여 반복되는 패턴을 재사용하려 한다. 함수형 언어는 자료구조에 대해 공통된 변형 연산을 적용하고, 특정 경우에 맞춰서 주어진 함수를 사용하여 작업을 커스터마이즈함으로써 재사용을 장려한다.

언어들이 소프트웨어에서 반복되는 문제들의 해결 방법을 어떻게 진화시켜왔는지를 살펴보자.

5.1 적은 수의 자료구조, 많은 연산자

100개의 함수를 하나의 자료구조에 적용하는 것이 10개의 함수를 10개의 자료구조에 적용 하는 것보다 낫다.

- 앨런 펄리스 Alan Perlis -

  • OOP 세상에서는 특정한 메서드가 장착된 자료구조를 개발자가 만들기를 권장한다.
  • 함수형 프로그래밍 언어에서는 몇몇 주요 자료구조와(list, set, map)와 거기에 따른 최적화된 연산들을 선호한다.
  • 함수 수준에서 캡슐화하면 커스텀 클래스 구조를 만드는 것보다 좀 더 세밀하고 기본적인 수준에서 재사용이 가능해진다.
  • 클로저에서는 XML 파싱의 경우, 이미 언어에 존재하는 자료구조로 XML을 변환하려고 한다.
    • 기존 자료 구조로 변환하면 일관된 방식으로 탐색이 가능하다.

5.2 문제를 향하여 언어를 구부리기

  • 특별히 함수형 언어만의 기능은 아니지만, 언어를 우아하게 문제 도메인으로 바꾸는 기능은 함수형, 선언현 방식의 현대 언어에서 흔히 볼 수 있다.
  • 문제를 프로그램에 맞추지 말고, 프로그램을 문제에 맞게끔 조정해가라.

5.3 디스패치 다시 생각하기

여기서 디스패치란 넓은 의미로 언어가 작동 방식을 동적으로 선택하는 것을 말한다.

5.3.1 그루비로 디스패치 개선하기

  • 자바에서 조건부 실행은 특별한 경우의 switch 문을 제외하고는 if 문을 사용하게 된다.
  • if문이 길게 연결되면 가독성이 떨어지기 때문에 주로 GoF의 팩토리나 추상 팩토리 패턴을 사용한다.
  • 좀 더 유연하게 결정을 표현할 수 있는 언어를 사용하면 이런 패턴들을 사용하지 않고도 간결하게 코드를 짤 수 있다.
1class LetterGrade {
2  def gradeFromScore(score) {
3    switch(score) {
4      case 90..100 : return "A"
5      case 80..<90 : return "B"
6      ...
7      case ~"[ABCDFabcdf]" : return score.toUpperCase()
8    }
9  }
10}
  • 그루비의 switch문은 여러가지 동적 자료형을 받을 수 있고, 범위(90..100), 열린 범위(80..<90), 정규식 디폴트 조건 등을 모두 사용할 수 있다.

5.3.2 클로저 언어 구부리기

  • 자바에서는 함수나 클래스를 만들 수는 있지만 기초적인 빌딩 블록을 만드는 것은 불가능하다. 따라서 개발자는 문제를 프로그래밍 언어로 번역해야 한다.
  • 클로저 같은 리스프 계열의 언어에서는 개발자가 언어를 문제에 맞게 변경할 수 있다.
  • 즉 언어 설계자와 그 언어를 사용하는 개발자가 만들 수 있는 것들의 경계가 불분명해지게 된다.
1(defn in [score low high]
2  (and (number? score) (<= low score high)))
3
4(defn letter-grade [score]
5  (cond
6  (in score 90 100) "A"
7  (in score 80 90) "A"
8  ...
9  (in score 0 60) "F"
10  (re-find #"[ABCDFabcdf]" score) (.toUpperCase() score)))
  • 읽기 좋은 letter-grade 함수를 만들고, 거기서 사용할 in 함수를 구현했다.
  • 이 코드에서는 in 함수를 사용하여 cond 함수가 일련의 테스트를 평가한다.

5.3.2 클로저의 멀티메서드와 맞춤식 다형성

  • 계속되는 if 문은 읽기도 어렵고 디버그하기는 더 어렵다.
  • 자바에서는 언어 수준에서 대체할 만한 적당한 것이 없기 때문에 팩토리나 추상 팩토리 패턴을 사용하여 해결한다.
  • 클로저에는 다른 객체지향 언어의 모든 기능이 다른 기능들과는 별개로 구현되어 있다.
  • 클로저도 다형성을 지원하지만 클래스를 평가해서 디스패치를 결정하는 것에 국한되어 있지는 않다.
  • 클로저의 멀티메서드는 디스패치 결정 조건을 리턴하는 디스패치 함수를 받아들이는 메서드를 말한다.
1(deffn basic-colors-in [color]
2    (for [[k v] color :when (not= v 0)] k))
3
4(defmulti color-string basic-color-in)
5
6(defmulti color-string [:red] [color]
7    (str "Red: " (:red color)))
8
9(defmulti color-string [:green] [color]
10    (str "Green: " (:green color)))
11
12(defmulti color-string [:blue] [color]
13    (str "Blue: " (:blue color)))
14
15(defmulti color-string [:blue] [color]
16    (str "Blue: " (:blue color)))
  • 다형성을 상속과 분리하면 강력하고 상황에 맞는 디스패치 방식이 가능해진다.
  • 다형성만큼 상황에 맞으면서도 제약은 훨씬 적은 강력한 디스패치 방식을 구현할 수 있다.

5.4 연산자 오버로딩

함수형 언어의 공통적인 기능은 연산자 오버로딩이다. +,-,*와 같은 연산자를 새로 정의하여 새로운 자료형에 적용하고 새로운 행동을 하게 하는 기능이다.

5.4.1 그루비

  • 그루비는 자바의 근본적인 의미를 유지하면서 그 문법을 개선하려 하는 언어다.
  • 그루비는 연산자들을 메서드 이름에 자동으로 매핑하는 연산자 오버로딩을 허용한다.
  • 일례로 정수 클래스에서 + 연산자를 오버로딩하려면 plus 메서드를 오버라이딩하면 된다.

5.4.2 스칼라

  • 스칼라는 연산자와 메서드의 차이점을 없애는 방법으로 연산자 오버로딩을 허용한다.
  • 즉 연산자는 특별한 이름을 가진 메서드에 불과하다.
  • 따라서 곱셈 연산자를 스칼라에서 오버라이드하려면 * 메서드를 오버라이드하면 된다.

새로운 언어를 만들지 말고, 연산자 오버로딩을 통해 문제 도메인을 향하여 언어를 구부리자.

5.5 함수형 자료구조

예외는 많은 함수형 언어가 준수하는 전제 몇 가지를 깨트린다.

  • 함수형 언어는 부수효과가 없는 순수함수를 선호한다.

    • 예외를 발생시키는 것은 예외적인 프로그램 흐름을 야기하는 부수효과다.
    • 함수형 언어들은 주로 을 처리하기 때문에 프로그램의 흐름을 막기보다는 오류를 나타내는 리턴 값에 바응하는 것을 선호한다.
  • 함수형 프로그램이 선호하는 또 하나의 특성은 참조 투명성이다.

    • 호출하는 입장에서는 단순한 값 하나를 사용하든, 하나의 값을 리턴하는 함수를 사용하든 다를 바가 없어야 한다.

5.5.1 함수형 오류 처리

자바에서 예외를 사용하지 않고 오류를 처리하기 위해 해결해야 할 근본적인 문제는 메서드가 하나의 값만 리턴할 수 있다는 제약이다.

1public static Map<String, Object> divide(int x, int y) {
2  Map<String, Object> result = new HashMap<String, Object>();
3  if (y == 0)
4    result.put("exception", new Exception("div by zero"));
5  else
6    result.put("answer", (double) x / y);
7  return result;
8}

이 접근 방법에는 문제점이 있다.

  • Map에 들어가는 값은 타입 세이프하지 않기 때문에 컴파일러가 오류를 잡아낼 수 없다.
  • 메서드 호출자는 리턴 값을 가능한 결과들과 비교해보기 전에는 성패를 알 수 없다.
  • 두 가지 결과가 모두 리턴 Map에 존재할 수가 있으므로, 결과가 모호해진다.

여기서 필요한 것은 타입 세이프하게 둘 또는 더 많은 값을 리턴할 수 있게 해주는 메커니즘이다.

5.5.2 Either 클래스

  • 함수형 언어에서는 다른 두 값을 리턴해야 하는 경우가 종종 있는데 그런 행동을 모델링하는 자료구조가 Either 클래스이다.
  • Either는 왼쪽 또는 오른쪽 값 중 하나만 가질 수 있게 설계되었다. 이러한 자료구조를 분리합집합(disjoint union)이라고 한다.
1type Error = String
2type Sucess = String
3def call(url:String):Either[Error,Success]={
4  val response = WS.url(url).get.value.get
5  if(valid(response))
6    Right(response.body)
7  else Left("Invalid Response")
8}

Either를 사용하면 타입 세이프티를 유지하면서 예외 또는 제대로 된 결과 값을 리턴하는 코드를 만들 수 있다. 함수형의 보편적인 관례에 따라 Either 클래스의 왼쪽이 예외, 오른쪽이 결과 값이다.

5.5.3 옵션 클래스

여러 언어나 프레임워크에는 Either와 유사한 Option이란 클래스가 있다. 이것은 적당한 값이 존재하지 않을 경우를 의미하는 none, 성공적인 리턴을 의미하는 some을 사용하여 예외 조건을 더 쉽게 표현한다.

1public static Option<Double> divide<double x, double y) {
2  if (y == 0)
3    return Option.none();
4  return Option.some(x / y);
5}

Option은 Either의 left와 right과 유사하지만 적당한 리턴 값이 없을 수 있는 메서드를 위해 none()과 some()을 가지고 있다. Either는 어떤 값이든 저장할 수 있는 반면, Option은 주로 성공과 실패의 값을 저장하는 데 쓰인다.

5.5.4 Either 트리와 패턴 매칭

Either 같은 자료구조의 이점을 알기 위해서는 패턴 매칭에 대해 다뤄야 한다.

1// 패턴 매칭과 유사한 구문으로 트리의 깊이 알아내기
2
3static public int depth(Tree t) {
4  for (Empty e : to.toEither().left())
5    return 0;
6  for (Either<Leaf, Node> ln: to.toEither().right()) {
7    for (Leaf leaf : ln.left())
8      return 1;
9    for (Node node : ln.right())
10      return 1 + max(depth(node.left), depth(node.right));
11  }
12  throw new RuntimeException("Inexhaustible pattern match on tree");
13}

depth() 메서드는 깊이를 알아내는 재귀 함수이다. 트리가 <Either, <Left, Node>>란 특정한 자료구조로 만들어진 것이기 때문에, 각각의 '슬롯'을 특정한 경우로 생각할 수 있다. 트리의 내부 구조를 규격화한 덕분에, 트리를 따라가면서 각 요소의 자료형에 따른 경우에 대해서만 생각하며 분석할 수 있다.