6장, 전진하라

2022.02.15

대부분의 현대 언어들은 다중 패러다임을 갖고 있다. 문제에 적합한 패러다임을 사용하는 법을 배우는 것이 더 좋은 개발자로 진화하는 길 중의 하나이다.

6.1 함수형 언어의 디자인 패턴

함수형에서는 빌딩블록과 문제의 접근 방법이 다르기 때문에, 전통적인 GoF 패턴들 중의 일부는 사라지고, 나머지는 근본적으로 다른 방법으로 같은 문제를 풀게 된다. 함수형 프로그래밍에서는 전통적인 디자인 패턴들이 다음과 같은 세가지로 나타난다.

  • 패턴이 언어에 흡수된다.
  • 패턴 해법이 함수형 패러다임에도 존재하지만, 구체적인 구현 방식은 다르다.
  • 해법이 다른 언어나 패러다임에 없는 기능으로 구현된다.

6.2 함수 수준의 재사용이

  • 합성(composition)(주어진 매개변수와 일급 함수들의 형태로 이루어진다)은 함수형 프로그래밍 라이브러리에서 재사용의 방식으로 자주 사용된다.

  • 함수형 언어들은 객체지향 언어들보다 더 큰 단위로 재사용을 한다.

    • 매개변수로 커스터마이즈되는 공통된 작업들을 추출해낸다.
  • 디자인 패턴을 통한 재사용은 궁극적으로 작은 단위의 재사용이다.

    • 디자인 패턴으로 해결할 수 있는 문제들은 아주 특정하고, 그런 문제가 자주 발견되면 요긴하게 사용할 수 있다.
    • 하지만 그 문제에만 적용되기 때문에 사용 범위는 좁을 수 밖에 없다.
  • 함수형 프로그래밍은 구조물들 간에 잘 알려진 관계(커플링)를 만들기 보다는, 큰 단위의 재사용 메커니즘을 추출하려 한다.

    잘 알려진 관계는 결국 단단한 커플링을 야기시켜 재사용의 범위를 제한하게 된다.

  • 함수형 언어들은 일급 함수(언어의 다른 구조물들이 사용되는 모든 곳에서 사용될 수 있는 함수)를 매개변수나 리턴 값으로 사용한다.

  • filter()메서드에서처럼 코드를 매개변수로 전달하는 기능은 코드 재사용의 다른 접근 방법을 제시해준다.

  • 궁극적으로 디자인 패턴의 존재 목적은 언어의 결함을 메꾸기 위함일 뿐이다.

6.2.1 템플릿 메서드

일급 함수를 사용하면 불필요한 구조물들을 없앨 수 있기 때문에 템플릿 메서드 디자인 패턴을 구현하기가 쉬워진다.

abstract class Customer {
  def plan

  def Customer() {
    plan = []
  }

  def abstract checkCredit()
  def abstract checkInventory()
  def abstract ship()

  def process() {
    checkCredit()
    checkInventory()
    ship()
  }
}

process() 메서드는 checkCredit(), checkInventory(), ship()에 의존한다.
이들은 추상 메서드이기 때문에 하위 클래스가 그 정의를 제공해야 한다.

추상 메서드의 정의는 하위 클래스를 구현하는 개발자에게 알려주는 일종의 문서 역할을 한다.
좀 더 유동성이 요구되는 상황에서는 이렇게 고정화된 메서드 선언이 적합하지 않을 수도 있다.
예를 들어 어떤 메서드도 받아서 실행할 수 있는 Customer 클래스를 만들 수도 있기 때문이다.

class CustomerBlocks {
  def paln, checkCredit, checkInventory, ship

  def CustomerBLocks() {
    paln = []
  }

  def process() {
    checkCredit?.call()
    checkInventory?.call()
    ship?.call()
  }
}

