패턴 매칭

패턴 매칭 (Pattern Matching)

패턴 매칭은 값을 패턴과 대조해 보는 메커니즘이에요. 매칭이 성공하면 값을 구성 요소로 분해(deconstruct)할 수도 있죠. Java의 switch 문보다 훨씬 강력하고, 일련의 if/else를 대신하는 데도 그대로 쓸 수 있어요. 여기서는 패턴 매칭의 문법부터 케이스 클래스·문자열·타입 매칭까지, 그리고 sealed 타입의 안전장치를 함께 살펴볼게요.

출처: 공식 문서 — Pattern Matching

문법 (Syntax)

매치 표현식은 값, match 키워드, 그리고 하나 이상의 case 절로 이루어져요.

// Scala 2
import scala.util.Random

val x: Int = Random.nextInt(10)

x match {
  case 0 => "zero"
  case 1 => "one"
  case 2 => "two"
  case _ => "other"
}
// Scala 3
import scala.util.Random

val x: Int = Random.nextInt(10)

x match
  case 0 => "zero"
  case 1 => "one"
  case 2 => "two"
  case _ => "other"

위의 val x는 0과 9 사이의 무작위 정수예요. xmatch 연산자의 왼쪽 피연산자가 되고, 오른쪽에는 네 개의 case가 있는 표현식이 와요. 마지막 case _는 그 밖의 다른 모든 Int 값을 잡아내는 "catch all" case죠. 이 case들을 _대안(alternatives)_이라고도 불러요.

매치 표현식은 값을 가져요:

// Scala 2
def matchTest(x: Int): String = x match {
  case 1 => "one"
  case 2 => "two"
  case _ => "other"
}
matchTest(3)  // returns other
matchTest(1)  // returns one
// Scala 3
def matchTest(x: Int): String = x match
  case 1 => "one"
  case 2 => "two"
  case _ => "other"

matchTest(3)  // returns other
matchTest(1)  // returns one

이 매치 표현식은 모든 case가 String을 반환하므로 타입이 String이에요. 그래서 함수 matchTest는 String을 반환해요.

케이스 클래스에 매칭하기

케이스 클래스는 패턴 매칭에서 특히 유용해요:

sealed trait Notification

case class Email(sender: String, title: String, body: String) extends Notification

case class SMS(caller: String, message: String) extends Notification

case class VoiceRecording(contactName: String, link: String) extends Notification

Notification은 sealed 트레이트이고, Email·SMS·VoiceRecording이라는 세 개의 구체적인 Notification 타입을 케이스 클래스로 구현했어요. (sealed 트레이트는 선언과 같은 파일에서만 확장할 수 있어요.) 이제 이 케이스 클래스들에 대해 패턴 매칭을 할 수 있어요:

// Scala 2
def showNotification(notification: Notification): String = {
  notification match {
    case Email(sender, title, _) =>
      s"You got an email from $sender with title: $title"
    case SMS(number, message) =>
      s"You got an SMS from $number! Message: $message"
    case VoiceRecording(name, link) =>
      s"You received a Voice Recording from $name! Click the link to hear it: $link"
  }
}
val someSms = SMS("12345", "Are you there?")
val someVoiceRecording = VoiceRecording("Tom", "voicerecording.org/id/123")

println(showNotification(someSms))  // prints You got an SMS from 12345! Message: Are you there?

println(showNotification(someVoiceRecording))  // prints You received a Voice Recording from Tom! Click the link to hear it: voicerecording.org/id/123
// Scala 3
def showNotification(notification: Notification): String =
  notification match
    case Email(sender, title, _) =>
      s"You got an email from $sender with title: $title"
    case SMS(number, message) =>
      s"You got an SMS from $number! Message: $message"
    case VoiceRecording(name, link) =>
      s"You received a Voice Recording from $name! Click the link to hear it: $link"

val someSms = SMS("12345", "Are you there?")
val someVoiceRecording = VoiceRecording("Tom", "voicerecording.org/id/123")

println(showNotification(someSms))  // prints You got an SMS from 12345! Message: Are you there?

println(showNotification(someVoiceRecording))  // prints You received a Voice Recording from Tom! Click the link to hear it: voicerecording.org/id/123

