이 글은 Programming in Scala 4/e
Chapter 12를 읽고 작성한 글입니다.
스칼라에서 trait는 코드 재사용의 근간
을 이루는 단위이다. 트레이트로 메서드와 필드 정의를 캡슐화하면 트레이트를 조합한 클래스에서 그 메서드나 필드를 재사용할 수 있다.
하나의 슈퍼 클래스만 갖는 상속과는 달리, 트레이트의 경우 몇 개라도 혼합해 사용(mix-in)할 수 있다.
이 챕터에서는 트레이트를 유용하게 사용할 수 있는 방법 2가지를 알아본다.
(1) 간결한 인터페이스(thin interface)를 활장해 풍부한 인터페이스(rich interface)를 만드는 것
(2) 쌓을 수 있는 변경(stackable modification)을 정의하는 것이다.
밑에서 위의 내용들을 자세히 정리하려 한다.
풍부한 인터페이스(rich interface)와 간결한 인터페이스(thin interface)
1. 풍부한 인터페이스 : 많은 메소드 중 필요에 의해 일치하는 메소드를 선택하여 사용하는 측에 편리한 인터페이스이다.
2. 간결한 인터페이스 : 풍부한 인터페이스 역할을 할 여러 추상 메소드를 동일 트레이트 안에 구현
트레이트를 정의하는 방법은 클래스의 정의 방법과 같다. 다만, class 키워드 대신 trait 키워드를 사용한다는 것이 차이점이다.
trait Philosophical {
def philosophize() = {
println("I consume memory, therefore I am!")
}
}
위의 코드는 trait 정의의 한 예시이다. 슈퍼 클래스를 따로 지정하지 않았으므로 AnyRef
가 슈퍼 클래스이다. 그리고, philosophize라는 메서드가 존재하는 트레이트이다.
위와 같이 트레이트를 정의하고 나면, extends
나 with
키워드를 사용해 클래스에 조합하여 사용할 수 있다. 하지만, 트레이트를 사용할 때 상속보다는 믹스인(mix-in)
을 사용하는 것이 좋다.
트레이트를 믹스인할 때는 extends
키워드를 사용한다. extends를 사용하면 트레이트의 슈퍼클래스를 암시적으로 상속한다. 만약 위의 예시인 트레이트를 믹스인하는 클래스를 살펴보자.
class Frog extends Philosophical {
override def toString = "green"
}
위와 같이 Philosophical이라는 트레이트를 믹스인한 클래스에서 다음 명령어들을 실행하면 어떻게 될까?
val frog = new Frog
frog.philosophize()
frog는 Frog에서 toString을 오버라이드한 문자열을 잘 출력하는 것이 보인다.
그리고, 트레이트 내부의 메서드도 잘 출력하는 것이 보인다.
그리고 위의 코드처럼 트레이트도 타입을 지정할 수 있다. 트레이트를 타입으로 지정하는 변수 philo
를 선언하고, 여기에 frog를 할당해도 제대로 출력되는 것이 보인다.
이처럼 트레이트를 어던 슈퍼클래스를 명시적으로 상속 받은 클래스에 혼합할 수 있다.
트레이트를 어떤 슈퍼클래스를 명시적으로 상속받은 클래스에 혼합할 수도 있다. extends
키워드를 사용해 슈퍼클래스를 지정하고, with
를 통해 트레이트를 믹스인 하면 된다.
다음 예시를 보자.
class Animal
class Frog extends Animal with Philosophical{
override def toString = "green"
}
class Animal
trait HasLegs
class Frog extends Animal with Philosophical with HasLegs{
override def toString = "green"
override def philosophize() = {
println("It ain't easy being" + toString+"!")
}
}
위와 같이 extends
를 통해 슈퍼 클래스를 지정하고 with
로 여러 트레이트를 연결하고, 트레이트 내부 메서드를 오버라이드하여 다양한 기능을 구현할 수 있다.
클래스는 다음과 같은 형태로 선언될 수 있다.
class Point(x:Int, y:Int)
즉, 클래스 파라미터 x,y를 받아서 정의된 클래스이다.
하지만, 트레이트는 위와 같은 형태로 선언하면 컴파일을 하지 못한다.
trait PointTrait (x:Int, y:Int) // 컴파일할 수 없다.
클래스의 경우 super
를 호출하여 상위 클래스를 호출하여 정적으로 바인딩하지만, 트레이트에서는 동적으로 바인딩한다. 이는 포스팅 뒷부분에서 나올 쌓을 수 있는 변경(stackable modification)과 관련이 있다.
트레이트의 주된 사용 방법 중 하나는 어떤 클래스에 그 클래스가 이미 갖고 있는 메서드를 기반으로 하는 새로운 메서드를 추가하는 것이다. 즉, 간결한 인터페이스(thin interface)를 풍부한 인터페이스(rich interface)로 만들 때 트레이트를 사용할 수 있다.
자바의 CharSequence 인터페이스를 스칼라의 트레이트로 선언한다면 다음과 같을 것이다.
trait CharSequence {
def charAt(index:Int) : Char
def length:Int
def subSequence(start:Int, end:Int): CharSequence
def toString: String
}
자바의 CharSequence 인터페이스를 스칼라로 옮기면 위와 같다. 즉, 구현부는 존재할 수 없는 것이다.
하지만, 스칼라에서는 내부에 구현을 넣을 수 있으므로 풍부한 인터페이스를 더 편하게 만들 수 있다.
즉, 트레이트를 이용해 인터페이스를 풍부하게 만들고 싶다면, 트레이트에 간결한 인터페이스 역할을 하는 추상메서드를 구현하고, 풍부한 인터페이스 역할을 할 여러 메서드를 추상 메서드를 사용하여 간결한 인터페이스만 구현하면 결국 풍부한 인터페이스의 구현을 모두 포함한 클래스를 완성할 수 있다.
우리는 직사각형 객체를 트레이트를 사용하여 구현하고 싶다. 직관적으로 트레이트가 없는 코드는 다음과 같이 구상할 수 있을 것이다.
class Point(val x:Int, val y:Int)
class Rectangle(val topLeft:Point, val bottomRight:Point){
def left = topLeft.x
def right = bottomRight.x
def width = right - left
// etc
}
직사각형을 가리키는 Retangle 클래스는 직사각형의 좌상단과 우하단 2개의 좌표를 받고 위치나 너비 등을 제공한다.
만약, 그래프 라이브러리에 속한 것이라면 2차원 그래픽 위젯도 들어있을 것이다.
abstract class Component {
def topLeft:Point
def bottomRight:Point
def left = topLeft.x
def right = bottomRight.x
def width = right - left
// etc
}
이러한 코드들은 트레이트를 통해 중복을 방지할 수 있다. 그렇다면 위 코드를 아우르는 트레이트는 어떻게 될까?
trait Rectangular {
def topLeft:Point
def bottomRight:Point
def left = topLeft.x
def right = bottomRight.x
def width = right - left
}
위와 같이 Rectangular라는 트레이트에 공통적인 기능들을 넣는다. 그리고 Component 클래스는 Rectangular를 믹스인해서 사용할 수 있다.
abstract class Component extends Rectangular {
// component 고유의 메서드
}
위와 같이 Rectangular의 기능을 맡는 trait를 믹스인하고, 내부에는 component의 고유 메서드를 작성하거나 오버라이드시키면 된다.
비슷하게 Rectangle도 트레이트를 믹스인할 수 있다.
class Rectangle(val topLeft:Point, val bottomRight:Point) extends Rectangular {
// 기타 메서드
}
위와 같이 정의를 마쳤으면 이제 Rectangle을 생성하고 바로 width, left 등 트레이트 내부 속성들을 조회할 수 있다.
위의 예제를 실제로 실행한 결과이다. Rectangle을 선언했을 뿐인데 트레이트의 모든 원소들을 사용할 수 있는 것을 알 수 있다.
풍부한 인터페이스를 이용하면 편리해지는 부분이 있지만, 다음과 같은 문제가 있을 수 있다.
<=
와 >=
, >
, <
는 서로 연관된 코드들이다. 이들 메서드를 선언하면 다음과 같을 것이다.
class Rational(n:Int, d:Int) {
require(d!=0)
private val g:Int = gcd(n, d)
val numer:Int = n/g
val d:Int = d/g
// 중략
def < (that:Rational) = this.numer * that.denom < that.numer * this.denom
def > (that: Rational) = !(that < this)
def <= (that:Rational) = (this < that) || (this == that)
def >= (that:Rational) = (this > that) || (this == that)
}
중략 밑의 메서드들을 보면 부등호의 방향만 다르고 중복된 코드를 많은 것을 확인할 수 있다.
Ordered trait
를 사용하면 이를 해결할 수 있다. Ordered
라는 트레이트를 사용하면 하나의 비교 연산자만 작성하면 모든 비교 연산자 구현을 대신할 수 있다.
class Rational (n:Int, d:Int) extends Ordered[Rational] {
// 중략
def compare(that:Rational) =
(this.numer * that.denom) - (that.numer * this.denom)
}
위는 여러 부등호 표현식을 구현하는 대신, compare
메서드 하나만 구현해도 위의 중복되는 코드들을 모두 줄여준다. 어떻게 이것이 가능한 것일까?
Ordered 트레이트를 믹스인 후, compare 메서드를 구현을 하면 되는 것이다.
지금까지 살펴 본 트레이트들과는 달리, Ordered trait
는 타입 파라미터를 명시해야 한다. 위의 코드처럼 Ordered[C]
처럼 C에 타입 타라미터를 명시해야하는 것이다.
compare 메서드는 호출 대상 객체와 인자로 전달 받은 객체를 비교하고, 두 객체가 동일하면 0, 호출 대상 객체 자신이 인자보다 작으면 음수, 더 크면 양수를 반환해야 한다. 이러한 규칙을 만족하는 식을 쓰면 된다.
우리는 위의 예제들에서 간결한 인터페이스를 풍부한 인터페이스로 바꾸는 것을 살펴봤다. 우리는 2번째 용도로 쌓을 수 있는 변경(stackable modification)
을 적용하는 방법을 살펴본다.
정수로된 큐에 변경을 쌓아나가 보자. 큐에는 정수를 넣는 put
과 정수를 꺼내는 get
이라는 두 메서드가 존재한다. 또한, 다음과 같이 변경하는 트레이트들을 정의해보자
abstract class IntQueue{
def get():Int
def put(x:Int) :Unit
}
import scala.collection.mutable.ArrayBuffer
class BasicQueue extends IntQueue{
private val buf = new ArrayBuffer[Int]
def get() = buf.remove(0)
def put(x:Int) = {buf += x}
}
위와 같이 기본적인 큐의 기능을 구현한 추상 클래스와 클래스에 대하여 실행을 실제로 시켜보면 다음과 같다.
정상적으로 동작하는 것을 알 수 있다. 이러한 동작들에 위에서 정의한 3가지 기능을 쌓으려고 한다.
trait Doubling extends IntQueue{
abstract override def put(x:Int) = super.put(2 * x)
}
trait의 추상 메서드가 super
를 호출하는 점이 흥미롭다. 일반적인 클래스라면 이런 방식의 호출은 실패하지만, 트레이트의 경우에는 성공할 수 있다.
이렇게 선언한 트레이트가 잘 동작하는 것을 알 수 있다.
이때, 믹스인의 순서가 중요하다. 믹스인을 할 때, 가장 오른쪽에 있는 트레이트의 효과를 먼저 적용
한다.
이러한 코드들은 매우 유연하다.
트레이트는 여러 클래스 유사 구조를 상속 받는 방법이지만, 많은 언어에서 볼 수 있는 다중 상속과는 중요한 차이가 있다.
다중 상속 언어에서 super
를 호출할 때 어떤 메서드를 부를 것인지에 대한 결정은 호출이 이루어지는 곳(컴파일 시점)에 이루어진다. 즉, 트레이트를 사용할 때는 특정 클래스에 믹스인한 클래스와 트레이트를 선형화(linearization)
해서 어떤 메서드를 호출할지 결정한다. 이 차이로 인해 쌓을 수 있는 변경이 가능한 것이다.