스칼라 언어 튜토리얼(Scala Tutorial)

원문: http://www.scala-lang.org/docu/files/ScalaTutorial.pdf#

1. 시작하기

이 문서는 스칼라 언어( Scala Language)와 컴파일러 시작하기 문서이다. 이 문서는 어느 정도의 프로그래밍 경험이 있고 Scala로 무엇을 할 수 있는 지에 대한 개요를 알고 싶어하는 독자를 위한 문서이다. 독자들은 OOP(특히 Java)에 대해서 기본 지식이 있다고 가정한다.

2. 첫 번째 예제

첫 번째 예제로 Hello world 프로그램을 사용하겠다. 썩 매력적인 예는 아니지만 언어 자체에 대해서 많이 알지 않더라도 Scala툴을 사용하는 방법을 설명하는 데에는  유용하다. Hello world 소스는 아래와 같다. 

object HelloWorld {
  def main(args: Array[String]) {
    println("Hello, world!")
  }
}

이 프로그램의 구조는 Java 프로그래머라면 꽤 익숙한 구조일 것이다. main이라는 메소드 하나로 구성되어 있고 그 메개변수는 실행할 때 전달한 인자들이 된다. 즉, String의 배열이다. 메소드의 내부는 println이라는 기정의 메소드를 호출한다. 매개변수로 "Hello world!"를 전달하고 있다. main 메소드는 리턴값이 없다. 이런 메소드를 (프로시져 메소드)라고 한다.리턴값이 없을 경우는 리턴형도 정의할 필요가 없다.

Java 프로그래머에 덜 익숙한 부분은 object 정의 이다. 이 오브젝트는 main 메소드를 소유하고 있다. 이런 정의는 Singleton 객체를 생성하게 된다. 결과적으로 이 예제는 HelloWorld라는 클래스와 인스턴스를 동시에 생성하게 된다. 또한 인스턴스의 이름도 HelloWorld가 된다. 인스턴스가 생성은 처음 사용될 시점에 일어난다.

눈치빠른 독자라면 main 메소드가 static으로 정의되지 않았다는 점도 발견했을 것이다. static멤버(메소드나 필드)는 Scala에서는 존재하지 않는다. Scala에서는 static 대신에 Singleton을 사용하게 된다.

2.1 컴파일하기

예제를 컴파일하려면 컴파일러를 실행해야 한다. scalac가 컴파일러이다. scalac는 여느 컴파일러와 마찬가지로 소스파일명과 컴파일 옵션들을 매개변수로 받고 하나 이상의 컴파일 결과 오브젝트 파일을 생성한다. 오브젝트 파일들은 Java클래스 파일들이다. 위의 예제를 

HelloWorld.scala로 저장했다고 하면 아래와 같이 실행해서 컴파일한다. ( '>'문자는 쉘 프로프트를 의미한다)

> scalac HelloWorld.scala

현재 디렉토리에 몇 개의 클래스 파일들이 생성될 것이다. 그 중 하나는 HelloWorld.class일 것이고 바로 이 클래스 파일이 scala 명령으로 실행할 수 있는 클래스를 가지고 있다.

2.2 실행하기

컴파일이 성공했다면, scala 명령으로 실행 시킬 수 있다. 사용법은 java 명령과 매우 비슷하다. 옵션들 또한 동일하다. 위의 예제는 아래와 같이 실행한다.

> scala -classpath . HelloWorld

Hello, World!

3. Java 사용하기

Scala의 강점 중 하나는 Java 코드와 간단히 상호작용할 수 있다는 점이다. java.lang 패키지의 모든 클래스를 디폴트로 import하고 있다. 그 외의 클래스를 사용할 때에는 명시적으로 import를 해야 한다.

여기 그 예제가 있다. 현재의 날짜 시간을 특정 국가의 표기법( 예를 들면 프랑스)으로 구하기이다.

Java에는 Date나 DateFormat같은 파워풀한 라이브러리 클래스들이 있다. Scala는 Java와 완전히 호환되기 때문에 Scala에 새롭게 동일한 클래스를 준비할 필요가 없다. 그저 Java의 해당 라이브러리를 import하기만 하면 된다.

