마틴 오더스키 교수님이 코세라에서 진행중인 Functional Programming Principles in Scala 강의(https://www.coursera.org/learn/progfun1)의 4주차 요약.
* 함수형을 잘 모르는 상태에서 요약한 것이므로 내용에 오류가 존재할 수 있음
순수 객체 지향
순수 객체 지향 언어에서 모든 값은 객체이다. 언어가 클래스를 기반으로 한다면 모든 값의 타입이 클래스라는 것을 뜻한다. 스칼라 역시 기본 타입과 함수와 같은 모든 것을 객체로 표현한다.
자바에서 기본 타입인 int, boolean을 스칼라에서는 특별한 타입이 아닌 클래스로 제공한다. 이들 클래스는 scala 패키지에 Int, Boolean으로 정의되어 있다. 정수값 1은 Int 타입이다.
Int를 포함 이들 타입은 자바에서 +, *, ==와 같은 연산자와 동일한 이름을 갖는 메서드를 제공한다. 예를 들어, 1 + 10은 실제로는 Int 타입의 + 메서드를 호출하고 첫 번째 인자로 10을 전달하는 1.+(10) 코드와 동일하다.
스칼라 컴파일러는 성능을 높이기 위해 Int 타입 값은 32비트 정수로 Boolean 타입은 자바의 boolean으로 표현한다.
함수도 객체로
함수 값도 객체로 처리한다. A=>B 타입의 함수는 Function1[A, B] 클래스를 축약한 것이다. Function2, Function3 등 파라미터 개수별로 그에 해당하는 함수 타입이 존재한다.
함수는 apply() 메서드를 가진 객체로서 f(x)는 f.apply(x)와 같다. 예를 들어,
var f = (x: Int) => x + x
f(7)
이 코드는 다음의 OO 코드로 해석할 수 있다.
val f = new Function1[Int, Int] {
def apply(x: Int) = x + x
}
f.apply(7)
제네릭과 타입 경계
다형은 하위 타입과 제네릭(generic)의 두 가지 형식이 존재하는데, 여기서는 이 두 가지와 관련된 주제인 타입 경계(type bound)와 가변성(variance)에 대해 설명한다.
IntSet이 다음과 같이 두 개의 하위 타입을 갖는다고 하자.
class NonEmpty extends IntSet { ... }
object Empty extends IntSet { ... }
정수 집합을 의미하는 IntSet의 모든 정수가 양수면 자신을 리턴하고 아니면 익셉션을 발생하는 assertAllPos() 메서드가 있다고 하자. 이 메서드는 NonEmpty와 Empty의 두 타입을 인자로 받을 수 있다. 이 두 타입은 IntSet의 하위 타입이므로 assertAllPos에 전달할 수 있는 타입은 IntSet 타입의 하위 타입이 된다. 이를 제네릭을 사용하면 다음과 같이 메서드를 정의할 수 있다.
def assertAllPos[S <: IntSet](s: S): IntSet
여기서 "<: IntSet"은 타입 파라미터 S의 상위 경계로서, 이는 IntSet에 맞는 타입만 S에 올 수 있다는 것을 의미한다. 상위 경계와 하위 경계는 각각 다음과 같이 지정한다.
- S <: T 는 S가 T의 하위 타입임을 의미한다
- S >: T 는 S가 T의 상위 타입임을 의미한다
[S >: NonEmpty <: IntSet]과 같이 상위 경계와 하위 경계를 함께 제한할 수 있다.
가변성(variance)
제네릭과 관련해서 처음에 잘 이해하기 힘든 게 가변성인데 이 강의에서도 제네릭과 가변성에 대해 다룬다.(여기서 <: 표시는 하위 타입과 상위 타입을 의미한다.)
A <: B일 때 제네릭 타입(즉, 파라미터화 타입) C[T]에 대해 다음의 세 가지 관계가 가능하다.
- C[A] <: C[B] : C는 공변(convariant)이다
- C[A] >: C[B] : C는 반공변(contravariant)이다
- 서로 하위 타입이 아님 : C는 무공변(nonvariant)이다
얼핏 생각하면 A <: B일 때 C[A] <: C[B]일 것 같지만 꼭 그렇지는 않다. 예를 들어, 자바에서 배열은 공변이다. 즉, NonEmpty <: IntSet이면 NonEmpty[] <: IntSet[] 이다. 따라서 다음과 같이 할당이 가능하다.
NonEmpty[] a = new NonEmpty[] { ... }
IntSet[] b = a // NonEmpty[]는 IntSet[]의 하위 타입
그런데 공변 배열은 문제를 일으킨다. 다음 코드를 보자. b는 애초에 NonEmpty 타입의 배열이다. 그런데 a를 할당한 b는 IntSet[] 배열이다. 따라서 b 배열에는 Empty 타입의 값을 할당할 수 있다.
NonEmpty[] a = new NonEmpty[] { ... }
IntSet[] b = a // NonEmpty[]는 IntSet[]의 하위 타입
b[0] = Empty // NonEmpty 배열에 Empty 할당 시도
NonEmpty s = a[0]
Empty와 NonEmpty는 서로 상위-하위 타입 관계가 아니므로 NonEmpty 타입을 요구하는 곳에 Empty 값을 할당할 수 없고, 위 코드는 익셉션을 발생한다.
[박스]
하위 타입인지 여부는 다음 LSP(Liskov Substitution Principle)로 확인해 볼 수 있다.
A <: B이면 B 타입의 값을 사용하는 모든 것은 A 타입 값을 사용할 수 있어야 한다.
스칼라에서 공변, 반공변, 불공변은 다음과 같이 정의한다.
- 공변: class C[+A] { ... }
- 반공변: class C[-A] { ... }
- 무공변: class C[A] { ... }
구성요소를 변경할 수 있는 타입은 (거의) 공변일 수 없다. 반대로 불변 타입은 특종 조건을 충족하면 공변일 수 있다. 보통 메서드에서 공변 타입 파라미터는 메서드 결과에만 위치할 수 있고, 반공변 타입 파라미터는 메서드 파라미터에만 올 수 있다.
데이터 구조에서 값을 가져오면 공변, 데이터 구조에 값을 넣으면 반공변 타입을 사용한다. 이펙티브 자바에 보면 PECS(Producer Extends, Consumer Super)란 규칙도 이에 대해 설명한다.
분해(decomposition)와 패턴 매칭
1 + 2와 같은 수식을 표현하기 위해 다음의 세 타입을 사용한다고 하자.
trait Expr {
def isNumber: Boolean
def isSum: Boolean
def numValue: Int
def leftOp: Expr
def rightOp: Expr
}
class Number(n: Int) extends Expr { ... }
class Sum(e1: Expr, e2: Expr) extends Expr { ... }
Expr 타입을 받아서 값을 구하는 eval() 함수는 다음과 같이 Expr에 정의된 메서드를 이용해서 타입을 구분하고 값을 구하고 필요한 계산을 수행할 수 있다.
def eval(e: Expr): Int = {
if (e.isNumber) e.numValue
else if (e.isSum) eval(e.leftOp) + eval(e.rightOp)
else ...(익셉션발생코드)
}
이 방식에서 새로운 연산인 Prod를 추가하면, Expr과 각 하위 타입에 isProd 메서드를 추가해야 하고 eval() 메서드에 isProd를 이용하는 if 문을 추가해야 한다. 변수 표현을 위한 Var가 추가되어도 동일하게 각 타입마다 Var인지 여부를 판단하기 위한 isVar 메서드를 추가하고 eval에 관련 if 문을 추가하게 된다.
이 방식은 새로운 타입을 추가할 때마다 기존 타입의 코드를 수정해야 하는 문제가 발생한다. isNumber 메서드나 isSum 메서드를 사용하지 않고 타입을 검사하는 방법을 사용할 수는 있지만 이는 깨지기 쉬운 코드를 만들곤 한다.
이를 객체 지향 방식으로 풀어보면 다음과 같이 Expr에 eval() 메서드를 추가하는 것이다.
trait Expr {
def eval: Int
}
class Number(n: Int) extends Expr {
def eval: Int = n
}
class Sum(e1: Expr, e2: Expr) extends Expr {
def eval: Int = e1.eval + e2.eval
}
더 나아지긴 했지만 여전히 다음의 단점이 있다.
- 식을 문자열로 구해주는 toStr()과 같은 새로운 기능을 구현하려면 모든 타입에 메서드를 정의해야 한다
- a * b + a * c를 a * (b + c)로 축약하는 기능은 한 객체의 메서드로 캡슐화할 수 없다.
케이스 클래스를 패턴 매칭에 사용할 수 있다. 케이스 클래스를 패턴 매칭에서 사용하는 자세한 내용은 스칼라 관련 서적에 자세히 나와 있다.
케이스 클래스와 패턴 매칭을 사용하면 새로운 기능을 추가하더라도 Expr과 그 하위 타입을 바꿀 필요가 없다. 또한 단일 객체의 메서드로는 구현할 수 없는 기능도 구현할 수 있다.