IBM®
메인 컨텐츠로 가기
    Korea [국가변경]    이용약관
 
 
   
        제품    서비스 & 솔루션    고객지원 & 다운로드    회원 서비스    
메인 컨텐츠로 가기

한국 developerWorks  >  자바  >

바쁜 자바 프로그래머를 위한 스칼라 입문: 컬렉션 타입

스칼라에서 튜플, 배열, 리스트를 사용하기

developerWorks
문서 옵션

JavaScript가 필요한 문서 옵션은 디스플레이되지 않습니다.

샘플 코드

영어원문

영어원문


제안 및 의견
피드백

난이도 : 초급

Ted Neward, 사장, Neward & Associates

옮긴이: 김도형 dwkorea@kr.ibm.com

2008 년 9 월 09 일

객체는 스칼라에서 중요한 역할을 합니다. 하지만 튜플(tuple), 배열, 리스트(list) 같은 함수적 타입도 마찬가지입니다. 이 Ted Neward의 인기 연재에서는 함수 언어에서 일반적인 타입을 스칼라에서 어떻게 지원하는지를 살펴보는 것을 시작으로 앞으로 스칼라의 함수 언어적 측면을 살펴 보려고 합니다.

스칼라를 배우는 자바(Java™) 개발자에게 객체는 자연스럽고 쉬운 출발점이다. 이 연재를 진행하면서 스칼라에서 객체 지향 프로그래밍이 실제 자바 프로그래밍과 그렇게 다르지 않은 면을 소개했다. 또한 스칼라가 어떻게 전통적인 객체 지향 개념을 다시 짚어, 부족한 점을 찾아내고, 21세기에 맞도록 재창조했는지를 보였다. 하지만 항상 뭔가 중요한 것은 커튼 뒤에 숨어 나타나길 기다리고 있었는데, 바로 스칼라가 함수 언어(functional language)이기도 하다는 사실이다. (여기서 funtional은 다른 기능 상 문제 있는(dysfunction) 언어들에 대비해서 그렇다는 말이다. - 역주: 함수 언어의 functional이 "기능을 다하는"이라는 뜻이기도 하다는 점을 이용한 중의적 표현이다.)

이미 객체에 대해 다룰 게 없다는 점 때문이 아니더라도 스칼라의 함수적 특징은 살펴볼 만하다. 스칼라의 함수적 프로그래밍은 어떤 상황(멀티스레드 프로그래밍처럼 - 역주: concurrency)에서 프로그래밍을 아주 쉽게 해주는 내장 개념뿐 아니라 몇몇 새로운 설계 개념과 발상을 제시할 것이다.

독자들은 이번 달에 스칼라로 하는 함수 프로그래밍에 처음으로 본격 진출하게 될 것인데 대부분의 함수 언어에 공통적인 네 가지 타입인 리스트, 튜플, 세트, Option을 살펴볼 것이다. 또 스칼라의 배열을 배울 것인데 이것은 다른 함수 언어에도 매우 생소한 것이다.

C# 2.0 nullable 타입

다른 언어도 다양한 방법으로 이 "nullability" 문제를 해결하려고 노력해 왔다. C++의 경우 결국 null과 0이 서로 다른 값이라고 결정하기까지 가능한 오랫동안 실질적으로 그 문제를 무시해 왔다. 자바 언어는 여전히 nullability 문제를 완전히 해결하지 않고 있지만 자바 프로그래머가 문제를 해결할 수 있도록 자동 객체 변환(autoboxing)에 의존하고 있다. 자동 객체 변환은 기본 값을 해당하는 감싼 객체(wrapper object)로 자동 변환하는 것이며 이 감싼 객체조차 자바 1.1에서야 추가되었다. 어떤 패턴 애호가는 각 타입에 해당하는 "Null 객체"가 있어야 한다고 제안한다. 이 객체는 모든 메서드가 아무것도 하지 않도록 재정의된 해당 타입의 인스턴스(사실은 서브타입)인데 현실적으로 매우 많은 작업이 필요하다. C# 1.0이 나온 후 C# 설계자들은 nullability 문제에 대해 전혀 다른 방법으로 접근하기로 결정했다.

C# 2.0은 nullable 타입이라는 개념을 도입했다. 이는 어떤 값 타입(value type, 기본적으로는 기본 타입)이든 해당 타입을 제네릭/템플릿 클래스인 Nullable<T>에 감싸 null을 지원하도록 만들 수 있다는 것을 나타내기 위해 사실상 문법적 지원을 추가한 것이다. 여기서 Nullable<T>는 타입 선언 때 ? 수정자(modifier)를 사용해 밖으로 드러나지 않게 도입되었다. 따라서 int?는 때론 null이 될 수 있는 정수를 나타낸다.

이는 겉보기에 적절한 결정 같지만 곧 문제가 복잡해지기 시작했다. intint?는 값을 서로 대입할 수 있는(compatible) 타입이어야 하나? 그렇다면 어떤 경우 intint?로 되거나 int?int로 바뀔까? int?int를 더하면 어떻게 되지? 그 결과가 null이 될 수도 있을까? 등이다. 추후 타입 시스템에 있어서 몇몇 꼬임과 방향 전환 후에 nullable 타입은 2.0에 포함되었다. 이 측면은 C# 프로그래머에게 거의 완전히 무시되었다.