import java.util.{Date, Locale}
import java.text.DateFormat
import java.text.DateFormat._

object FrenchDate {
  def main(args: Array[String]) {
    var now = new Date
    val df = getDateInstance(LONG, Locale.FRANCE)
    println(df format now)
  }
}

Scala의 import문은 자바와 거의 비슷해 보이지만, 더 파워풀하다. 첫 번째 줄 처럼 {}를 사용해서 같은 패키지내의 클래스를 동시에 지정할 수 있다. 세 번째 라인처럼 그 패키지내의 모든 클래스를 import하려면 *대신에 _를 사용한다. Scala에서 *는 특수한 의미를 가지는 식별자 이기 때문이다. 여기에 대해서 나중에 설명될 것이다. 세 번째 줄에 의해서 DateFormat 클래스의 모든 멤버가 임포트된다. getDateInstance 메소드와 LONG 필드가 직접 엑세스 가능한 상태가 된다.
main 메소드에서 Java의 Data 인스턴스를 생성한다. 파라메터가 없기 때문에 디폴트로 현재의 날짜와 시간으로 생성된다. 다음으로 getDateInstance static 메소드로 날짜 포멧을 설정한다. 마지막 줄에서 현재의 날짜를 지역화된 DateFormat 인스턴스로 포멧한 결과를 출력한다. 특히 마지막 줄은 Scala의 재미있는 문법이 사용되었다.  매개변수가 하나인 메소드들은 삽입문(infix syntax)을 사용할 수 있다.

  df format now

는 좀 더 간결하다는 점 빼고는 아래와 동일하다.

  df.format(now)

별 것 아닌 것 같지만, 중요한 결과를 낳게 된다. 자세한 내용은 다음 절에 설명될 것이다.

Java와의 융화에 대한 마지막으로 언급할 내용은, Scala는 직접적으로 Java의 클래스를 상속하거나 interface를 implemnet하는 것도 가능하다는 점이다.

4. 모든 것은 Object이다.

Scala는 순수 객체지향 언어이다. 즉 수치, 함수 등 모든 것은 객체이다. Java와 다른 점이 바로 이것이다. Java에서는 원시타임과 참조 타입을 분리해서 사용하며 함수를 값으로 사용할 수 없다.

4.1 수치는 Object이다.

수치도 Object이기 때문에 메소드를 가지고 있다. 그리고 아래와 같은 연산 표현식은

  1 + 2 * 3 / x

명시적으로 메소드를 호출하고 있다. 왜냐하면 아래의 표현식과 동치이기 때문이다.

(1).+(((2).*(3))./(x))

+, *와 같은 것도 Scala에서는 문제없는 식별자임을 의미한다.
두 번째 식의 괄호는 생략할 수 없다. 왜냐하면 Scala의 구문 분석기는 각 토큰을 최대한 길게 구분하기 때문이다. 즉, 아래와 같은 표현식은

1.+(2)

1.과 +와 2로 토큰을 분리한다. 이렇게 분리되는 이유는 1보다 1.이 가장 긴 토큰이 되기 때문이다. 1.은 1.0이라는 상수로 해석되엇 Int가 아니고 Double 객체가 된다. 아래와  같이 표현해야

(1).+(2)

1이 Double형으로 해석되는 것을 피할 수 있다.

4.2 함수도 Object이다

Java 프로그래머에게 함수도 Scala에서는 Object라고 한다면 좀 놀랄 것이다. Object이기 때문에 함수도 매개변수로 전달할 수도 있고 변수에 넣어둘 수도 있다. 함수를 값으로 취급할 수 있다는 점이 함수 프로그래밍이라는 아주 재미있는 프로그래밍 페러다임이다.

