[Scala] trait

smlee·2023년 8월 16일
0

Scala

목록 보기
16/37
post-thumbnail

이 글은 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. 간결한 인터페이스 : 풍부한 인터페이스 역할을 할 여러 추상 메소드를 동일 트레이트 안에 구현


trait의 동작 원리

트레이트를 정의하는 방법은 클래스의 정의 방법과 같다. 다만, class 키워드 대신 trait 키워드를 사용한다는 것이 차이점이다.

trait Philosophical {
	def philosophize() = {
    	println("I consume memory, therefore I am!")
    }
}

위의 코드는 trait 정의의 한 예시이다. 슈퍼 클래스를 따로 지정하지 않았으므로 AnyRef가 슈퍼 클래스이다. 그리고, philosophize라는 메서드가 존재하는 트레이트이다.

위와 같이 트레이트를 정의하고 나면, extendswith 키워드를 사용해 클래스에 조합하여 사용할 수 있다. 하지만, 트레이트를 사용할 때 상속보다는 믹스인(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로 여러 트레이트를 연결하고, 트레이트 내부 메서드를 오버라이드하여 다양한 기능을 구현할 수 있다.

트레이트와 클래스 간 다른 문법

(1) 트레이트는 '클래스' 파라미터를 가질 수 없다.

클래스는 다음과 같은 형태로 선언될 수 있다.

class Point(x:Int, y:Int)

즉, 클래스 파라미터 x,y를 받아서 정의된 클래스이다.
하지만, 트레이트는 위와 같은 형태로 선언하면 컴파일을 하지 못한다.

trait PointTrait (x:Int, y:Int) // 컴파일할 수 없다.

(2) 트레이트는 동적 바인딩을 지원한다.

클래스의 경우 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을 선언했을 뿐인데 트레이트의 모든 원소들을 사용할 수 있는 것을 알 수 있다.

Ordered 트레이트

풍부한 인터페이스를 이용하면 편리해지는 부분이 있지만, 다음과 같은 문제가 있을 수 있다.

<=>=, >, <는 서로 연관된 코드들이다. 이들 메서드를 선언하면 다음과 같을 것이다.

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 메서드를 구현을 하면 되는 것이다.

(1) Ordered trait 믹스인

지금까지 살펴 본 트레이트들과는 달리, Ordered trait는 타입 파라미터를 명시해야 한다. 위의 코드처럼 Ordered[C]처럼 C에 타입 타라미터를 명시해야하는 것이다.

(2) compare 메서드 정의

compare 메서드는 호출 대상 객체와 인자로 전달 받은 객체를 비교하고, 두 객체가 동일하면 0, 호출 대상 객체 자신이 인자보다 작으면 음수, 더 크면 양수를 반환해야 한다. 이러한 규칙을 만족하는 식을 쓰면 된다.

트레이트를 이용해 변경 쌓아 올리기

우리는 위의 예제들에서 간결한 인터페이스를 풍부한 인터페이스로 바꾸는 것을 살펴봤다. 우리는 2번째 용도로 쌓을 수 있는 변경(stackable modification)을 적용하는 방법을 살펴본다.
정수로된 큐에 변경을 쌓아나가 보자. 큐에는 정수를 넣는 put과 정수를 꺼내는 get이라는 두 메서드가 존재한다. 또한, 다음과 같이 변경하는 트레이트들을 정의해보자

  • Doubling : 큐에 있는 모든 정수를 2배로 만든다.
  • Incrementing : 큐에 있는 모든 정수를 1씩 증가시킨다.
  • Filtering : 큐에 있는 음수를 걸러낸다.
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)해서 어떤 메서드를 호출할지 결정한다. 이 차이로 인해 쌓을 수 있는 변경이 가능한 것이다.


📚 Reference

0개의 댓글