뒤돌아보면 Option[T]Int 사이의 구분을 명확히 하는 Option 타입을 사용하는 함수적 접근이 다른 방식보다 더 간략한 것 같다. 특히 nullable 타입에 관련된 직관적이지 않은 자동 타입 변환(promotion) 규칙과 비교하면 더욱 그렇다(함수 언어 측에서 이 개념에 대해 거의 20년간 고민해 오고 있었던 건 나쁠 것 없었던 셈이다). Option[T]에 익숙해지려면 조금 걸린다. 하지만 일반적으로 Option[T]가 더 명료한 코드를 작성할 수 있게 해 주고 기대에 잘 부합하는 것 같다.

각각의 타입은 코드를 쓰는 것에 대해 생각하는 새로운 방식을 제공한다. 전통적인 객체 지향 특징과 결합해 이 타입들은 놀랍도록 정확한 결과를 낸다.

Option 타입 사용하기

아무것도 아닌 게 실제 아무것도 아닌 게 아닐 때는? 바로 null과 대비되는 0이다.

우리 대부분이 특별히 잘 이해하고 있는 개념에 대해, 소프트웨어 상에서 "아무것도 아닌 것(nothing)"을 표현하는 것은 놀랍게도 어렵다. 예를 들어 C++ 공동체 내에서 NULL과 0에 대해 벌어진 그 모든 논쟁이나 SQL 공동체 내 NULL 열 값에 대한 논쟁을 보라. NULL이나 null은 프로그래머 대부분이 "아무것도 아닌 것"에 대해 생각하는 방식이다. 하지만 바로 이 점이 자바 프로그래밍에 있어서는 몇몇 특이한 문제를 일으킨다.

어떤 메인 메모리 혹은 디스크 기반 데이터베이스에서 특정 프로그래머의 봉급을 찾도록 설계된 간단한 작업을 생각해 보자. 즉, 해당 API는 호출하는 측에서 프로그래머 이름을 가리키는 String 객체를 넘겨 받아서 …을 돌려주는… 앗? 모델링 관점에서 해당 API는 해당 프로그래머가 연간 받는 봉급을 나타내는 Int 값을 돌려줘야 한다. 하지만 해당 프로그래머가 데이터베이스에 없을 때 뭘 돌려주느냐 하는 골치 아픈 문제가 남아 있다(아마 그런 프로그래머가 아예 고용된 적이 없을 수도 있고, 해고됐을 수도 있으며, 이름을 잘못 입력했을 수도 있을 것이다). 만약 돌려주는 값이 타입이 Int라면, 보통 데이터베이스에서 해당 사람을 못 찾았다는 것을 나타내는 "표식(flag)"인 null을 돌려줄 수는 없다. (이 경우 예외를 발생시켜야 한다고 생각할지도 모르겠다. 하지만 데이터베이스에 값이 없는 경우는 대부분의 경우 실제 예외적인 상황이라고 할 수 없다. 따라서 이 경우 예외를 일으키는 것은 부적절할 수 있다.)

자바 코드에서는 결국 호출하는 측에서 해당 메서드가 null을 돌려줄 수도 있다는 것을 알 수 있도록 java.lang.Integer를 돌려주도록 할 수밖에 없다. 물론 프로그래머가 이런 경우를 철저하게 문서로 만들고 그렇게 주의 깊게 준비한 문서를 다른 프로그래머가 잘 읽으리라고 기대할 수도 있다. 그렇다… 바로 프로그래머에게 할당한 불가능한 일정에 대한 우리의 반대 의견을 관리자가 꼼꼼히 듣고 경영진이나 고객에게 조심스럽게 전달하리라는 기대처럼 말이다.

스칼라는 이 난국에 대해 일반적인 함수적인 대안을 제공한다. 어떤 면에서 Option 타입이나 Option[T]는 말로 다 설명할 수 없다. Option 타입은 Some[T]None이라는 정확히 두 개의 서브클래스를 가진 제네릭 클래스다. 이는 언어 타입 시스템을 심각한 소용돌이 속으로 몰아 넣지 않고도 "아무것도 아닌 값(no value)"이 있을 수 있다는 가능성을 전달하는 데 도움을 준다. 사실 다음에 보일 것처럼, Option[T]를 사용하면 문제가 명료하게 해결된다.

Option[T]를 사용할 때 핵심은 Option[T]가 사실상 "아무것도 아닌" 값을 나타내기 위해 다른 값인 None을 사용하는, 엄격하게 타입이 적용된 크기 1의 컬렉션임을 이해하는 것이다. 따라서 데이터가 발견되지 않았다는 것을 나타내기 위해 메서드에서 null을 돌려주는 대신 Option[T]를 돌려주도록 선언한다. 여기서 T는 원래 돌려주려고 하던 값의 타입이다. 이제 데이터가 발견되지 않는 경우 그저 None을 돌려주면 된다. 다음을 보자.


Listing 1. 풋볼팀 예제
                
  @Test def simpleOptionTest =
  {
    val footballTeamsAFCEast =
      Map("New England" -> "Patriots",
          "New York" -> "Jets",
          "Buffalo" -> "Bills",
          "Miami" -> "Dolphins",
          "Los Angeles" -> null)
    
    assertEquals(footballTeamsAFCEast.get("Miami"), Some("Dolphins"))
    assertEquals(footballTeamsAFCEast.get("Miami").get(), "Dolphins")
    assertEquals(footballTeamsAFCEast.get("Los Angeles"), Some(null))
    assertEquals(footballTeamsAFCEast.get("Sacramento"), None)
  }