알고리즘의 각 단계는 클래스에 할당할 수 있는 성질에 불과하다. 이것이 상세한 구현 방법을 언어의 기능으로 감추는 일례다.
?.와 같은 문법적 설탕 덕분에, 개발자들은 길게 열거된 if 블록 같은 반복적인 코드는 언어에 양도할 수 있다.
고계함수가 있기 때문에 명령 패턴이나 템플릿 패턴 같은 고전적인 패턴에서 자주 사용되는 보일러플레이트 코드가 필요 없어진다.

6.2.2 전략

일급함수의 사용으로 간편해진 디자인 패턴으로는 전략 패턴을 들 수 있다. 일급 함수를 사용하면 전략을 만들고 조작하기가 쉽다.

interface Calc {
  def product(n, m)
}

class CalcMult implements Calc {
  def product(n, m) { n * m }
}

class CalcAdd implements Calc {
  def product(n, m) {
    def result = 0
    n.times {
      result += m
    }
    result
  }
}

class StrategyTest {
  def listOfStrategies = [new ClacMult(), new ClacAdds()]

  @Test
  public void product_verifier() {
    listOfStrategies.each { s ->
      assertEquals(10, s.product(5, 2))
    }
  }
}

코드 블록을 일급함수로 사용하여, 위 에졔에서 보일러 플레이트 코드의 대부분을 제거할 수 있다.

@Test
public void exp_verifier() {
  def listOfExp = [
    {i, j -> Math.pow(i,j)},
    {i, j ->
      def result = i
      (j-i).times { result *= i }
      result
    }
  ]

  listOfExp.each { e ->
    assertEquals(32, e(2, 5))
    assertEquals(100, e(10, 2))
    assertEquals(1000, e(10, 3))
  }
}

위는 코드 블록을 사용하여 간단하게 지수 계산의 두 가지 전략을 정의했다.
전통적인 방법은 각 전략에 이름과 구조를 정해야 하고, 이런 방법이 바람직한 경우도 있다.
같은 클래스나 인터페이스를 무조건 상속해야 하는 제약으로, 안정성을 향상할 수 있었다.
이것은 함수형 프로그래밍과 디자인 패턴의 논의가 아니라 동적 언어와 정적 언어의 논의라 하겠다.

6.2.3 플라이웨이트 디자인 패턴과 메모이제이션

플라이웨이트 패턴은 많은 수의 조밀한 객체의 참조들을 공유하는 최적화 기법이다. 참조들을 객체 풀에 생성하여 특정 뷰를 위해 사용한다.

플라이웨이트는 같은 자료형의 모든 객체를 대표하는 하나의 객체, 즉 표준 객체라는 아이디어를 사용한다.

애플리케이션 내에서 각 사용자를 위해 객체를 모두 생성하기보다는, 표준 객체들의 목록을 하나 만들고 각 사용자는 원하는 객체의 참조를 가지는 식이다.

class Computer {
  def type
  def cpu
  def memory
  def hardDrive
}

class Desktop extends Computer {
  def driveBays
  def fanWattage
}

class Laptop extends Computer {
  def usbPorts
  def dockingBay
}

class AssignedComputer {
  def computerType
  def userId

  public AssignedComputer(computerType, userId) {
    this.computerType = 제곱근전략
    this.userId = userId
  }
}

@Singletone(strict=false) class ComputerFactory {
  def types = [:]

  private ComputerFactory() {
    def laptop = new Laptop();
    def tower = new Desktop();
    types.put("MacBookPro_6_2", laptop)
    types.put("SunTower", tower)
  }

  def ofType(computer) {
    types[computer]
  }
}

ComputerFactory 클래스는 가능한 모든 종류의 컴퓨터의 캐시를 만들고, 적당한 인스턴스를 ofType() 메서드를 통해서 전달한다.

인스턴스마다의 공통된 정보를 따로 저장하는 것은 좋은 아이디어다. 이 아이디어를 유지한 채로 함수형 프로그래밍으로 넘어가고 싶다.

def computerOf = {type ->
  def of = [MacBookPro_6_2: new Laptop(), SunTower: new Desktop()]
  return of[type]
}

