원문: Effective Scala - Types and Generics
타입 시스템의 주요 목적은 프로그래밍 오류를 감지하는 것입니다. 타입 시스템은 특정한 코드 불변성을 컴파일러가 검증할 수 있도록 제한된 형태의 정적 검증을 제공합니다. 물론 타입 시스템은 다른 이점도 있지만, 오류 검사가 그 존재 이유입니다.
우리는 이러한 목표를 반영하여 타입 시스템을 사용해야 하지만, 독자를 염두에 두고 신중하게 사용해야 합니다. 타입을 적절히 사용하면 명확성이 향상될 수 있지만, 지나치게 기교를 부리면 오히려 코드가 모호해질 수 있습니다.
스칼라의 강력한 타입 시스템은 학문적 탐구와 실험의 원천이 되는 경우가 많습니다(예: 스칼라에서의 타입 레벨 프로그래밍). 이러한 기술들은 학문적으로 흥미로운 주제이지만, 실제 생산 코드에서 유용하게 적용되는 경우는 드뭅니다. 따라서 피하는 것이 좋습니다.
Return type annotations (반환 타입 어노테이션)
스칼라는 반환 타입 어노테이션을 생략할 수 있지만, 이러한 주석은 좋은 문서 역할을 합니다. 특히 공개 메서드에서는 매우 중요합니다. 메서드가 노출되지 않거나 반환 타입이 명확할 경우에는 생략해도 괜찮습니다.
특히 믹스인(mixins)을 사용하여 객체를 인스턴스화할 때 주의해야 합니다. 스칼라 컴파일러는 이러한 경우 싱글톤 타입을 생성합니다. 예를 들어, 아래의 make 함수는:
trait Service
def make() = new Service {
def getId = 123
}
trait Service
def make (): Service = new Service {
def getId = 123
}
이제 작성자는 make의 공개 타입을 수정하지 않고 더 많은 trait를 믹스인 할 수 있으며, 하위 호환성을 더 쉽게 관리할 수 있습니다.
Variance (변성)
변성은 제네릭 타입과 서브타이핑(subtyping)이 결합될 때 발생합니다. 변성은 포함된 타입의 서브타이핑이 컨테이너 타입의 서브타이핑과 어떻게 관련되는지를 정의합니다 (Container[T]에서 T가 포함된 타입, Container가 컨테이너 타입입니다). 스칼라는 선언 지점 변성 어노테이션을 제공하므로, 특히 컬렉션 같은 일반적인 라이브러리의 작성자들은 어노테이션을 많이 사용해야 합니다. 이러한 어노테이션은 코드의 사용성을 높이는 데 중요하지만, 잘못 사용하면 위험할 수 있습니다.
변성은 스칼라 타입 시스템의 고급 기능이지만, 서브타이핑에 도움이 되기 때문에 널리(그리고 올바르게) 사용해야 합니다.
불변 컬렉션은 공변(covariant)이어야 합니다. 포함된 타입을 받는 메서드는 컬렉션을 적절히 "downgrade"해야 합니다.
// 스칼라에서 제너릭 타입에 +를 붙이면 공변이라는 뜻입니다
trait Collection[+T] {
def add[U >: T](other: U): Collection[U]
}
가변 컬렉션은 무공변(invariance)이어야 합니다. 가변 컬렉션에서 공변성은 일반적으로 유효하지 않습니다. 다음의 상황을 확인해보세요.
// 가변 컬렉션을 공변으로 선언합니다
trait HashSet[+T] {
def add[U >: T](item: U)
}
// 동물의 종류로 Dog, Cat을 선언합니다
trait Mammal
trait Dog extends Mammal
trait Cat extends Mammal
// 컨테이너 컬렉션 HashSet의 포함 타입으로 Dog를 선언합니다
val dogs: HashSet[Dog]
// dogs를 HashSet[Mammal]로 인식합니다
val mammals: HashSet[Mammal] = dogs
// 고양이는 동물의 종류이니 mammals 컬렉션에 넣을 수 있습니다
// 하지만 mammals는 원래 HashSet[Dog]이니 문제가 됩니다!!
mammals.add(new Cat{})
Type aliases (타입 별칭)
타입 별칭은 편리한 이름을 제공해야 할 때나 사용 목적을 명확히 할 때에 사용하세요. 타입 이름이 이미 충분한 설명을 제공할 때에는 사용하지 마세요.
// 이 타입은 이미 자명합니다
() => Int
// 이 타입은 위의 타입보다 자명하지 않습니다
type IntMaker = () => Int
IntMaker
다음과 같은 경우에는 타입 별칭이 유용할 수 있습니다. 이 경우 별칭이 목적을 전달하고 코드의 간결성을 향상시킵니다.
class ConcurrentPool[K, V] {
type Queue = ConcurrentLinkedQueue[V]
type Map = ConcurrentHashMap[K, Queue]
// ...
}
별칭만으로 충분할 때에는 서브클래싱을 사용하지 마세요.
별칭을 사용하면 함수 리터럴을 타입의 값으로 제공할 수 있으며, 함수 합성도 사용할 수 있습니다.
// SocketFactory는 SocketAddress를 받고 Socket을 생성하는 클래스입니다
trait SocketFactory extends (SocketAddress => Socket)
// 이렇게 타입 별칭으로 선언하는게 더 낫습니다
type SocketFactory = SocketAddress => Socket
// 타입 별칭을 사용하면 함수 리터럴을 값으로 쓰고, 함수 합성도 가능합니다
val addrToInet: SocketAddress => Int
val inetToSocket: Long => Socket
val factory: SocketFactory = addrToInet andThen inetToSocket
타입 별칭은 패키지 객체를 사용하여 최상위 이름에 바인딩할 수 있습니다. 참고로, 타입 별칭은 새로운 타입이 아닙니다. 별칭 이름이 실제 타입으로 문법적으로 대체되는 것과 동일합니다.
package com.twitter
package object net {
type SocketFactory = (SocketAddress) => Socket
}
Implicits (암시적 변환)
Implicits은 강력한 타입 시스템 기능이지만 반드시 신중하게 사용되어야 합니다. 암시적 변환에는 복잡한 해석 규칙이 있으며, 단순한 문법적 검사만으로는 실제로 무슨 일이 일어나는지 파악하기 어렵습니다. 다음과 같은 상황에서는 암시적 변환을 사용하는 것이 괜찮습니다:
- 스칼라 스타일의 컬렉션을 확장하거나 추가할 때
- 객체를 적응하거나 확장할 때 ("pimp my library" 패턴, 그냥 확장 클래스 만드는 걸 멋지게 부르는 겁니다)
- 타입 안전성을 높이기 위해 제약 증거를 제공할 때
- 타입 증거를 제공할 때 (타입클래스화)
- 타입 명시를 위해서 사용할 때
만약 Implicits을 사용한다면, 항상 Implicits 없이도 동일한 목적을 달성할 수 있는 방법이 있는지 물어보세요.
유사한 데이터 타입 간에 자동 변환을 위해 Implicits을 사용하지 마세요(예: List를 Stream으로 변환). 각 타입이 다른 의미론을 가지며, 독자가 그 차이점을 신경 써야 하기 때문에 이러한 변환은 명시적으로 하는 것이 좋습니다.