스칼라 Mapget 메서드가 돌려주는 값은 전달된 키에 해당하는 실제 값이 아니라는 점에 유의하자. Option[T]의 인스턴스로 값을 감싼 Some()이거나 키가 맵에 없는 경우를 명료하게 나타내는 None이다. 이는 Listing 1의 "Los Angeles" 키의 경우처럼 주어진 키가 맵에 있지만 값이 null인 경우에 특별히 유용하다.

Option[T]를 사용하는 대부분의 경우 프로그래머는 패턴 일치(pattern matching)를 사용할 것이다. 이는 아주 함수적인 개념으로 실질적으로 타입과 값 양 쪽 모두에 "switch"를 적용할 수 있게 해 준다. 그 밖에도 패턴을 정의할 때 일치하는 부분을 변수에 대응시키고, Some()이냐 None이냐에 따라 분기하며, 앞으로 사용하지 않도록 지정된(deprecated) 메서드인 get()을 호출할 필요 없이 Some에 값을 뽑아낼 때도 사용할 수 있다. Listing 2는 스칼라의 패턴 일치를 사용하는 예다.


Listing 2. 패턴 일치 예제
                
  @Test def optionWithPM =
  {
    val footballTeamsAFCEast =
      Map("New England" -> "Patriots",
          "New York" -> "Jets",
          "Buffalo" -> "Bills",
          "Miami" -> "Dolphins")
          
    def show(value : Option[String]) =
    {
      value match
      {
        case Some(x) => x
        case None => "No team found"
      }
    }
    
    assertEquals(show(footballTeamsAFCEast.get("Miami")), "Dolphins")
  }




위로


튜플과 집합

C++에서 이것을 struct라고 부른다. 자바 프로그래밍에서는 데이터 전달 객체 혹은 인자 객체라고 한다. 스칼라에서는 이것을 튜플(tuple)이라고 부른다. 실제 튜플은 몇몇 다른 데이터 타입을 캡슐화(encapsulation)나 추상화(abstraction)를 하려는 시도를 거의 혹은 전혀 하지 않고 하나의 인스턴스로 모으는 클래스일 뿐이다. 사실 종종 어떤 추상화도 하지 않는 편이 더 유용하기도 하다.

스칼라에서 튜플 타입을 생성하는 것은 이상할 정도로 쉬운데 바로 이점이 핵심이다. 내부 요소가 애당초 외부에 공개된다면 어떤 타입 내부 요소를 설명하는 이름을 붙이는 건 애당초 의미가 없다. Listing 3을 보자.


Listing 3. tuples.scala
                
// JUnit test suite
//
class TupleTest
{
  import org.junit._, Assert._
  import java.util.Date
 
  @Test def simpleTuples() =
  {
    val tedsStartingDateWithScala = Date.parse("3/7/2006")

    val tuple = ("Ted", "Scala", tedsStartingDateWithScala)
    
    assertEquals(tuple._1, "Ted")
    assertEquals(tuple._2, "Scala")
    assertEquals(tuple._3, tedsStartingDateWithScala)
  }
}

튜플을 생성하려면 값들을 메서드 호출할 때처럼 그저 한 쌍의 괄호 사이에 넣으면 된다. 값을 빼내려면 단지 "_n" 메서드를 호출하면 된다. 여기서 n은 추출해 낼 튜플 요소의 위치 인자다. 즉, _1은 처음 요소, _2는 두 번째와 같은 식이다. 자바의 java.util.Map은 사실상 두 요소로 이뤄진 튜플의 집합이다.

튜플을 이용하면 여러 개의 값을 간단하게 하나의 단위로 움직일 수 있다. 이는 튜플이 자바 프로그래밍에서라면 매우 무거웠을 뭔가를 제공한다는 뜻이다. 바로 다수 값을 돌려주는 경우다. 예를 들어 String 내 문자 수를 세어 가장 많이 나타난 문자를 돌려주는 메서드를 생각해 보자. 하지만 프로그래머가 가장 많이 들어 있는 문자와 함께 몇 번 나타났는지를 알고자 하면 설계가 까다로워진다. 문자와 빈도 수를 담은 클래스를 명시적으로 생성하거나 아니면, 객체 필드에 값을 넣어두고 요청하면 돌려줘야 할 것이다. 양 쪽 다 스칼라 경우보다 꽤 긴 코드가 필요하다. 단순히 문자와 빈도 수를 담은 튜플을 돌려줌으로써, 스칼라에서는 튜플 내 값들을 "_1", "-2", … 식으로 쉽게 접근할 수 있는 건 물론이고, 여러 개 값을 쉽게 돌려줄 수 있다.

튜플에 들어 있는 각 값에 쉽게 _1 접근을 할 수 있는 것은 말할 것도 없고 문자 및 문자와 연관된 빈도 수를 담은 튜플을 단순히 돌려줌으로써 스칼라는 여러 개의 반환 값을 쉽게 돌려줄 수 있다.

다음에서 보겠지만 스칼라 프로그래머는 종종 Option과 튜플을 (Array[T] 혹은 리스트 같은) 컬렉션에 저장한다. 이로써 이 상대적으로 간단한 구문이 가진 엄청난 유연성과 위력을 활용할 수 있다.




위로


배열 사용하기