함수를 값처럼 사용하는 것이 왜 유용한지를 알아 보기 위해서, 아주 간단한 예를 한 번 보자. 타이머 함수가 있다고 하자. 이 함수는 1초에 한번씩 어떤 액션을 실행하게 된다. 근데 어떻게 액션을 지정할까(전달할까)? 바로 함수를 전달한다. 아마 이러한 종류의 함수 전달 기법은 많은 프로그래머들에게 익숙할 것이다. 이러한 기법은 종종 유저 인터페이스 코드에서 사용된다. 어떤 이벤트가 발생했을 때 실행할 콜백 함수를 지정하는 것이 그 예이다.

아래의 프로그램에서 oncePerSecond라는 타이머 함수가 있고, 콜백 함수를 매개변수로 받는다. 콜백함수의 타입 정의는 () => Unit 이라는 부분이다. 매개변수가 없으며 리턴값도 없는 함수임을 의미한다. Unit이라는 타입은 C/C++에서 void와 의미상 동일하다)  main함수는 터미널에 문장을 출력하는 함수를 매개변수로 timer 함수를 호출한다. 즉, 이 프로그램은 매 초 "time flies like an arrow" 라는 문장을 출력하게 된다.

object Timer {
  def oncePerSecond(callback: () => Unit) {
    while (true) { callback(); Thread sleep 1000 }
  }
  def timeFlies() {
    println("time flies like an arrow...")
  }
  def main(args: Array[String]) {
    oncePerSecond(timeFlies)
  }
}

4.2.1 Anonymous 함수

위의 예는 이해하기 쉽긴하지만 조금 개선해 볼 여지는 있다. 먼저, timeFlies 라는 함수는 oncePerSecond 함수에 전달되기만들 위해서 정의 되어 있다. 이 함수에 이름을 붙일 필요성이 없는 것이다. oncePerSecond를 호출할 때 매개변수를 지정하듯이 함수도 그 자리에 바로 내용을 적을 수 있다면 편할 것이다. Scala에서는 이러한 함수를 anonymous 함수라고 한다. 즉 이름 없는 함수라는 말이다. 아래가 timeFlies 함수를 정의하는 대신에 anonymous 함수를 사용하도록 개정한 예이다.

object TimerAnonymous {
  def oncePerSecond(callback: () => Unit) {
    while (true) { callback(); thread sleep 100 }
  }
  def main(args: Array[String]) {
    oncePerSecond( () => println("time flies like an arrow..."))
  }
}

여기서 anonymous 함수의 본체는 => 이후의 부분이다. ()의 에는 매개변수를 지정할 수 있는데 여기서는 매개변수가 없다고 정의했다. 앞의 예에서의 timeFlies 함수의 본체와 동일한 내용이다.

5. 클래스

앞에서 본 것처럼 Scala는 객체 지향 언어이므로 클래스라는 개념이 존재한다. 클래스 문법은 Java의 그것과 거의 비슷하다. 중요한 차이점이라면 Scala의 클래스는 파라메터를 가질 수 있다는 점이다. 여기 복소수를 클래스로 표현한 것이다.

class Complex(real: Double, imaginary: Double) {
  def re() = real
  def im() = imaginary
}

이 클래스는 실수부와 허수부,  2개의 매개변수는 받는다. 이 매개변수들은 인스턴스를 생성할 때 반드시 전달해야 한다. 예> new Complex(1.5, 2.3) 메소드는 각각의 변수에 접근하는 re, im 두개를 가지고 있다.

메소드에 리턴형이 지정되어 있지 않다는 점에 주목하자. 컴파일러는 우변을 보고 각각 Double형을 리턴함을 연역해 낸다.

하지만 이 예 처럼 항상 타입을 추측할 수 있는 것은 아니며 그러한 생략할 수 있는지 없는지를 판단하는 룰을 찾는 것도 아직은 거의 비현실적이다. 처음에는 최대한 간단한 부분에서부터 타입을 생략하도록 해서 컴파일러와 친숙해 가는 것이 중요하다. 얼마 정도 지나면 언제 타입을 생략할 수 있고 언제 명시적으로 타입을 정의해야 하는지 느낄 수 있게 될 것이다.

5.1 매개변수가 없는 메소드

re와 im메소드에 약간의 흠이 있다면, 호출할 때 아래와 같이 ()를 붙여줘야 한다는 점이다. 

