enum으로 대체할 수 있는 단위타입에 대해 – Swift

트위터에서 재밌는 링크를 하나 발견했다.

https://speakerdeck.com/abizern/safer-programming-with-types

이 링크가 전달하는 내용의 요지는 어떤 값의 타입이 동일하지만, 단위가 다르다거나 하는 등의 이유로 다르게 평가될 수 있는 값을 어떻게 안전하게 관리하는가 하는 문제이다. 예를 들어서 어떤 물건의 가격이나, 길이는 실수값의 숫자들로 표현가능하지만, 이 둘이 호환되는 단위는 아니다. 시간과 길이, 무게와 부피 등 데이터 타입으로는 같지만 단위가 달라서 실질적을 호환될 수 없는 값들은 현실에 많이 있다.

예를 들어 반지름과 중심각을 받아서 원호의 길이를 계산하는 함수를 작성한다고 생각해보자.

func getArcLength(radius: Double, angle: Dounle) -> Double {
  return radius * angle
}

let l = getArcLength(radius: 4, angle: M_PI / 4) /// 3.14159265358979

간단한 문제이다. 그런데 이는 생각치 못한 방향으로 흘러갈 수 있다. 왜냐하면 이 함수에서 사용한 공식은 중심각의 크기가 라디안일 때 적용되기 때문이다. 사용자들은 이렇게 쓸지도 모른다.

let l = getArcLength(radius: 4, angle: 45)

이는 원하는 결과가 아니다. 이 문제 대해서 링크한 슬라이드에서는 NSMeasurement나 Phantom Type을 쓰라고 이야기한다. Measurement는 길이나 각도 등의 값을 단위와 함께 저장하여 변환하는 등의 기능을 제공하는 클래스이다. Phantom Type은 그저 타입 구분을 위해서 빈 타입을 정의하고, 이를 다른 타입으로 감싸서 동일한 원시 타입 값을 구분하게 하는 것이다.

struct Degree {}
struct Radian {}
struct Angle<T> {
  let value: Double
}

func getArcLength(radius: Double, angle: Angle<Radian>) -> Double {
  return radius * angle.value
}

func getArcLength(radius: Double, angle: Angle<Degree>) -> Double {
  return radius * angle.value / M_PI * 180
}

예전에 하스켈을 배울 때 이와 비슷한 문제를 다뤄본 적이 있다. 하스켈의 커스텀 타입은 기본적으로 열거형에 가깝다. 타입은 값 구축자(constructor)와 매개값으로 만들어질 수 있는데, 하나의 타입이 사실 여러 가지의 구축자를 만들 수 있다. 이러한 가장 흔한 예는 Maybe 타입(Swift의 옵셔널과 비슷하다.)이라 할 수 있다.

data Maybe a = Some a | None

특정한 타입의 값을 만들기 위해서는 값 구축자를 이용하는데 위의 예에서 보듯, 값 구축자는 하나 혹은 그 이상의 파라미터를 받을 수 있고, 값 구축자 그 자체만으로 값이 될 수 있다. 이를 테면, True, False 두 가지 값만 존재하는 불리언은 이런식으로 만들어 질 수 있을 것 같다.

어떤 같은 양에 따라서 단위가 2가지 이상인 경우 하스켈에서는 다음과 같이 2가지 값 구축자를 이용해서 값을 만들 수 있고, 어떤 구축자를 사용했느냐에 따라서 같은 숫자라도 두 값이 구분된다. 다음 예는 하스켈에서 각도를 가리키기 위한 고유한 타입을 정의한 것이다. 라디안을 쓰거나 디그리 단위를 쓸 수 있게 하고, 각도와 관련된 함수들에서는 패턴 매칭에 따라 값을 분리할 수 있다.

data Angle = Degree Double | Radian Double deriving (Show)

toRadian :: Angle -> Angle
toRadian (Degree x) = Radian ( x / 180 * pi )
toRadian x = x

getArcLength :: Double -> Angle -> Double
getArcLength r a = let Radian x = toRadian a in r * x

main = print $ getArcLength 4 (Degree 90)
-- 6.283185307179586

그렇다면 Swift에서도 같은 방식으로 enum을 이용해서 여러 단위를 가질 수 있는 특정한 성질의 값을 만들 수 있을 것이다.

enum Angle {
  case degree(Double)
  case radian(Double)

  func toRadian() -> Double {
    switch self {
      case let .degree(x): return x / 180 * M_PI
      case let .radian(x): return x
    }
  }

  func toDegree() -> Double {
    switch self {
      case let .degree(x): return x
      case let .radian(x): return x * M_PI / 180
    }
  }
}

func getArcLength(radius: Double, angle: Angle) -> Double {
  return radius * angle.toRadian()
}

위 코드에서 Angle이라는 enum 타입을 만들고, 각각의 case가 Double 값을 연관 값으로 갖도록 한다. 그리고 Radian, Degree에 따라서 각 값을 그대로 혹은 적절히 변환해서 올바른 Double 값으로 변환할 수 있다. 이렇게 단위를 여럿 가지는 타입을 enum으로 구현하면 매우 가독성이 뛰어나고, 정교하게 typed된 코드를 얻을 수 있다.

  • 먼저 각도에 쓰이는 값은 Angle 이라는 고유 타입이 된다. 이는 각도값을 길이, 무게, 부피 혹은 일반적인 Double 값과 함부로 혼용할 수 없게 만든다. Swift 컴파일러는 .degree(15) + 40 이라는 계산을 허용하지 않을 것이다.
  • .degree(30).radian(1.2) 은 같은 Angle 타입이므로 Angle 타입을 받는 함수에 공통적으로 전달될 수 있다. 유령타입을 쓰는 경우는 각각의 단위를 별개 타입으로 구분해버리므로 이런 유연성은 떨어진다.
let l1 = getArcLength(radius: 4, angle: .degree(90)) // 6.28318530717959
let l2 = getArcLength(radius: 4, angle: .radian(M_PI / 2)) //6.28318530717959

라디안 단위이든 도 단위이든 각도라는 카테고리 내의 하위 타입이므로, 이들을 별도의 타입으로 분류하는 것은 enum으로도 충분히 가능한 셈이며, 유령 타입을 쓰는 것보다도 훨씬 더 자연스러운 코드를 만들 수 있다. (유령타입을 쓰는 경우 두 단위 모두를 지원하는 함수를 만드려면 오버로딩해야한다.)