먼저 친숙한 배열을 이제 Array[T]로 다시 새롭게 살펴보자. 자바 코드의 배열처럼 스칼라의 Array[T]는 순서가 있는 일련의 요소다. 각 요소는 배열 내 위치를 나타내는 숫자에 의해 지칭되는데 그 값은 배열의 총 크기를 넘지 말아야 한다. Listing 4를 보자.


Listing 4. array.scala
                
object ArrayExample1
{
  def main(args : Array[String]) : Unit =
  {
    for (i <- 0 to args.length-1)
    {
      System.out.println(args(i))
    }
  }
}

스칼라의 배열은 자바 배열과 같지만 (실제로도 자바 배열로 컴파일된다) 명확히 다르게 정의된다. 먼저 스칼라의 배열은 "내장(built-in)"된 타입도 아닌 사실상 제네릭 클래스다(최소 스칼라 라이브러리에 들어 있는 다른 클래스와 다를 바 없다). 예를 들어 스칼라에서 배열은 엄격하게, 배열의 길이를 돌려주는 "length" 등 몇몇 흥미로운 메서드를 가진 클래스인, Array[T]의 인스턴스로 정의된다. 따라서 Array를 0에서 args.length - 1 사이의 Int를 이용해 내부 요소를 훑거나 배열의 i 번째 요소를 얻어내는 등 전통적인 형태로 사용할 수도 있다(얻어낼 요소를 나타내기 위해 [] 대신 ()를 사용한다. 이는 사실 상 이상한 이름을 가진 메서드의 한 예다).

하지만 그것만으로는 흥미롭지 않다. 전혀 함수적이지 않다. (역주: 원문은 "that's no fun… and it's no fun(ctional)."이다. 원문에는 "미안하다. 약간의 프로그래밍 유머다. 좋다. 계속하자."라는 설명이 붙어 있다.)


완전한 Array[T] 타입 계층을 알려면 Scaladocs를 참고한다. 타입 계층은 실제 꽤 훌륭하며 많은 측면에서 java.util Collections 클래스들과 비슷하다.

배열 확장

Array는 놀랍게도 많은 부모 클래스에서 상속받은 메서드를 많이 가지고 있다. ArrayArray0를 확장하고, Array0ArrayLike[A]를, ArrayLike[A]Mutable[A]를, Mutable[A]RandomAccessSeq[A]를, RandomAccessSeq[A]Seq[A]를 확장하는 식이다. 물론 이 상속 관계는 Array가 유용한 연산을 많이 가지고 있고 자바 프로그래밍 때보다 스칼라에서 배열을 사용하기가 더 쉽다는 것을 의미한다.

예를 들어 Listing 4처럼 배열 내 요소를 순서대로 처리하는 경우 (이론의 여지는 있지만) 아주 간단히 할 수 있고, Iterable 특성에서 상속한 foreach 메서드를 사용해 (명백하게) 더욱 함수적인 방식으로 할 수 있다.


Listing 5. ArrayExample2
                
object 
{
  def main(args : Array[String]) : Unit =
  {
    args.foreach( (arg) => System.out.println(arg) )
  }
}

여기서 그다지 많은 노력을 절약한 것 같아 보이지 않을 수도 있지만, 배열 내 요소를 순차적으로 처리하면서 함수(무명이든 아니든)를 특정 의미 하에 실행할 수 있도록 다른 클래스에 넘기는 것은 함수 프로그래밍에 있어 일반적인 주제다. 그리고 고차 함수를 이런 방식으로 사용하는 것은 이런 반복(iteration)하는 경우에만 쓸 수 있는 것이 아니다. 사실 배열에서 불필요한 요소를 걸러내고 그 결과를 처리하는 일종의 여과(filtration) 과정을 거치는 것은 드문 일이 아니다. 예를 들어 스칼라에서는 filter 메서드를 사용해 쉽게 여과한 후에, 결과로 나온 리스트를 받아, map 메서드에 다른 함수(이 경우는 (T) => U 타입. 여기서 T와 U는 둘 다 제네릭 타입이다)를 넘기거나 foreach로 다시 할 수 있다. Listing 6에서는 후자의 경우를 시도해 봤다. (filter(T) : Boolean 메서드를 인자로 받는다는 점에 유의한다. 이 메서드는 배열이 가지고 있는 타입의 값 하나를 인자로 받아 Boolean을 돌려준다.)


Listing 6. 스칼라 프로그래머를 찾는 예제
                
class ArrayTest
{
  import org.junit._, Assert._
  
  @Test def testFilter =
  {
    val programmers = Array(
        new Person("Ted", "Neward", 37, 50000,
          Array("C++", "Java", "Scala", "Groovy", "C#", "F#", "Ruby")),
        new Person("Amanda", "Laucher", 27, 45000,
          Array("C#", "F#", "Java", "Scala")),
        new Person("Luke", "Hoban", 32, 45000,
          Array("C#", "Visual Basic", "F#")),
		new Person("Scott", "Davis", 40, 50000,
		  Array("Java", "Groovy"))
      )

    // 모든 스칼라 프로그래머를 찾는다.
    val scalaProgs =
      programmers.filter((p) => p.skills.contains("Scala") )
    
    // 두 명밖에 없어야 한다.
    assertEquals(2, scalaProgs.length)
    
    // ... 이제 얻어낸 스칼라 프로그래머 배열 내 각각에 대해 작업을 한다.
    // (물론 봉급을 올려준다!)
    //
    scalaProgs.foreach((p) => p.salary += 5000)
    
    // 각각은 5000씩 봉급이 올랐어야 한다.
    assertEquals(programmers(0).salary, 50000 + 5000)
    assertEquals(programmers(1).salary, 45000 + 5000)
    
    // ... 스칼라를 모르는 프로그래머를 제외하고 말이다.
    assertEquals(programmers(2).salary, 45000)
	assertEquals(programmers(3).salary, 50000)
  }
}