object ComplexNumbers {
  def main(args: Array[String]) {
    val c = new Complex(12, 3.4)
    println("imaginary part: " + c.im())
  }
}

()를 생략해서 실수부와 허수부를 마치 필드에 엑세스하듯이 사용할 수 있다면 좀 더 좋을 것이다. Scala라면 ()를 생략해서 필드에 엑세스 하는 듯한 문법을 사용할 수 있다. 다른 매개변수가 없는 메소드와의 차이점은 정의할 때, 사용할 때 ()를 생략했다는 점 말고는 없다.  그래서 아래와 같이 다시 기술할 수 있다.

class Complex(real: Double, imaginary: Double) {
  def re = real
  def im = imaginary
}

5.2 상속과 오버라이딩

Scala에서 모든 클래스는 상위 클래스로부터 상속한다. Complex 클래스와 같이 상위 클래스를 지정하지 않으면 Scala.AnyRef 가 상위 클래스가 된다.

상위 클래스의 메소드를 오버라이드 하는 것도 가능하다. 하지만 Scala는 오버라이드할 때 명시적으로 override 수식자로 오버라이드함을 표시해야 한다. 이는 실수로 오버라이드하는 것을 피할 수 있는 잇점도 있다. Object 객체에는 toString 메소드가 존재한다. Complex 클래스도 Object 클래스로 부터 상속한 toString 메소드를 재정의하면

class Complex(real: Double, imaginary: Double) {
  def re = real
  def im = imaginary
  override def toString() = 
    "" + re + (if (im < 0) "" else "+") + im + "i"
}

6 Case 클래스와 패턴 매칭

프로그램에서 종종 볼 수 있는 데이터 구조로 tree가 있다. 예를 들어서 인터프리터나 컴파일러는 그램을 내부적으로 tree로 분석한다. XML 문서도 tree 구조이다. red-black tree 와 같이 몇 종류의 컨테이너도 tree에 기반한다.

이 장에서는 간단한 계산기 프로그램으로 Scala에서 tree를 다루는 것을 확인해 볼 것이다. 이 프로그램이 하는 일은 덧셈과 정수 상수와 정수 변수로 이루어진 아주 간단한 연산 표현식을 조작하는 것이다. 그 두가지 예로 1 + 2와 (x + x) + ( 7 + y)를 사용한다.

가장 먼저 해야 할 일은 표현식으로 어떻게 구성할 것인가를 정하는 것이다. 가장 자연스러운 것은 tree이다. 노드가 연산을, 잎이 값들이 된다. (상수나 변수)

Java에서는 이런 tree는 tree를 위한 상위 추상 tree 클래스를 사용해서 구성하고 각 노드와 잎을 각각을 하위 클래스로 구현할 것이다. 함수 프로그래밍 언어에서는 아마도 대수형 데이터타입을 사용할 것이다. Scala는 그 중간 쯤 되는 case 클래스라는 컨샙을 제공한다.  아래에 tree에 활용한 예가 있다.

abstract class Tree
case class Sum(l: Tree, r: Tree) extends Tree
case class Var(n: String) extends Tree
case class Const(v: Int) extends Tree

Sum, Var, Const는 모두 case 클래스로 선언되었기 때문에 일반 클래스와는 아래의 내용에서 다르다.

- 인스턴스 생성에서 new 키워드를 생략할 수 있다. (즉, new Const(5) 대신에 Const(5)라고 사용해도 됨을 의미)
- 생성자 파라메터에 대한 getter 함수가 자동으로 정의된다. (즉, Const 클래스의 인스턴스 c의 생성자 파라메터 v의 값을 구하기 위해서 c.v 를 사용할 수 있게 된다.)
- equals, hashCode가 디폴트로 자동으로 정의된다. 이는 인스턴스의 구조를 기반으로 제공된다. 클래스가 무엇인지에 따라 정의 되는 것이 아니다.
- toString 메소드가 자동으로 정의된다. 이 메소드는 String 표현을 원형대로 표현한다. (예를 들며 x + 1 이라는 tree표현은 Sum(Var(x), Const(1))로 표현된다) 
- 패턴 매칭을 통해서 인스턴스가 풀어져서 사용된다. 아래에 설명이 나올 것이다.