def computerOfType = computerOf.memoize()

표준 객체 종류는 computerOf 함수로 정의된다. 메모아이즈된 인스턴스를 생성하기 위해서는 memoize() 메서드를 호출하기만 하면 된다.

전통적인 디자인 패턴에서는, 두 개의 메서드를 구현한 새 클래스를 팩토리로 사용한다. 함수형 버전에서는 하나의 메서드를 구현한 후에 메모아이즈된 버전을 리턴한다. 캐싱처럼 세부적 사항을 런타임에 맡기면 직접 구현한 코드가 실패할 기회가 적어진다.

6.2.4 팩토리와 커링

디자인 패턴 차원에서 보면, 커링은 함수의 팩토리 처럼 사용된다. 함수형 프로그래밍 언어에서 보편적인 기능은 함수를 여느 자료구조처럼 사용할 수 있게 해주는 일급 함수들이다. 이 기능 덕분에, 주어진 조건에 따라 다른 함수들을 리턴하는 함수를 만들 있다. ( 사실상 팩토리의 본질 )

object CurryTest extends App {
  def filter(xs: List[Int], p: Int => Boolean): List[Int] =
    if (xs.isEmpty) xs
    else if (p(xs.head)) xs.head :: filter(xs.tail, p)
    else filter(xs.tail, p)

  def dividesBy(n: Int)(x: Int) = ((x % n ) == 0)  // 1

  var nums = List(1, 2, 3, 4, 5, 6, 7, 8)
  println(filter(nums, dividesBy(2))) // 2
  println(filter(nums, dividesBy(3)))
}

위 예제는 함수형 프로그래밍에서의 패턴의 두 가지 형태를 보여준다.

  1. 커링이 언어나 런타임에 내장되어 있기 때문에, 함수 팩토리의 개념이 이미 녹아들어있어 다른 구조물이 필요 없다.
  2. 책의 저자가 지적한 다양한 구현 방법에 대한 중요성을 보여준다.
  • 디자인 패턴이란 문제를 풀기 위해 구조물에 의존하므로 간단히 구현하기 어려운 큰 문제들을 푸는 방법이라고 생각한다.
  • 일반화된 함수에서 특정한 dividesBy() 함수를 만드는 것은 작은 문제라고 생각하기 때문이다.
  • 커링을 본래 의도대로 사용하면 이미 사용되는 이름 외에 다른 이름을 형식적으로 만들 필요도 없다.<

일반적 함수에서 특정한 함수를 만들 때는 커링을 사용하라.

6.3 구조형 재사용과 함수형 재사용

객체지향의 한 가지 목적은 캡슐화와 상태 조작을 쉽게하는 것이다. 그래서 객체지향형 추상화는 문제 해결을 위해 주로 상태를 이용한다. (클래스와 클래스 간의 상호 관계를 주로 사용)

함수형 프로그래밍은 구조물들을 연결하기보다는 부분들로 구성하여 움직이는 부분을 최소화하려고 노력한다.

6.3.1 구조물을 사용한 코드 재사용

명령형 및 객체지향형 프로그래밍 스타일에서는 구조물과 메시징이 빌딩블록이다. 객체지향 코드를 재사용하려먼, 대상이 되는 코드를 다른 클래스로 옮기고 상속을 통해 접근해야 한다.

// 자연수 분류기
public class ClassifierAlpha {
  private int number;

  public ClassifierAlpha(int number) {
    this.number = number;
  }

  public boolean isFactor(int potential_factor) {
    return number % potential_factor == 0;
  }

  public Set<Integer> factors() {
    HashSet<Integer> factors = new HashSet<>();
    for (int i = 1; i <= sqrt(number); i++)
      if (isFactor(i)) {
        factors.add(i);
        factors.add(number / i);
      }
    return factors;
  }

  static public int sum(Set<Integer> factors) {
    // ...
  }