map 함수는 새 Array가 만들어지는 데 쓰인다. 이 때 원래 배열은 건드리지 않는데 실제로 함수 프로그래머 대부분이 좋아하는 방식이다.


Listing 7. filter와 map
                
  @Test def testFilterAndMap =
  {
    val programmers = Array(
        new Person("Ted", "Neward", 37, 50000,
          Array("C++", "Java", "Scala", "C#", "F#", "Ruby")),
        new Person("Amanda", "Laucher", 27, 45000,
          Array("C#", "F#", "Java", "Scala")),
        new Person("Luke", "Hoban", 32, 45000,
          Array("C#", "Visual Basic", "F#"))
		new Person("Scott", "Davis", 40, 50000,
		  Array("Java", "Groovy"))
      )

    // Find all the Scala programmers ...
    val scalaProgs =
      programmers.filter((p) => p.skills.contains("Scala") )
    
    // Should only be 2
    assertEquals(2, scalaProgs.length)
    
    // ... now perform an operation on each programmer in the resulting
    // array of Scala programmers (give them a raise, of course!)
    //
    def raiseTheScalaProgrammer(p : Person) =
    {
      new Person(p.firstName, p.lastName, p.age,
        p.salary + 5000, p.skills)
    }
    val raisedScalaProgs = 
      scalaProgs.map(raiseTheScalaProgrammer)
    
    assertEquals(2, raisedScalaProgs.length)
    assertEquals(50000 + 5000, raisedScalaProgs(0).salary)
    assertEquals(45000 + 5000, raisedScalaProgs(1).salary)
  }

Listing 7의 Person의 salary 멤버가 다양한 프로그래머의 봉급을 수정하기 위해 앞서 내가 사용해야만 했던 var 대신 val로 표시되어 변경되지 않는 것에 주의하라.

스칼라의 Array는 내가 여기에서 나열했거나 시연한 것 이상의 메서드를 담는다. 배열을 다루는 것에 대해 일반적인 충고를 하자면 배열을 따라 필요한 것을 찾거나 행하는 전통적인 for ... 패턴을 쓰기보다는 Array에서 제공하는 메서드의 장점을 활용하는 방법을 적극적으로 찾으라. 이렇게 하는 가장 쉬운 방법은 (내포된) 함수(필요하다면 Listing 7의 testFilterAndMap 예처럼)를 하나 쓰는 것이다. 이 함수는 바라는 동작을 수행하고 나서 그것을 바라는 결과게 따라 map, filter, foreach 중 하나 또는 Array의 다른 메서드로 전달한다.




위로


리스트 사용하기

수년 간 함수 프로그래밍의 핵심 특성이었던 리스트는 객체 지향 언어에서 배열이 수년간 누려왔던 것과 같은 "내장" 기능으로서의 지위를 누려왔다. 리스트는 함수적인 소프트웨어를 만드는 데 있어 근본이고, 따라서 (초보 스칼라 프로그래머로서) 독자들은 리스트와 그 동작 원리를 반드시 이해해야 한다. 새로운 설계에 리스트를 사용하지 않더라도, 리스트는 스칼라 라이브러리 전반에서 널리 사용된다. 따라서 리스트를 익히는 것은… 음… 부득이하다(imperative).

(미안하다. 또 다른 함수 프로그래밍 유머이다. 이게 마지막이다. - 역주: 앞선 유머와 비슷하게 함수 언어가 아닌 imperative 언어에 빗대 imperative라는 단어를 사용했다.)

스칼라에서 리스트는 그 핵심 정의가 스칼라 라이브러리의 표준 클래스인 List[T]라는 점에서 배열과 비슷하다. 또 Array[T]처럼 List[T]는 바로 직속 베이스 클래스인 Seq[T]에서 시작해 몇몇 베이스 클래스와 특성에서 상속 받는다.

기본적으로 리스트는 요소의 집합으로 리스트의 선두(head) 요소나 마지막(tail)에서만 요소를 얻어낼 수 있다. 리스트는 Lisp에서 나온 것으로 알려져 있다. 잡학 박사라면 Lisp가 주로 "LISt Processing(리스트 처리)"를 위해 설계된 언어의 이름임을 알 것이다. 여기서 리스트 선두에서 요소를 뽑아내는 것은 car 연산이고, 선두를 제외한 나머지를 뽑아내는 연산은 cdr이었다(car/cdr라는 이름에는 역사적인 이유가 있다. 그 이유를 가장 먼저 보내는 사람은 보너스 점수를 주겠다).

리스트를 사용하는 것은 배열을 사용하는 것보다 사실 더 쉽다. 이는 역사적으로 함수 언어가 리스트 사용을 위한 지원이 잘 되어 있었던 때문이기도 하고(스칼라는 이런 지원을 이어 받았다), 리스트가 세련되게 조합되고 분해될 수 있기 때문이기도 하다. 예를 들어 종종 함수에서는 리스트의 내용을 추출해 낼 필요가 있다. 이를 위해 함수는 우선 리스트의 선두에서 첫 번째 요소를 뽑아내 처리한다. 그리고 남은 리스트를 재귀적으로 자신에게 다시 넘긴다. 이렇게 하면 처리 코드 내 일종의 공유하는 상태가 생길 가능성이 줄고, 각 단계가 단지 하나의 요소만 처리할 필요가 있다는 점을 감안하면, (해당 처리가 간단하지 않다는 가정 하에) 코드를 여러 스레드에서 동시에 실행하기도 쉬워진다.