함수 showNotification은 추상 타입 Notification을 파라미터로 받아서 그 타입에 대해 매칭해요. 즉 전달된 값이 Email인지 SMS인지 VoiceRecording인지 알아내는 거죠. case Email(sender, title, _)에서는 sendertitle 필드가 반환값에 쓰이고, body 필드는 _로 무시돼요.

문자열에 매칭하기

s-인터폴레이터는 문자열에 변수를 넣어주기도 하고, 패턴 매칭에도 유용해요:

// Scala 2
val input: String = "Alice is 25 years old"

input match {
  case s"$name is $age years old" => s"$name's age is $age"
  case _ => "No match"
}
// Result: "Alice's age is 25"
// Scala 3
val input: String = "Alice is 25 years old"

input match
  case s"$name is $age years old" => s"$name's age is $age"
  case _ => "No match"
// Result: "Alice's age is 25"

이 예시에서 name과 age는 패턴을 바탕으로 문자열의 일부를 추출해요. 구조화된 텍스트를 파싱할 때 유용하죠.

문자열 패턴 매칭에는 추출기 객체(extractor object)를 쓸 수도 있어요:

// Scala 2
object Age {
  def unapply(s: String): Option[Int] = s.toIntOption
}

val input: String = "Alice is 25 years old"

val (name, age) = input match {
  case s"$name is ${Age(age)} years old" => (name, age)
}
// name: String = Alice
// age: Int = 25
// Scala 3
object Age:
  def unapply(s: String): Option[Int] = s.toIntOption

val input: String = "Alice is 25 years old"

val (name, age) = input match
  case s"$name is ${Age(age)} years old" => (name, age)
// name: String = Alice
// age: Int = 25

패턴 가드 (Pattern guards)

패턴 가드는 case를 더 구체적으로 만들기 위해 쓰는 불리언 표현식이에요. 패턴 뒤에 if <boolean expression>을 붙이면 돼요:

// Scala 2
def showImportantNotification(notification: Notification, importantPeopleInfo: Seq[String]): String = {
  notification match {
    case Email(sender, _, _) if importantPeopleInfo.contains(sender) =>
      "You got an email from special someone!"
    case SMS(number, _) if importantPeopleInfo.contains(number) =>
      "You got an SMS from special someone!"
    case other =>
      showNotification(other) // nothing special, delegate to our original showNotification function
  }
}

val importantPeopleInfo = Seq("867-5309", "[email protected]")

val someSms = SMS("123-4567", "Are you there?")
val someVoiceRecording = VoiceRecording("Tom", "voicerecording.org/id/123")
val importantEmail = Email("[email protected]", "Drinks tonight?", "I'm free after 5!")
val importantSms = SMS("867-5309", "I'm here! Where are you?")

println(showImportantNotification(someSms, importantPeopleInfo)) // prints You got an SMS from 123-4567! Message: Are you there?
println(showImportantNotification(someVoiceRecording, importantPeopleInfo)) // prints You received a Voice Recording from Tom! Click the link to hear it: voicerecording.org/id/123
println(showImportantNotification(importantEmail, importantPeopleInfo)) // prints You got an email from special someone!

println(showImportantNotification(importantSms, importantPeopleInfo)) // prints You got an SMS from special someone!
// Scala 3
def showImportantNotification(notification: Notification, importantPeopleInfo: Seq[String]): String =
  notification match
    case Email(sender, _, _) if importantPeopleInfo.contains(sender) =>
      "You got an email from special someone!"
    case SMS(number, _) if importantPeopleInfo.contains(number) =>
      "You got an SMS from special someone!"
    case other =>
      showNotification(other) // nothing special, delegate to our original showNotification function

val importantPeopleInfo = Seq("867-5309", "[email protected]")

val someSms = SMS("123-4567", "Are you there?")
val someVoiceRecording = VoiceRecording("Tom", "voicerecording.org/id/123")
val importantEmail = Email("[email protected]", "Drinks tonight?", "I'm free after 5!")
val importantSms = SMS("867-5309", "I'm here! Where are you?")