  public boolean isPerfect() {
    return sum(factors()) - number == number;
  }
  // ...
}


// 소수 찾기
public class PrimeAlpha {
  private int number;

  public PrimeAlpha(int number) {
    this.number = number;
  }

  public boolean isFactor(int potential_factor) {
    return number % potential_factor == 0;
  }

  public Set<Integer> factors() {
    HashSet<Integer> factors = new HashSet<>();
    for (int i = 1; i <= sqrt(number); i++)
      if (isFactor(i)) {
        factors.add(i);
        factors.add(number / i);
      }
    return factors;
  }

  public boolean isPrime() {
    // ...
  }

  // ...
}

isFactor()factors() 메서드는 두 가지의 햐법을 독립적으로 구현한 결과가 같다는 것을 깨닫게 해주는 자연스러운 결과이다.

중복 코드를 제거하는 리팩토링

위와 같은 중복된 코드를 해결하는 방법은 상위 클래스 추츨 리팩터링을 진행하는 것이다.

public class FactorsBeta {
  protected int number;

  public FactorsBeta(int number) {
    this.number = number;
  }

  public boolean isFactor(int potential_factor) {
    return number % potential_factor == 0;
  }

  public Set<Integer> factors() {
    HashSet<Integer> factors = new HashSet<>();
    for (int i = 1; i <= sqrt(number); i++)
      if (isFactor(i)) {
        factors.add(i);
        factors.add(number / i);
      }
    return factors;
  }
}

public class ClassfierBeta extends FactorsBeta {

  public ClassfierBeta(int number) {
    super(number);
  }

  public int sum() {
    // ...
  }

  public boolean isPerfect() {
    return sum() - number == number;
  }
}

public class PrimeBeta extends FactorsBeta {

  public PrimeBeta(int number) {
    super(number);
  }

  public int isPrime() {
    // ...
  }
}

이것이 커플링(coupling)을 통한 코드 재사용의 일례이다. number 변수와 getFactors() 메서드를 공유함으로써 두 클래스를 묶어버리는 방법이다. 다시 말하면, 언어에 내장되어 있는 커플링 법칙을 사용하여 작동한다. 객체지향은 커플링된 상호작용 스타일을 정의한다.

합성을 사용한 코드 재사용

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(i)) {
        factors.add(i);
        factors.add(number / i);
      }
    return factors;
  }
}

public class FClassifier {

  public static int sumOfFactors(int number) {
    Integer<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;
  }

  // ...
}
  • 모든 메서드는 number를 매개변수로 받아야 한다. 값을 유지할 내부 상태는 없다.
  • 모든 메서드는 순수 함수이다. 자연수 분류 문제라는 범위 밖에서도 유용하다.
  • 일반적이고 합리적인 변수의 사용으로 함수 수준에서의 재사용이 쉬워졌다.
  • 이 코드는 캐시가 없기 때문에 반복적으로 사용하기에 비능률적이다.

코드의 재사용을 위해 커플링 대신에 합성을 사용하였다. 이 예와 같은 간단한 경우에는 코드 구조물의 골격이 그대로 드러나는 것을 볼 수 있다. 하지만, 복잡한 코드베이스를 리팩토링할 때는 객체지향 언어의 코드 재사용 방식인 이런 커플링이 여기저기 나타난다.
무성하게 커플링된 구조물들을 이해해야 하는 어려움 때문에 객체지향 언어에서는 코드 재사용이 피해를 많이 입었다.

함수형 프로그래머로 생각한다는 것은 코딩의 모든 방면을 다르게 생각하는 것이다.

  1. 디자인 패턴은 언제나 언어나 런타임에 흡수될 수 있다.
  2. 패턴들은 그 의미를 보존하면서 다른 방법으로 구현될 수 있다.
  3. 함수형 언어와 런타임은 전적으로 다른 기능을 가질 수 있고, 그것들을 사용하면 같은 문제라도 완전히 다른 방식으로 풀어나갈 수 있다.