리스트를 모으고 분리하는 것은 Listing 8에서 보는 것처럼 아주 단순하다.


Listing 8. 리스트 사용 예
                
class ListTest
{
  import org.junit._, Assert._
  
  @Test def simpleList =
  {
    val myFirstList = List("Ted", "Amanda", "Luke")
    
    assertEquals(myFirstList.isEmpty, false)
    assertEquals(myFirstList.head, "Ted")
    assertEquals(myFirstList.tail, List("Amanda", "Luke")
    assertEquals(myFirstList.last, "Luke")
  }
}

여기서 리스트를 만드는 것은 배열과 거의 비슷하다는 점에 유의하자. "new"가 필요 없다는 점만 제외하면 둘 다 일반적인 객체를 생성할 때와 비슷하다(이는 "케이스 클래스(case class)"의 기능이다. 여기에 대해서는 이 연재를 진행하며 추후 다루겠다). tail 메서드 호출 결과를 더욱 유심히 살펴보자. 리스트의 마지막 요소가 아니라(마지막 요소는 last로 얻는다), 첫 요소를 제외한 나머지 리스트다.

물론, 리스트의 위력은 일부 리스트가 빌 때까지 단순히 리스트의 선두를 꺼내 처리하고 결과를 축적하는 리스트 내 요소의 재귀적 처리에 있다.


Listing 9. 재귀적 처리
                
  @Test def recurseList =
  {
    val myVIPList = List("Ted", "Amanda", "Luke", "Don", "Martin")
    
    def count(VIPs : List[String]) : Int =
    {
      if (VIPs.isEmpty)
        0
      else
        count(VIPs.tail) + 1
    }
    
    assertEquals(count(myVIPList), myVIPList.length)
  }

여기서 count 메서드의 리턴 값을 생략하면 스칼라 컴파일러나 해석기(interpreter)가 투덜댈 것이다. 이 호출이 많은 재귀 연산을 행하는 경우 스택 프레임(stack frame) 수를 줄이기 위한 최적화인 말단 재귀 호출(tail-recursive call)이기 때문에 리턴 타입을 명시해야 한다(역주: Unit을 명시할 수는 있다. 그저 아무것도 적어주지 않으면 재귀 호출 때문에 리턴 타입을 유추할 수 없을 뿐이다). 당연하지만 리스트 내 요소의 수를 얻기 위해 List의 "length" 멤버를 이용하는 편이 분명히 더 쉽다. 하지만 여기서는 핵심은 "일반적으로" 리스트 처리가 얼마나 유용한가를 보이는 것이다. Listing 9는 전체 메서드는 완전하게 멀티스레드에 안전하다(thread-safe). 처리에 필요한 중간 상태가 모두 스택 상의 인자에 저장되어 있고, 스택의 정의대로 여러 스레드에서 접근할 수 없기 때문이다. 함수적 접근법이 아름다운 점은 함수적으로 프로그래밍하면서 공유 상태를 만들어 내기가 실제 꽤 어렵다는 것이다.

리스트 API

리스트는 :: 메서드를 사용해 리스트를 생성하는 등 다른 흥미로운 특성을 가지고 있다. (그렇다. 그저 이상한 이름을 가진 또 하나의 메서드다. 더 설명할 것이 없으니 다음으로 넘어가자.) 따라서, List 생성자 문법을 이용해서 리스트를 생성하는 대신, 리스트들을 다음 같이 "cons"(더블 콜론 메서드를 그렇게 부른다)할 수 있다.


Listing 10. :: 메서드 사용
                