println(showImportantNotification(someSms, importantPeopleInfo)) // prints You got an SMS from 123-4567! Message: Are you there?
println(showImportantNotification(someVoiceRecording, importantPeopleInfo)) // prints You received a Voice Recording from Tom! Click the link to hear it: voicerecording.org/id/123
println(showImportantNotification(importantEmail, importantPeopleInfo)) // prints You got an email from special someone!

println(showImportantNotification(importantSms, importantPeopleInfo)) // prints You got an SMS from special someone!

case Email(sender, _, _) if importantPeopleInfo.contains(sender)에서 패턴은 sender가 중요한 사람 목록에 있을 때만 매칭돼요.

타입만으로 매칭하기

타입을 기준으로도 매칭할 수 있어요:

// Scala 2
sealed trait Device
case class Phone(model: String) extends Device {
  def screenOff = "Turning screen off"
}
case class Computer(model: String) extends Device {
  def screenSaverOn = "Turning screen saver on..."
}

def goIdle(device: Device): String = device match {
  case p: Phone => p.screenOff
  case c: Computer => c.screenSaverOn
}
// Scala 3
sealed trait Device
case class Phone(model: String) extends Device:
  def screenOff = "Turning screen off"

case class Computer(model: String) extends Device:
  def screenSaverOn = "Turning screen saver on..."


def goIdle(device: Device): String = device match
  case p: Phone => p.screenOff
  case c: Computer => c.screenSaverOn

def goIdleDevice의 타입에 따라 다른 동작을 해요. 이 패턴은 case 안에서 매칭된 객체의 메서드를 호출해야 할 때 유용하죠. 타입의 첫 글자를 case 식별자로 쓰는 게 관례예요(여기서는 pc).

매칭된 패턴을 변수에 바인딩하기

타입에 따라 다른 동작을 하면서 동시에 매칭된 패턴에서 필드를 추출하려면 변수 바인딩을 써요:

// Scala 2
def goIdleWithModel(device: Device): String = device match {
  case p @ Phone(model) => s"$model: ${p.screenOff}" 
  case c @ Computer(model) => s"$model: ${c.screenSaverOn}"
}
// Scala 3
def goIdleWithModel(device: Device): String = device match
  case p @ Phone(model) => s"$model: ${p.screenOff}"
  case c @ Computer(model) => s"$model: ${c.screenSaverOn}"

sealed 타입

위 예시들에서 기본 타입에 sealed 키워드가 붙어 있는 걸 눈치채셨을 거예요. 이건 추가적인 안전을 제공해요. 컴파일러가 기본 타입이 sealed일 때 매치 표현식의 case들이 빠짐없이(exhaustive) 있는지 검사해 주거든요.

예를 들어 위에서 정의한 showNotification에서 한 case, 예를 들어 VoiceRecording을 잊어버리면 컴파일러가 경고를 냅니다:

// Scala 2
def showNotification(notification: Notification): String = {
  notification match {
    case Email(sender, title, _) =>
      s"You got an email from $sender with title: $title"
    case SMS(number, message) =>
      s"You got an SMS from $number! Message: $message"
  }
}
// Scala 3
def showNotification(notification: Notification): String =
  notification match
    case Email(sender, title, _) =>
      s"You got an email from $sender with title: $title"
    case SMS(number, message) =>
      s"You got an SMS from $number! Message: $message"

이 정의는 다음과 같은 경고를 만들어요:

match may not be exhaustive.

It would fail on pattern case: VoiceRecording(_, _)

컴파일러가 실패할 입력 예시까지 알려주죠!

반대로 말하면, 이 빠짐없음 검사가 동작하려면 기본 타입의 모든 서브타입을 기본 타입과 같은 파일에 정의해야 해요. 그렇지 않으면 컴파일러가 가능한 case를 전부 알 수가 없거든요. 예를 들어 sealed trait Notification을 정의한 파일 밖에서 Notification의 새 타입을 정의하면 컴파일 에러가 나요:

case class Telepathy(message: String) extends Notification
           ^
        Cannot extend sealed trait Notification in a different source file

정리

스칼라의 패턴 매칭은 케이스 클래스로 표현되는 대수적 타입(algebraic types)을 매칭할 때 가장 유용해요. 또 스칼라는 케이스 클래스와 별개로, 추출기 객체unapply 메서드를 이용해 패턴을 직접 정의하는 것도 허용해요.

더 알아보기