원문: Effective Scala - Object oriented programming
스칼라의 방대한 기능들은 대부분 객체 시스템을 통해 제공됩니다. 스칼라는 모든 값이 객체인 순수한 언어입니다. 달리 말하면 원시 타입과 복합 타입 간에 구분이 없습니다. 또한 스칼라는 믹스인(mixin)을 지원하여 더 직관적이고 세부적인 모듈 구성을 가능하게 하며, 컴파일 타임에 정적으로 타입 검사를 받으면서 유연하게 결합할 수 있습니다.
믹스인 시스템의 동기는 전통적인 의존성 주입의 필요성을 없애는 데 있습니다. 이러한 "컴포넌트 스타일" 프로그래밍의 정점은 케이크 패턴(cake pattern)입니다.
Dependency injection (의존성 주입)
우리가 지금까지 사용한 바로, 스칼라는 "고전적인" 생성자 기반 의존성 주입의 구문적 오버헤드를 굉장히 많이 제거해주어서 따로 수정할 필요 없이 그냥 사용하는걸 선호할 정도입니다. 코드는 깔끔하게 보이고, 의존성은 기존대로 생성자 타입에 인코딩되며, 클래스 생성은 문법적으로 너무 간단하여 식은 죽 먹기일 정도입니다. 이는 지루하지만 간단하고 잘 동작합니다. 프로그램을 모듈화할 때 의존성 주입을 사용한다면 상속보다 조합(composition)을 우선시해야 합니다. 이렇게 하면 자연스레 더 모듈화되고 테스트하기 쉬운 프로그램을 작성하게 됩니다. 상속이 필요한 상황에 직면했을 때, 스스로에게 질문해보세요. 만약 언어에서 상속을 지원하지 않는다면 어떻게 구조화해야할까요? 이 질문에 대한 답이 상속보다 더 설득력 있을 수 있습니다.
의존성 주입은 일반적으로 트레잇(trait)을 통해 구현됩니다.
trait TweetStream {
def subscribe(f: Tweet => Unit)
}
class HosebirdStream extends TweetStream ...
class FileStream extends TweetStream ...
class TweetCounter(stream: TweetStream) {
stream.subscribe { tweet => count += 1 }
}
이외에도 일반적으로 다른 객체를 생성하는 팩토리를 주입하는 방법도 사용합니다. 이때는 구체화된 팩토리 타입보다 간단한 함수를 사용하는 것이 좋습니다.
class FilteredTweetCounter(mkStream: Filter => TweetStream) {
mkStream(PublicTweets).subscribe { tweet => publicCount += 1 }
mkStream(DMs).subscribe { tweet => dmCount += 1 }
}
Traits (트레잇)
의존성 주입은 공통 인터페이스의 사용이나 트레잇에서 공통 코드를 구현하는 것을 막지 않습니다. 오히려 이러한 이유로 트레잇을 사용하는 것을 강력히 권장합니다. 구체적인 클래스가 여러 개의 인터페이스(트레잇)를 구현할 수 있으며, 공통 코드를 모든 클래스에서 재사용할 수 있습니다.
트레잇은 간단하면서도 독립적으로 유지해야 합니다. 분리 가능한 기능을 하나의 트레잇에 묶지 말고, 관련된 작은 기능들을 함께 묶는 방식을 생각하세요. 예를 들어, I/O 작업을 하는 객체가 있다고 가정합니다.
trait IOer {
def write(bytes: Array[Byte])
def read(n: Int): Array[Byte]
}
이를 두 개의 기능으로 분할합니다.
trait Reader {
def read(n: Int): Array[Byte]
}
trait Writer {
def write(bytes: Array[Byte])
}
그런 다음 이들을 조합해 IOer를 구성할 수 있습니다(IOer: new Reader with Writer). 인터페이스를 작게 유지하면 독립성을 높이고 깔끔하게 모듈화할 수 있습니다.
Visibility (가시성)
스칼라는 매우 표현력 있는 가시성 수정자(visibility modifiers)를 제공합니다. 이 가시성 수정자는 무엇이 공용 API인지를 정의하기 때문에 매우 중요하게 여기고 사용해야 합니다. 공용 API는 사용자가 구현 세부사항에 의존하지 않고 저자가 이를 변경할 수 있는 여지를 제공해야 하기 때문에 제한적이어야 합니다. 일반적으로 공용 API를 확장하는 것이 축소하는 것보다 훨씬 쉽습니다. 잘못된 주석은 코드의 이진 호환성(backwards binary compatibility)을 손상시킬 수 있습니다.
private[this]
private으로 표시된 클래스 멤버는 이 클래스의 모든 인스턴스에서 접근 가능합니다(그러나 이를 상속받은 클래스는 아닙니다). 대부분의 상황에서 당신은 private[this]를 사용하기를 의도했을겁니다.
// 이것보다
private val x: Int = ...
// 이것을 사용하는게 좋습니다
private[this] val x: Int = ...
private[this]를 사용하면 특정 인스턴스를 통해서 접근하는 것을 제한할 수 있습니다. 스칼라 컴파일러는 private[this]를 단순한 필드 접근으로 변환할 수 있으며, (접근이 정적으로 정의된 클래스로만 제한되기 때문에) 이는 성능 최적화에 도움이 될 수 있습니다.
부연설명
그냥 private은 class-private이라 부르고, private[this]는 object-private이라고 부릅니다.
class-private일때는 해당 클래스일경우에 모두가 접근이 가능합니다.
예를들면 해당 클래스의 companion object, 해당 클래스가 동일한 클래스를 파라미터로 받을 경우 등입니다.
class Person {
// 이렇게 일반적인 class-private일경우
private val name = ???
// 같은 클래스 내에서 접근 가능하고
def isSame(p: Person) = this.name == p.name
}
object Person {
val testPerson = new Person()
// companion object에서도 접근할 수 있습니다
def testPersonName = testPerson.name
}
object-private일때는 현재 객체의 현재 인스턴스일때만 접근이 가능합니다. 동일한 클래스인 다른 객체의 인스턴스를 통해서는 접근할 수 없습니다.
class Person {
// 이렇게 object-private으로 선언할경우
private[this] val name = ???
// 다른 인스턴스를 통해 접근하면 컴파일 에러가 발생합니다
def isSame(p: Person) = this.name == p.name
}
Singleton class types (싱글톤 클래스 타입)
스칼라를 통해 코드를 작성한다면 종종 싱글톤 클래스 타입을 만들게 됩니다.
def foo() = new Foo with Bar with Baz {
...
}
이러한 상황에서 반환 타입을 선언하여 가시성을 제한할 수 있습니다.
def foo(): Foo with Bar = new Foo with Bar with Baz {
...
}
foo() 호출자는 반환된 인스턴스의 제한된 뷰(Foo with Bar)만 볼 수 있습니다.
Structural typing (구조적 타이핑)
일반적인 상황에서 구조적 타입은 사용하지 마세요. 이는 편리하고 강력한 기능이지만, JVM에서 효율적으로 구현되지 않습니다. 하지만 구현 특성상 리플렉션을 수행하는 데 매우 유용한 약어를 제공합니다.
val obj: AnyRef
// close 함수를 가지는 객체라고 여기고 변환해서 close를 호출할 수 있습니다
obj.asInstanceOf[{ def close() }].close()