이렇게 산술 연산식을 표현할 데이터 타입을 정의하였다. 이제 이 데이터를 조작할 연산의 정의를 시작해 보자. 먼저 특정한 환경에서 연산식을 처리하는 함수부터 시작한다. 환경이란 변수와 그 값의 대응 테이블이다. 예를 들어서 x + 1 이라는 연산식은 x가 5라고 정의된 환경에서는 6이 될 것이다. 이를 표현하면 { x -> 5 } 는 6이 된다.

이제 환경을 어떻게 표현할 지를 결정해야 한다. hash table이 먼저 떠오르겠지만 함수 자체를 사용하는 방법이 있다! 환경이란 값을 이름과 연관시키는 함수일 뿐이다. 위의 예의 { x -> 5}는 Scala에서는 아래와 같이 간단히 표현할 수 있다.

  { case "x" => 5 }

이 소스는 매개변수로 "x"라는 String을 전달하면 정수 5를 리턴하는 함수를 정의하게 된다. 그외의 경우는 예외를 발생시키며 실패한다. 환경 함수를 기술하기 전에, 환경의 타입에 이름을 붙이자. 물론 타입은 String => Int가 된다. 이런한 타입에 이름을 부여하게 되면 좀 더 알기 편하고 앞으로 수정하기도 편하다. 아래와 같이 정의한다.

type Environment = String => Int

이제 Environment란 String을 매개변수로 받고 Int를 리턴하는 함수데이터형임을 의미하게(alias) 된다.

평가 함수를 정의하는 것은 개념적으로 매우 심플하다. 두개의 표현식을 더한 값은 두각 표현식의 값을 더한 것도 동일하다. 표현식이  변수라는 환경에서 취득할 수 있고 상수라면 그 자체가 값이 된다. 이를 Scala에서 표현하면

def eval(t: Tree, env: Environment): Int = t match {
  case Sum(l, r) => eval(l, env) + eval(r, env)
  case Var(n) => env(n)
  case Const(v) => v
}

eval(평가)함수는 tree t에 패턴매칭을 실행해서 동작하게 된다. 위의 소스는 직관적으로 무슨 동작을 하는지 표현되어 있다. 아래의 설명처럼 Tree와 환경을 받아서 Tree의 종류에 따라서 다음 처리를 분기한다.

1. 트리 t가 Sum이라면 왼쪽 서브 트리를 l이라고 바인드하고 오른쪽 서브트리를 r이라고 바인드한다. 화살표의 오른쪽으로 처리를 진행해서 l과 r에 대해서  다시 한번 각각 eval함수를 실행하게 된다. 

2. 첫 번째 매칭에서 실패한 경우, 즉, Sum이 아니라고 하면. Var인지 첵크해서 Var라고 한다면 Var노드의 값을 n이라는 변수로 바인드 해서 화살표 오른쪽을 실행한다.

3. 두 번째에서도 매칭에 실패하고 Const였다고 하면 Const노드의 값을 변수 v에 대입하고 화살표 오른쪽 식을 실행한다.

4. 모두 실패했다면, 예외가 발생(raise)해서 패턴 매칭에 실패했음을 알린다. 이런 경우는 Tree클래스의 서브 클래스가 위의 세 종류 이외에 더 존재할 경우에 발생할 수 있을 것이다.

위에서 보듯이 패턴매칭 개념은 값을 순서대로 매칭해 보고 매칭되었을 때 그 값의 여러가지 부분을 변수에 바인드하고 오른쪽 표현식(일반적으로 변수들을 바인드 된 변수를 사용하는)이 실행된다. 

OOP에 익숙한 프로그래머라면 왜 eval이라는 메소드를 Tree와 그 서브 클래스에 정의하지 않는지 의아해 할지도 모르겠다.  case 클래스에도 메소드를 정의할 수 있기 때문에 그렇게도 할 수 있긴 하다. 어떤 것을 쓰느냐는 취향의 문제이긴 하지만 확장성 면에서 중요한 암시적 차이가 있다.