  @Test def recurseConsedList =
  {
    val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
    
    def count(VIPs : List[String]) : Int =
    {
      if (VIPs.isEmpty)
        0
      else
        count(VIPs.tail) + 1
    }
    
    assertEquals(count(myVIPList), myVIPList.length)
  }

:: 메서드를 사용할 때는 몇몇 이상한 규칙이 적용되기 때문에 주의가 필요하다. 이 문법은 함수 언어에서는 매우 일반적이라서 스칼라 설계자들은 이 문법을 지원하기로 결정했다. 하지만 이 문법이 제대로 일반적으로 동작하기 위해서는 한 가지 엉뚱한 규칙이 필요하다. 즉, 콜론으로 끝나는 이상한 이름을 가진 메서드는 모두 우측 결합(right-associative) 규칙을 따라야 한다. 이는 전체 수식이 수식의 가장 오른쪽부터 Nil로 시작해야 한다는 것이고, Nil은 편리하게도 List다. 따라서 이는 ::가 (예제의 경우) String의 멤버 메서드가 아니라 광역(global) :: 메서드로 해석될 수 있다는 것이다. 또한 이는 모든 것에서 리스트를 생성할 수 있다는 것을 의미한다. ::를 사용할 때는 가장 오른쪽의 요소는 반드시 리스트여야 한다. 아니면 오류 메시지를 보게 될 것이다.

우측 결합 규칙이란?

::에 대해 더 잘 이해하려면 "cons" 같은 연산자들이 그저 이상한 이름을 가진 메서드일 뿐이라는 것을 기억해야 한다. 보통의 왼쪽 결합 문법(left-associative syntax)에서는 왼쪽에 있는 토큰이 해당 메서드(오른쪽 토큰)를 호출할 객체다. 따라서 보통 1 + 2라는 식은 컴파일러가 봤을 때는 1.+(2)나 마찬가지다.

하지만 리스트의 경우는 상황이 다르다. 시스템 내 모든 클래스는 시스템 내 모든 타입에 대한 :: 메서드를 가져야 한다. 이는 관심의 분리(separation of concerns) 원칙에 대한 매우 심각한 위배다.

스칼라는 (:::::나 심지어 foo:처럼 임의로 만든 메서드처럼) 콜론으로 끝나는 이상한 이름을 가진 메서드는 우측 결합 법칙을 따라야 한다는 규칙으로 이 문제를 해결했다. 따라서 예를 들어 a :: b :: c :: Nil은 정확히 우리가 원하는 대로 Nil.::(c).::(b).::(a)로 변환된다. 즉, 모든 걸 시작하기 위한 List 객체 하나로 각 :: 호출은 객체 인자를 받고 List를 돌려주는 식으로 계속해서 연결될 수 있다.

다른 이름에 대해서도 우측 결합 특성을 지정할 수 있으면 좋겠지만, 이 글을 쓰는 시점에서 스칼라는 이 규칙을 언어 상에서 직접 지정하고 있다. 당분간 우측 결합을 지정하는 유일한 문자는 콜론이다.

스칼라에서 리스트를 사용하는 가장 유용한 방법 중 하나는 패턴 일치와 함께 사용하는 것이다. 리스트는 값과 타입에 일치할 수 있는 것뿐만 아니라 동시에 변수 정의(variable binding)를 할 수도 있기 때문이다. 예를 들어 Listing 10의 리스트 코드는 패턴 일치로 최소 한 개의 요소를 가진 리스트와 빈 리스트를 구분하면 더 간단하게 만들 수 있다.


Listing 11. 리스트
                
  @Test def recurseWithPM =
  {
    val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
    
    def count(VIPs : List[String]) : Int =
    {
      VIPs match
      {
        case h :: t => count(t) + 1
        case Nil => 0
      }
    }
    
    assertEquals(count(myVIPList), myVIPList.length)
  }

case 식에서 리스트의 선두가 추출되어 변수 h로 정의될 것이고, 나머지(tail)은 t로 정의될 것이다. 이 경우 h는 사용하지 않는다. (사실 선두 값은 사용되지 않을 것이라는 것을 나타내기 위해 h를 와일드카드인 _로 바꿔서 그 위치는 사용되지 않을 변수를 위한 곳이라는 것을 나타내는 편이 나을 수도 있다.) 하지만 t는 앞선 예제처럼 재귀적으로 count에 전달된다. 여기서 스칼라의 모든 수식은 암묵적으로 값을 돌려준다는 점도 상기하자. 이 경우 패턴 일치식의 결과는 count + 1에 대한 재귀 호출이거나 리스트의 마지막에 도달했을 때는 0이다.

양쪽 예제 모두 라인 수에서는 별 차이가 없다. 그렇다면 왜 패턴 일치를 사용할까? 솔직히 이런 간단한 경우에는 차별점을 찾기 어렵다. 하지만 심지어 위 예제를 특정한 값을 찾아내도록 조금 확장하는 정도로만 더 복잡해져도 생각이 달라질 것이다.


리스트 12. 패턴 일치로 "Amanda" 찾기
                
  @Test def recurseWithPMAndSayHi =
  {
    val myVIPList = "Ted" :: "Amanda" :: "Luke" :: "Don" :: "Martin" :: Nil
    
    var foundAmanda = false
    def count(VIPs : List[String]) : Int =
    {
      VIPs match
      {
        case "Amanda" :: t =>
          System.out.println("Hey, Amanda!"); foundAmanda = true; count(t) + 1
        case h :: t =>
          count(t) + 1
        case Nil =>
          0
      }
    }
    
    assertEquals(count(myVIPList), myVIPList.length)
    assertTrue(foundAmanda)
  }

특히 정규식(regular expression)이나 XML 노드(node)처럼 정말 복잡한 예제에는 패턴 일치 방식이 훨씬 유리함을 쉽게 알 수 있을 것이다. 또한 패턴 일치는 리스트에만 쓸 수 있는 게 아니다. 이전 배열 예제에도 패턴 일치를 적용할 수 있다. 다음은 앞선 recurseWithPMAndSayHi 테스트를 배열로 구현한 것이다.


Listing 13. 배열과 패턴 일치를 이용한 예제
                
