원본: Effective Scala - Functional Programming
값 지향 프로그래밍(Value oriented programming)은 많은 이점을 제공하는데, 특히 함수형 프로그래밍 구조와 함께 사용될 때 그 이점이 더욱 두드러집니다. 이 스타일은 상태의 변형(stateful mutation)보다 값의 변환(transformation)에 중점을 두어 참조 투명성을 유지하고, 더 강력한 불변성을 제공하며 논리적으로 이해하기 쉬운 코드를 작성하게 해줍니다. 케이스 클래스(Case class), 패턴 매칭(Pattern matching), 구조 분해 바인딩(Destructuring bindings), 타입 추론(Type inference), 경량 클로저(Lightweight closure) 및 메서드 생성 구문(method-creation syntax)은 이 스타일에서 사용되는 중요한 도구입니다.
Case classes as algebraic data types (케이스 클래스를 사용한 대수적 데이터 타입)
케이스 클래스는 대수적 데이터 타입(ADT)을 인코딩합니다. 이는 많은 종류의 데이터 구조를 모델링하는 데 유용하고, 패턴 매칭과 함께 사용하면 강력한 불변성을 갖춘 간결한 코드를 제공합니다. 패턴 매처는 완전한(exhaustivity) 분석을 구현하여 더 강력한 정적 보장을 제공합니다.
대수적 데이터 타입을 케이스 클래스로 인코딩할 때 다음과 같은 패턴을 사용합니다.
sealed trait Tree[T]
case class Node[T](left: Tree[T], right: Tree[T]) extends Tree[T]
case class Leaf[T](value: T) extends Tree[T]
Tree[T] 타입은 Node와 Leaf라는 두 가지 생성자를 갖고 있습니다. 타입을 sealed로 선언하면 컴파일러는 이 생성자들이 선언된 파일 외부에서는 추가적으로 구현되지 않음을 알 수 있어 완전한 분석을 수행할 수 있습니다.
패턴 매칭과 같이 사용하면 이러한 모델링은 간결하고 "명백히 올바른" 코드를 작성할 수 있습니다.
// T는 Ordered[T]이므로 min 비교가 가능합니다
def findMin[T <: Ordered[T])(tree: Tree[T]) = tree match {
case Node(left, right) => Seq(findMin(left), findMin(right)).min
case Leaf(value) => value
}
트리와 같은 재귀 구조는 대수적 데이터 타입의 전형적인 구현이지만, 그 유용성은 훨씬 뛰어납니다. 특히 상태 기계(state machine)에서 불연속 합집합을 모델링할 때 자주 사용됩니다.
Options (옵션 타입)
Option 타입은 비어 있거나(None) 값으로 채워진(Some(value)) 컨테이너입니다. 이는 null을 안전하게 대체할 수 있으며, null 대체가 가능할경우 언제든지 사용해야 합니다. Option은 최대 하나의 항목을 가진 컬렉션으로, 컬렉션 연산으로 꾸며져 있습니다. 적극적으로 활용해보세요!
// 이렇게 사용하세요
var username: Option[String] = None
// ...
username = Some("foobar")
// 이렇게 사용하지 마세요
var username: String = null
// ...
username = "foobar"
전자의 방식이 후자의 방식보다 훨씬 안전합니다. Option 타입은 username이 비어 있는지 여부를 정적으로 확인하도록 강제합니다.
Option 값에 대한 조건부 실행은 foreach 메소드를 통해 실행되어야 합니다.
// 이렇게 쓰세요
opt foreach { value =>
operate(value)
}
// 이렇게 쓰지 마세요
if (opt.isDefined)
operate(opt.get)
이러한 스타일은 조금 이상해 보일 수 있지만, 훨씬 안전하고 간결합니다(우리는 get을 쓰지 않았어요). 만약 두 가지 분기가 모두 필요한 경우에는 패턴 매칭을 사용하세요.
opt match {
case Some(value) => operate(value)
case None => defaultAction()
}
하지만 단순히 기본값이 누락된 경우에는 getOrElse를 사용하세요.
operate(opt getOrElse defaultValue)
Option을 너무 남용하지 마세요. 만약 합리적인 기본값이(Null Object 패턴) 있을 경우에는 이를 대신 사용하세요.
(간단한 예시를 들자면 Sequence의 Nil, Tree의 EmptyNode 같은 것을 말합니다)
또한 Option은 널 값이 될 수 있는 값을 감싸는 편리한 생성자를 제공합니다.
Option(getClass.getResourceAsStream("foo"))
이 코드는 getResourceAsStream이 null을 반환할 경우 None을 갖는 Option[InputStream]을 생성합니다.
Pattern matching (패턴 매칭)
패턴 매칭(x match { ... })은 훌륭한 Scala 코드에서 광범위하게 사용됩니다. 이는 조건부 실행(conditional execution), 구조 분해(destructuring), 형 변환을 하나의 구조로 통합합니다. 잘 사용하면 명확성과 안전성을 향상시켜 줍니다.
패턴 매칭을 통해 타입 전환(type switches)을 구현하세요.
obj match {
case str: String => ...
case addr: SocketAddress => ...
패턴 매칭은 구조 분해와도 매우 잘 어울립니다(대표적인 예는 케이스 클래스를 활용할 때입니다).
// 이렇게 하세요
animal match {
case Dog(breed) => "dog (%s)".format(breed)
case other => other.species
}
// 이렇게 하지 마세요
animal match {
case dog: Dog => "dog (%s)".format(dog.breed)
case _ => other.species
}
사용자 정의 추출기(custom extractors)를 작성할 때는 꼭 apply를 같이 작성하세요, 그렇지 않으면 사용하기에 부적절한 상황이 발생할 수 있습니다.
기본값이 더 적절할 때는 패턴 매칭을 조건부 실행에 사용하지 마세요. 컬렉션 라이브러리는 종종 Option을 반환하는 메서드를 제공합니다.
// 이렇게 사용하세요
val x = list.headOption getOrElse default
// 이렇게 사용하지 마세요
val x = list match {
case head :: _ => head
case Nil => default
}
Partial functions (부분 함수)
Scala는 부분 함수를 정의하는 문법적 축약을 제공합니다.
val pf: PartialFunction[Int, String] = {
case i if i % 2 == 0 => "even"
}
그리고 orElse를 이용해 조합할 수 있습니다.
val tf: (Int => String) = pf orElse { case _ => "odd" }
tf(1) == "odd"
tf(2) == "even"
부분 함수는 여러 상황에서 발생하며 PartialFunction으로 효과적으로 인코딩될 수 있습니다. 예를 들어 메서드의 인수로 사용될 수 있습니다.
trait Publisher[T] {
def subscribe(f: PartialFunction[T, Unit])
}
val publisher: Publisher[Int] = ...
publisher.subscribe {
case i if isPrime(i) => println("found prime", i)
case i if i % 2 == 0 => count += 2
/* 나머지는 무시하기 */
}
또는 Option을 반환할 수 있는 상황에서 부분 함수를 사용할 수 있습니다.
// 이 함수는
type Classifier = Throwable => Option[java.util.logging.Level]
// PartialFunction으로 표현할 수 있습니다
type Classifier = PartialFunction[Throwable, java.util.logging.Level]
// 이렇게 활용됩니다
val classifier1: Classifier
val classifier2: Classifier
val classifier: Classifier = classifier1 orElse classifier2 orElse { case _ => java.util.logging.Level.FINEST }
Destructuring bindings (구조 분해 바인딩)
구조 분해 값 바인딩은 패턴 매칭과 연관되어 있습니다. 이 둘은 완전히 동일한 매커니즘을 사용합니다. 다만 구조 분해 값 바인딩은 정확히 하나의 가능성만 존재할 때 적용됩니다(예외가 발생할 가능성이 있습니다). 구조 분해 바인딩은 튜플이나 케이스 클래스를 사용할 때 유용합니다.
val tuple = ('a', 1)
val (char, digit) = tuple
val tweet = Tweet("just tweeting", Time.now)
val Tweet(text, timestamp) = tweet
Laziness (지연 평가)
Scala는 val 필드에 lazy가 접두사로 붙어있을경우 필요할 때 계산됩니다. Scala에서는 필드와 메서드가 동일하므로(단, 필드가 private[this]인 경우를 제외하고) lazy val 필드는 아래 코드와 같습니다.
// 이 코드는
lazy val field = computation()
// 아래 코드를 문법적으로 짧게 허용해준 거와 동일합니다
var _theField = None
def field = if (_theField.isDefined) _theField.get else {
_theField = Some(computation())
_theField.get
}
즉, 결과를 계산하고 메모이제이션(memoization)합니다. 이러한 목적으로 지연 필드를 사용하는 것은 장려하지만, 지연이 의미적으로 필요할 때 사용하는 것은 피하세요. 이러한 상황에서는 명시적으로 지연 평가를 처리하는 것이 비용적으로도 좋고, 사이드 이펙트를 관리하는 목적으로도 좋습니다.
Lazy 필드는 쓰레드에도 안전합니다.
Call by name (이름에 의한 호출)
메소드 매개변수는 이름에 의한 호출 방식(by-name)으로 지정될 수 있습니다. 이는 매개변수가 값이 아닌 반복될 수 있는 계산에 바인딩된다는 의미입니다. 이 기능은 신중하게 사용해야 하며, 값에 의한 호출(by-value)을 기대하는 호출자는 이러한 동작을 기대하지 않았을 수도 있습니다. 이 기능의 주요 목적은 문법적으로 자연스러운 DSL(도메인 특화 언어)을 구성하기 위함이며, 특히 새로운 제어 구조를 만드는 경우 기본 언어 기능처럼 보이게 할 수 있습니다.
이름에 의한 호출은 이러한 제어 구조에서만 사용해야 하며, 호출자는 의심할 여지없이 계산하여 도출된 값이 아니라 '블록'이라는 것이 명확해야 합니다. 이름에 의한 호출은 마지막 인수 목록의 마지막 위치에서만 사용해야 합니다. 이때 메소드 이름을 통해 호출자에게 인수가 이름에 의한 호출이라는 것을 알려주어야 합니다.
값을 여러 번 계산하거나 특히 이 계산이 사이드 이펙트를 유발할 경우, 명시적인 함수를 사용해야 합니다.
class SSLConnector(mkEngine: () => SSLEngine)
이렇게 사용할경우 의도를 명확히 할 수 있으며, 호출자는 혼란 없이 사용할 수 있습니다.
flatMap
flatMap(map과 flatten의 혼합 함수)은 섬세함과 큰 유용성을 지닌 중요한 함수입니다. map처럼 flatMap도 Future, Option과 같은 비전통적 컬렉션에서도 자주 사용됩니다. flatMap의 동작은 그 선언(signature)을 통해 알 수 있습니다. 만약 Container[A]가 있다면
flatMap[B](f: A => Container[B]): Container[B]
flatMap은 각 요소들에 대해 f 함수를 호출하여서 새로운 컬렉션들을 생성하고, 새롭게 생성된 컬렉션들은 결과로 병합(flatten)됩니다. 예를 들어, 동일한 문자가 반복되지 않는 두 문자열의 모든 순열을 얻으려면
val chars = 'a' to 'z'
val perms = chars flatMap { a =>
chars flatMap { b =>
if (a != b) Seq("%c%c".format(a, b))
else Seq()
}
}
이는 더 간결한 for comprehension으로 표현될 수 있으며, 이는 위 코드를 문법적 설탕(syntax sugar)으로 변환한 것과 유사합니다.
val perms = for {
a <- chars
b <- chars
if a != b
} yield "%c%c".format(a, b)
flatMap은 Options와 작업할 때 특히 유용합니다. 이 함수는 여러 Option 체인을 하나로 축소할 수 있습니다.
val host: Option[String] = ...
val port: Option[Int] = ...
val addr: Option[InetSocketAddress] =
host flatMap { h =>
port map { p =>
new InetSocketAddress(h, p)
}
}
이는 for comprehension으로 더 간결하게 만들 수 있습니다.
val addr: Option[InetSocketAddress] = for {
h <- host
p <- port
} yield new InetSocketAddress(h, p)
Future에서 flatMap을 활용하는 방법은 Future 섹션에서 이미 다뤘습니다 (이전에 번역한 글을 참고해주세요 2024.09.01 - [Scala/EffectiveScala] - Effective Scala - Concurrency)