- 메소드를 사용할 경우, 새로운 종류의 노드를 추가할 경우 그냥 Tree를 상속하는 새로운 하위 클래스를 정의하기만 하면 된다. 반면에 새로운 연산을 정의하려면 모든 하위 클래스를 수정해야 하므로 일이 복잡해 질 것이다.

- 패턴 매칭을 사용하면 상황은 반전된다. 새로운 노드를 추가하려면 tree에 대해서 패턴 매칭을 수행하는 모든 함수를 수정해야 하지만, 연산을 추가하는 것은 해당 함수만 추가하면 되므로 간단하다.

패턴 매칭에 대해서 좀 더 알아 보기위해 산술식에 다른 연산(심볼에 대한 미분값)을 추가해 보자. 이 연산에서는 아래와 같은 룰을 취한다.

1. sum의 미분값은 각각의 미분값의 sum이다.

2. 미분값는 구하는 심볼명을 v에 대입했을 때, 어떤 변수의 미분값은 그 변수명이 v일 경우 1, 그외에는 0가 된다.

3. 상수의 미분값은 0

이러한 룰은 거의 1:1로 변환한 것처럼 Scala코드로 구현할 수 있다.

def derive(t: Tree, v: String): Tree = t match {
  case Sum(l, r) => Sum(derive(l, v), derive(r, v))
  case Var(n) if (v == n) => Const(1)
  case _ => Const(0)
}

이 함수에는 패턴 매칭에 대해서 2가지 컨셉이 사용되어 있다. 첫 번째로 case 표현에 가드가 들어 있다. if 키워드 이후의 판정식이 그것이다. 이 가드는 if의 판정식 결과가 true가 아니면 패턴 매칭 이후 처리를 진행하지 않는다. 즉 트리의 변수명과 심볼명이 동일할 때에만  1을 리턴한다.

두번 째로 와일드 카드가 사용되었다. _는 모든 경우를 의미한다.

아직 패턴 매칭의 모든 힘을 파악한 것은 아니지만, 이 문서에서는 여기까지만 다루는 것으로 하겠다.  이제 위의 두 함수를 어떻게 사용할 수 있는 지에 대한 예를 보겠다. 아래의 예는  (x + x) + (7 + y)라는 표현식에 여러가지 연산을 한다. 먼저 { x -> 5, x -> 7 }의 환경에서 값을 계산하고, x와 y에 대해서 미분값을 구해본다.

def main(args: Array[String]) {
  val exp: Tree = Sum(Sum(Var("x"),Var("x")), Sum(Const(7),Var("y")))
  val env: Environment = { case "x" => 5 case "y" => 7 }
  println("Expression: " + exp)
  println("Evaluation with x=5, y=7: " + eval(exp, env))
  println("Derivative relative to x:\n " + derive(exp, "x"))
  println("Derivative relative to y:\n" + derive(exp, "y"))
}

프로그램을 실행하면 아래와 같이 출력된다.
Expression: Sum(Sum(x),Var(y)),Sum(Const(7),Var(y)))
Evaluation with x=5, y=7: 24
Derivative relative to x:
  Sum(Sum(Const(1),Const(1)),Sum(Const(0),Const(0)))
Derivative relative to y:
  Sum(Sum(Const(0),Const(0)),Sum(Const(0),Const(1)))

출력 결과에서 보듯이 미분값의 결과는 유저가 알기 편하게 출력 전에 간략화 되어야 한다는 것을 알 수 있다. 이러한 함수는 패턴매칭을 사용해서 풀 수 있는 재미있는 문제이다. 이는 독자를 위해서 남겨 두겠다.

7. 형질(Traits)

Scala에서는 상위 클래스를 상속하는 방법 이외에 형질(Traits)를 통해서 코드를 가져오는 것이 가능하다. 상속과 달리 복수개의 형질을 임포트하는 것도 가능하다.

