이 글은 Programming in Scala 4/e
Chapter 10을 읽고 정리한 글입니다.
클래스 간의 근본적인 두 가지 관계에는 상속(Inheritance)과 구성(composition)을 비교할 것이다.
구성(composition)
은 어떤 클래스가 다른 클래스의 참조를 갖는 것을 뜻한다. 참조를 가진 클래스는 가지고 있는 참조 클래스를 이용해 자신의 역할을 수행한다.
상속(inheritance)
은 슈퍼클래스/서브클래스 관계를 이룬다. 즉, 슈퍼클래스를 기반으로 코드를 확장이 되는것이다.
상속과 구성 외에도, 추상 클래스, 파라미터 없는 메서드, 클래스 확장, 메서드 및 필드 오버로드, 파라미터 필드, final 멤버와 final 클래스, 팩토리 객체와 팩토리 메서드에 대해서 알아볼 예정이다.
추상 클래스는 Scala에서도 Java에서처럼 abstract
라는 키워드를 통해 작성한다.
2차원 사각형을 구성하는 추상 클래스를 만든다고 가정하면, 아래와 같이 만들 수 있을 것이다.
abstract class Element {
def contents:Array[String]
}
이 클래스에서 contents는 구현이 없는 메서드 선언
이다. 즉, contents는 Element 클래스의 추상 멤버
이다. 추상 멤버가 있는 클래스는 추상 클래스로 선언해야만 한다. 이렇게, abstract
키워드는 추상 멤버가 있음을 알려주는 키워드이다.
이 때 주의해야할 점은, abstract 키워드가 붙어있지 않다. Java와 달리 Scala에서는 추상 메서드에 abstract 수식자를 추가할 필요가 없다.
Scala의 경우 메서드 구현이 없으면 추상 메서드로 구분하고, 구현이 있으면 구체 메서드(concrete method)라고 구분한다.
구현이 없는 클래스이므로 추상 클래스로는 인스턴스를 만들 수 없다. 위와 같이 new를 이용해 인스턴스를 만드려고 해도, 인스턴스화할 수 없다는 오류 메시지가 뜨며 에러가 발생한다.
인스턴스화를 하려면, 구현이 존재해야 한다. 따라서 Element를 상속 받은 서브 클래스에서 contents 구현을 채워 넣어야 한다.
선언(declaration)과 정의(definition)
선언은 위의 Element의 contents처럼 구현이 없어도 이러한 원소가 있다고 알려주는 것을 뜻한다. 반면, 정의는 구현을 가리키는 것으로, 위의 예시에 적용하면 Element의 contents는 선언되어 있지만, 정의되어 있지는 않은 것이다.
위의 추상 클래스를 조금 더 확장시켜보자.
width
와 height
라는 메서드들을 통해 각각의 너비와 높이를 리턴해주는 함수를 작성할 예정이다.
abstract class Element{
def contents:Array[String]
def height:Int = contents.length
def width:Int = if(height == 0) 0 else contents(0).length
}
height
와 width
메서드는 모두 메서드이지만, 파라미터가 존재하지 않는다.
def width():Int = // 본문
위와 같이 소괄호를 아예 사용하지도 않는다. 위와 같이 빈 괄호가 있는 메서드는 빈 괄호 메서드(empty-paren method)라고 부른다.
def width:Int = // 본문
반면, 스칼라에서는 위와 같이 파라미터 없는 메서드(parameterless method)를 사용하는 일이 자주 있다.
일례로, 어떤 메서드가 인자도 받지 않고 그 메서드가 속한 객체의 필드를 읽는 방식으로만 변경 가능한 상태에 접근하는 경우( = 객체 상태를 변경하지 않는 경우)에는 파라미터 없는 메서드를 사용하는 것이다.
단일 접근 원칙(uniform access principle)
필드나 메서드 중 어떤 방식으로 속성을 정의하더라도 클라이언트 코드에 영향을 끼치면 안 된다. 이 원칙 때문에 파라미터 없는 메서드를 정의한다.
위와 같은 메서드들은 필드로 정의할 수도 있다.
abstract class Element {
def contents:Array[String]
val height:Int = contents.length
val width:Int = if(height == 0) 0 else contents(0).length
}
이렇게 필드로 바꾸어도 무관하다. 라이브러리를 이용하는 클라이언트 관점에서 보면 완전히 같은 정의이기 때문이다. 필드와 유일한 차이는 필드를 사용하면 클래스가 초기화될 때 미리 계산해두므로, 호출 시마다 매번 계산을 하는 메서드 방식보다 약간 빠르다는 차이이다.
하지만, 필드를 사용하면 별도의 메모리 공간을 사용하므로 클래스를 어떻게 사용하냐에 따라 선택을 해야 한다.
다시 주제로 돌아와서, 빈 메소드와 파라미터 없는 메서드는 매우 혼란스럽다. 빈 괄호 메서드와 파라미터 없는 메소드가 거의 비슷하기 때문이다. 따라서 스칼라는 파라미터 없는 메서드와 빈 괄호 메서드를 자유롭게 섞어 쓸 수 있다. 즉, 파라미터 없는 메서드는 빈 괄호 메서드로 오버라이드할 수 있으며, 그 반대도 유효하다.
위는 length 메서드이다. 빈 괄호 메서드와 파라미터 없는 메서드로 모두 호출했을 때 똑같이 동작하는 것을 확인할 수 있다.
두 가지 방법 모두 유효하지만,해당 함수 호출이 호출 대상 객체의 프로퍼티에 접근하는 것 이상의 작업을 수행한다면 빈 괄호 메서드를 사용하기를 권한다. 예를 들어, 메서드가 I/O 작업을 수행하거나 var 변수 재할당 등 직간접적으로 변경 가능한 객체를 이용해 호출하면, 빈 괄호 메서드가 권장 된다.
요약하면, 인자를 받지 않고 부수 효과도 없는 메서드는 파라미터 없는 메서드로 정의할 것을 권장한다.
클래스를 확장하는 방법에는 상속이 있다. 상속이란, 기존 클래스에 근거하여 클래스를 확장해나가는 것을 뜻한다.
Java에서와 마찬가지로 extends
키워드를 사용하여 상속을 구현한다.
위의 예시로 돌아가자. 우리는 맨처음에 abstract class인 Element 클래스를 인스턴스화할 수 없는 것을 알았고, Element 클래스를 상속 받아 내부의 contents를 구현해야 하는 것을 알았다.
class ArrayElement(conts:Array[String]) extends Element {
def contents:Array[String] = conts
}
위는 상속을 구현한 예이다. ArrayElement는 Element를 상속받을 때, private가 아닌 모든 멤버를 상속 받는다. 또한, ArrayElement를 Element의 서브 타입으로 만든다. 그리고, ArrayElement를 Element의 서브 클래스라고 칭한다. 반대로, Element는 ArrayElement의 슈퍼 클래스가 된다.
서브타입 관계에서는 슈퍼클래스에서 값을 필요하면 서브 클래스의 값을 사용할 수 있다는 뜻이다.
추상 메서드 Element를 확장한 ArrayElement가 있고, ArrayElement에 array라는 원소를 선언했다.
그리고, 이를 Element
타입인 e에 넣으면 어떻게 될까? 출력 결과를 보면 알겠지만, ArrayElement의 원소가 Element에 들어간 것을 알 수 있다. 즉, 서브클래스는 서브 타입이므로 슈퍼 클래스가 값을 필요로 하면 서브 클래스의 값을 사용할 수 있는 것이다.
상속이 될 때, 슈퍼 클래스의 모든 멤버는 서브 클래스의 멤버가 된다. 다만, 2가지 예외가 있다. (1) 슈퍼 클래스의 private 멤버들은 상속 불가하다. (2) 슈퍼 클래스와 서브 클래스의 같은 메서드명을 가진 메서드가 있으면서 내용이 다를 때 슈퍼 클래스의 멤버가 서브 클래스의 멤버로 넘어가지 않는다.
이 경우에서, (2)의 경우를 오버라이드(override)
라고 한다.
즉, 오버라이드는 슈퍼클래스의 메서드를 서브 클래스에서 재정의 혹은 구현을 하는 것이다.
이때 주의해야할 점은, 스칼라에서 메서드와 필드는 같은 네임 스페이스에 속한다는 점이다.
따라서 위의 예시에서 오버라이드를 이렇게 할 수 있다.
class ArrayElement(conts:Array[String]) extends Elements{
val contents:Array[String] = conts
}
메서드였던 contents
를 필드로 오버라이드한 것을 알 수 있다. 즉, 메서드를 필드로 변경할 수 있는 것이다.
그리고, 필드와 메서드가 같은 네임스페이스 상에 있기 때문에 자바에서는 허용되지만 스칼라에서는 허용되지 않는 것이 있다. 한 클래스 내에서 같은 이름의 필드와 메서드를 동시에 정의하지 못하는 것이다.
class CompilesFine{
private int f = 0;
private int f(){
return 1;
}
}
위는 Java 코드이다. Java는 필드와 메서드의 네임스페이스가 다르므로 같은 이름의 메서드와 필드가 가능하지만, 스칼라에서는 컴파일 오류가 난다.
스칼라의 namespace
스칼라에서는 오직 2개의 namespace가 존재한다.
1. 값(필드, 메서드, 패키지, 싱글톤 객체)
2. 타입(클래스와 트레이트 이름)
자바의 namespace
필드, 메서드, 타입, 패키지
우리는 위에서 메서드와 필드가 네임스페이스를 공유한다는 사실을 알았다. 그렇다면, 간결하게 파라미터 필드를 정의할 수는 없는 것일까?
class ArrayElement(val contents:Array[String]) extends Element
위와 같이 선언하면 된다. 즉, 파라미터 내부에 val
이나 def
같은 키워드를 붙이면 되는 것이다.
위의 코드는 다음 코드와 같이 동작한다.
class ArrayElement(conts:Array[String]) extends Elements{
val contents:Array[String] = conts
}
따라서 네임 스페이스를 공유하여 같은 이름을 사용하지 못하더라도 간결하게 정의할 수 있다.
override는 슈퍼 클래스의 추상 메서드를 구현하는 경우 생략해도 되지만, 구체적 멤버를 오버라이드 하는 모든 멤버에 대해 override
키워드를 반드시 붙여야 한다.
이는 컴파일러가 발견하기 오류를 방지하고, 시스템은 안전하게 발전시킬 수 있기 때문이다.
우리는 위에서 다음과 같은 형태가 유효한 것을 확인할 수 있었다.
슈퍼 클래스에 서브 클래스의 객체를 참조할 수 있음을 확인했다. 이러한 현상을 다형성(polymorphism)
이라고 한다.
클래스들이 확장해나감에 따라 여러 가지 형태로 클래스를 다룰 수 있다.
변수나 표현식에 대한 메서드 호출을 동적으로 바인딩할 수 있다. 동적 바인딩이란, 실제로 불리는 메서드를 표현식이나 변수의 타입이 아닌, 실행 시점에 실제 그 객체가 어떤 타입인가를 따르는 것
이다.
상속 계층을 설계하다 보면 서브클래스가 특정 멤버를 오버라이드 하지 못하도록
막고 싶을 때가 있다. 이 경우, final
키워드를 멤버에 붙인다.
단순히 클래스 내부 멤버 뿐만 아닌, 클래스 전체를 상속하지 못해야할 때가 존재한다. 이럴 때는, final
키워드를 클래스 앞에 추가
하면 해당 클래스는 상속을 하지 못한다.
팩토리 객체는 다른 객체를 생성하를 메서드를 제공하는 객체
이다. 팩토리 객체가 있으면, 클라이언트는 new를 이용해 직접 객체를 만들기보다 팩토리 객체를 통해 객체를 생성하는게 낫다.
우리는 이러한 팩토리 객체와 메서드를 구현하는 방법이 여러 가지 있다. 가장 직관적으로는 동반 객체를 만들고, 여기에 팩토리 메서드를 만드는 것이다.
object Element {
def elem(contents:Array[String]) :Element = new ArrayElement(contents)
def elem(line:String): Element = new LineElement(line)
def elem(chr:Char, width:Int, height:Int):Element = new IniformElement(chr, width, height)
}
위와 같이 작성한다면, elem이 팩토리 메서드가 되며, Element가 팩토리 객체가 된다. 이렇게 내부 표현을 감춤으로써 사용할 수 있다.