  @Test def recurseWithPMAndSayHi =
  {
    val myVIPList = Array("Ted", "Amanda", "Luke", "Don", "Martin")

    var foundAmanda = false
    
    myVIPList.foreach((s) =>
      s match
      {
        case "Amanda" =>
          System.out.println("Hey, Amanda!")
          foundAmanda = true
        case _ =>
          ; // Do nothing
      }
    )

    assertTrue(foundAmanda)
  }

연습으로 Listing 13을 재귀 호출을 이용해서 작성해 보자. 또한 recurseWithPMAndSayHi 내에 정의된 변경할 수 있는 var도 사용하지 말아 보자. 힌트: 패턴 일치 블록이 여럿 필요할 지도 모른다. (이 글의 코드 다운로드에 한 가지 해결책이 들어 있다. 하지만 그 해결책을 보기 전에 직접 해 볼 것을 권한다.)




위로


결론

이 연재에 대해

저자인 Ted Neward는 이 연재를 통해 독자들에게 스칼라 프로그래밍 언어를 심층적으로 소개한다. 이 새 developerWorks 연재를 통해 스칼라에 대한 최근 스칼라를 둘러싼 떠들썩한 호평의 실체를 살펴보고, 스칼라의 언어적 특성의 일부가 실제 어떻게 사용되는지도 배우게 될 것이다. 비교가 필요할 때는 항상 스칼라 코드와 자바 코드를 나란히 보일 예정이다. 하지만 곧 알게 되겠지만 스칼라에 있는 많은 요소는 자바 언어와 직접적인 관계가 없는 것들이고, 이런 부분이 스칼라를 매력적으로 만든다. 그렇다면 자바 코드로도 할 수 있다면 왜 힘들여 스칼라를 배울까?

스칼라의 풍부한 컬렉션은 스칼라의 함수적 이력과 특성의 직접적인 결과다. 튜플은 느슨하게 연관된 값들을 모으는 쉬운 방법을 제공하며, Option[T]는 "어떤(some)" 값을 "아무것도 아닌(no)" 값과 구분하는 쉬운 방법을 제공한다. 배열은 기존 자바 방식 배열 기능을 사용할 수 있게 하되 몇몇 부분에서 개선된 기능을 제공하며, 리스트는 함수 언어의 주된 컬렉션이다.

하지만 이 특성을 사용할 때는 조심해야 한다. 특히 튜플의 경우가 그렇다. 즉, 튜플만 너무 사용하게 될 수 있고 그러다 보면 기존의 기본 객체 모델링을 잊을 수도 있다. 예를 들어 만약 이름, 나이, 봉급과 아는 프로그래밍 언어 리스트를 담은 특정 튜플이 코드에 반복해 나타난다면 클래스 타입과 객체로 모델링해야 한다.

스칼라의 좋은 점은 함수적인 것 동시에 객체 지향적이라는 점이다. 따라서 스칼라의 함수적인 타입에 대해 배우는 것을 즐기는 동시에 클래스 설게에 대해서는 전통적인 관점을 유지할 수 있다.





위로


다운로드 하십시오

설명이름크기다운로드 방식
이 글의 예제 스칼라 코드j-scala06278.zip200KBHTTP
다운로드 방식에 대한 정보


참고자료

교육
  • 바쁜 자바 프로그래머를 위한 스칼라 입문: 객체 지향론자를 위한 함수 프로그래밍"(Ted Neward, 한국 developerWorks, 2008년 4월): 이 연재의 첫 편으로 스칼라 전반에 대해 소개하고 여러 요소 중 스칼라의 동시성(concurrency)에 대한 함수적인 접근에 대해 설명한다.

  • "자바를 이용한 함수 프로그래밍"(Abhijit Belapurkar, 한국 developerWorks, 2007년 4월): 자바 개발자 관점에서 함수 프로그래밍의 이점과 용례에 대해 배운다.

  • "Dead like COBOL"(Ted Neward, developerWorks, 2008년 5월): 자바 플랫폼을 떠나 더 푸른 초장을 향해 떠날 시간인가? 이 글에서는 자바의 종말에 동의하거나 동의하지 않는 사람들의 주장에 대해 생각해 본다.

  • "Ted Neward on why Java developers need Scala"(JavaWorld, 2008년 6월): Andrew Glover와의 토론을 담은 이 포드캐스트에서 함수 프로그래밍과 자바 생태계에 있어 스칼라의 위치에 대한 Ted의 더 많은 생각을 들어보자.

  • "Scala by Example"(Martin Odersky, 2007년 12월): 짧은 코드 중심의 스칼라 입문서(PDF 형식)

  • Programming in Scala(Martin Odersky, Lex Spoon, Bill Venners, Artima, 2007년 12월): 최초의 책 한 권 분량의 스칼라 입문서

  • Scaladocs: 스칼라 라이브러리 내 API 명세

  • developerWorks 자바 기술 존: 자바 프로그래밍의 모든 측면에 대한 수백 개의 기사가 제공된다.


제품 및 기술 얻기

토론


필자소개

Ted Neward photo

Ted Neward는 Neward & Associates의 사장으로 자바, .NET, XML 서비스와 다른 플랫폼에 대한 컨설팅, 조언, 교육, 강연을 한다. 워싱턴 주 시애틀 근처에 산다.




기사에 대한 평가


보다 나은 서비스를 제공하기 위함이오니 잠시 짬을 내어 이 양식을 제출하여 주십시오.



 


 


 


이 문서 북마킹 하기

mar.gar.in mar.gar.in naver naver eolin eolin del.icio.us del.icio.us





위로


Java and all Java-based trademarks are trademarks of Sun Microsystems, Inc. in the United States, other countries, or both. 기타 회사, 제품, 및 서비스명은 다른 상표나 서비스 마크일 수 있습니다.

developerWorks 콘텐트를 다른 사이트에 전재하기:
developerWorks 콘텐트에 대한 저작권은 IBM에 있습니다. IBM의 서면 허가나 원본 저자의 허락이 없이는 전재를 금합니다. 저희 콘텐트를 전재하시려면 IBM developerWorks 담당자 에게 문의하십시오.
    IBM 소개 개인정보 보호정책 문의