Java 프로그래머라면 코드를 가지고 있는 인터페이스라고 생각하면 감을 잡기 쉬울 것이다. Scala에서는 형질의 상속이라 함은 형질의 인터페이스를 구현하고 형질이 가지는 코드를 임포트 해오는 것을 의미한다.

형질의 유용성을 알아보기위해 순서를 가지는 객체로 예로 확인해 보자.  예를 들면 객체들을 정렬할 때 같이 객체의 크기를 비교할 수 있으면 편할 때가 종종 있다. Java에서는 Comparable인터페이스를 구현한 객체들이 크기를 비교할 수 있는 객체이다. Scala에서는 그것 보다 더 진보한 Scala판 Comparable을 형질로 만들어 보자. 이름하여 Ord이다.

객체를 비교할 때 보다 작은, 작거나 같은, 같은, 크거나 같은, 보다 큰와 같이 6가지의 술어가 있으며 편리하다. 이 6가지 중에 2가지만 있으면 나머지 4가지는 이 2가지로 표현할 수 있다. 즉 보다 작다(<)와 같은(=) 서술만 있으면 나머지들을 다 표현할 수 있다. 스칼라에서는 이러한 추론은 아래와 같이 직관적으로 형질을 정의할 수 있다.

trait Ord {
  def < (that: Any): Boolean
  def <= (that: Any): Boolean = (this < that) || (this == that)
  def > (that: Any): Boolean = !(this <= that)
  def >= (that: Any): Boolean = !(this < that)
}

위에서 보듯이 Java에서의 Comparable 인터페이스를 정의하면서 동시에 3개의 메소드를 정의하고 하나는 가상함수로 남겨두었다. Equal 비교와 NotEqual비교는 모든 객체에 기본으로 들어 있으므로 여기서 기술하지 않아도 된다. Any라는 타입은 모든 타입의 상위 타입이다. 하지만, Int나 Float와 같은 기본타입까지도 포함하기 때문에 Java의 Object타입보다 더 일반화(General)되어 있다.
어떤 클래스의 객체를 비교하는 기능을 구현하려면, [등호]와 [보다 작은]을 구현하기만 하고 위의 Ord와 묶으면 된다. 예를 들어서 그레고리언 카렌다의 날짜를 나타내는 Date라는 클래스를 정의해 보자. 날짜는 년, 월, 일이라는 정수로 구성되므로 아래와 같은 Date클래스로 부터 시작하자.

class Date(y: Int, m: Int, d: Int) extends Ord {
  def year = y
  def month = m
  def day = d

  override def toString(): String = year + "-" + month + "-" + day
}

클래스명과 매개변수의 뒤에 기술한 extends Ord라는 부분에 주목하자. 예상하는 대로 Date 클래스는 Ord 형질(trait)을 상속함을 표기한 것이다.

이제 Object로 부터 상속한 equals메소드를 정의하자.equals메소드는 년, 월, 일이 모두 같은지 비교한다. 기본의 quals 메소드는 Java에서와 같이 동일 객체인지를 비교하기 때문에 의미가 없다. equals는 아래와 같이 정의를 하게 될 것이다.

  override def equals(that: Any) : Boolean =
    that.isInstanceOf[Date] && {
      val o = that.asInstanceOf[Date]
      o.day == day && o.month == month && o.year == year
    }

이 메소드는 isInstance와 asInstanceOf라는 기정의 메소드를 사용한다. isInstanceOf는 Java의 instanceOf 연산자에 해당해서 매당 객체가 인수의 클래스의 인스턴스이면 true를 되돌려 준다. asInstanceOf는 Java의 형변환 연산자에 대응된다. 해당 객체가 인수로 주어진 클래스의 인스턴스이면 그 클래스로 형을 변환하고 아니라면 ClassCaseException을 발생시킨다.

마지막으로 정의할 매소드는 아래와 같이 [보다 작은]을 판단하는 메소드이다. 여기서는 error라는 기정의 메소드를 사용한다. error는 메세지를 포함한 예외를 throw시킨다.

  def <(that: Any): Boolean = {
    if (!that.isInstanceOf[Date])
      error("cannot compare " + that + " and a Date")
    var o = that.asInstanceOf[Date]
    (year < o.year) ||
    (year == o.year && (month < o.month ||
                (month == o.month && day < o.day)))
  }

이렇게 Date클래스를 완성했다. 이 클래스의 모든 인스턴스들은 날짜라고도 볼 수 있고 comparable로도 볼 수 있다. 게다가 6개의 모든 비교 메소드들을 제공한다. equals와  < 메소드는 직접 정의했고 나머지들은 Ord 형질에 정의되어 있다.

형질(trait)을 사용하면 편리한 상황은 더 많지만 이 문서는 여기까지만 설명하기로 하겠다.

8. 일반화(Genericity)

이 튜토리얼에서 마지막으로 소개할 Scala의 특징은 Genericity이다. Java 프로그래머라면 Java 1.5 이전까지 버젼에서 일반화의 부족으로 왔던 여러가지 문제점들에 대해서 잘 알고 있었을 것이다. 일반화란 코드가 실행할 대상의 타입을 정의할 수 있도록 함을 의미한다. 예를 들어서 링크드 리스트 라이브러리를 프로그래밍한다면 요소의 데이터형을 결정하는 데  문제점에 직면할 것이다. 왜냐하면 링크드 리스트가 사용되는 위치(context)에 따라서 어떤 데이터형(예를 들면 Int)인지 미리 정의를 할 수가 없기 때문이다.

Java 프로그래머들은 모든 객체들의 상위 클래스인 Object 사용에 의존하곤 한다. 하지만 이러한 방법은 이상적인 방법은 아니다. 왜냐하면 원시타입(integer, long, float 등)에서는 사용할 수 없으며  수 많은 타입 변환 연산자를 기술해야 함을 의미하기 때문이다. Scala에서는 이러한 문제를 해결하기 위해 Generic 클래스와 메소드를 정의해는 것이 가능하다. 가장 간단한 예인 컨테이너 클래스를 가지고 실험해 보자. 여기서 컨테이너란 불특정 객체를 가리키는 포인터 (비어 있는 것도 가능)를 내부에 저장한다.

class Reference[T] {
  private var contents: T = _
  def set(value: T) { contents = value }
  def get: T = contents
}

이 Reference 클래스는 T라는 타입을 지정할 수 있다. 이 클래스가 조작하는 객체의 타입을 인수로 지정할 수 있게 한 것이다. contents라는 변수와 set 메서드의 인수의 타입과 get 메서드의 리턴 타입도 T로 전달한 타입이 된다.

위의 예에서는 contents 자체에 대한 설명은 필요하지 않지만 초기치로 지정한 _는 공부해 볼만 하다. _는 기본값을 의미한다. _를 초기값으로 정의하면 변수가 수치인 경우에는 0, Boolean인 경우에는 false, Unit형인 경우에는 (), 객체인 경우에는 null이 된다.

이 클래스를 사용하려면 T 타입 인수에 사용할 타입을 지정해서 셀이 담고 있는 요소의 타입이 무엇인지 알려 주어야 한다. 예를 들어서 정수를 담고 있는 셀을 생성하려면 아래와 같이 기술한다.

object IntegerReference {
  def main(args: Array[String]) {
    val cell = new Reference[Int]
    cell.set(13)
    println("Reference contains the half of " + (cell.get * 2))
  }
}

위에서 보듯이 get 메서드가 리턴한 값을 정수처럼 사요하기 위해서 타입 변환 연산자를 정의할 필요가 없다. 또한 cell은 정수를 담는 다고 선언했기 때문에 정수 이외의 값은 담을 수 없다.

9. 결론

이 문서는 Scala 언어의 개요와 간단한 예를 보여 주었다. 좀 더 공부해 보고 싶은 독자라면 좀 더 진보된 예제를 훨씬 많이 보여주는  "예제를 통한 Scala"(Scala By Example) 문서나 필요에 따라서 "Scala 언어 규격"(Scala Language Specification) 문서를 참고하면 좋을 것이다.

2009/04
번역: 허련호(airless at funit.net)

Comments