15. 케이스 클래스와 패턴 매치

dev.jhjhj·2021년 8월 31일
0

Scala

목록 보기
10/10

간단한 예

도메인 특화 언어에 산술 표현식을 다루는 라이브러리가 필요하다고 가정해보다

예시의 첫단계는 입력 데이터를 정의하는 것
문제를 간단히 하기 위해 변수와 숫자, 단항/이항 연산자로 이뤄진 산술식

abstract class Expr		
case class Var (name: String) extends Expr
case class Number (name: Double) extends Expr
case class UnOp (operatot: String, arg : Expr) extends Expr
case class BinOp (operatot: String, left: Expr, right: Expr) extends Expr
  • 추상 기본 클래스 하나
  • 그 클래스를 상속한 4개의 서브 클래스, 각 서브클래스는 식의 한종류를 표현
  • 모든 클래스의 내용은 비어있음

케이스 클래스

class 선언 앞에 case가 붙어있으면 케이스 클래스라고 부른다.

case 클래스의 편리한 기능

  1. 컴파일러는 클래스 이름과 같은 이름의 팩토리 메소드를 추가
  • 팩토리 메소드는 중첩해서 객체를 생성할 때 좋음
    • ex ) new Var("x") 대신 Var("x")를 사용해서 Var 객체 생성 가능
scala> val v = Var("x")
v: Var = Var(x)
  1. 케이스 클래스의 파라미터 목록에 있는 모든 인자에 암시적으로 val 접두사를 붙인다. 각 파라미터가 클래스의 필드도 된다.

  2. 스칼라 컴파일러는 케이스 클래스에 toString, hashCode, equals 메소드의 일반적인 구현을 추가

  3. 스칼라 컴파일러는 케이스 클래스에서 일부를 변경한 복사본을 생성하는 copy 메소드를 추가

    • 기존 인스턴스에 하나 이상의 속성을 바꾼 새로운 인스턴스를 생성할 때 유용 (copy 메소드 8.8절 참조)

패턴 매치

// 패턴 매치를 사용하는 simplifyTop 함수
def simplifyTop(expr: Expr) : Expr = expr match {
    case UnOp("-", UnOp("-", e)) => e       // 부호를 두 번 반전
    case BinOp("+", e, Number(0)) => e      // 0을 더함
    case BinOp("*", e, Number(1)) => e      // 1을 곱함
}

def simplifyTop(expr: Expr): Expr
 
scala> simplifyTop(UnOp("-", UnOp("-", Var("x"))))
val res0: Expr = Var(x)
  • 스칼라는 다음과 같은 형식으로 패턴 매치를 사용
    셀렉터 match { 대안들 }
    참고) 자바의 스위치문 switch (셀렉터) { 대안들 }
  • 셀렉터(selector)는 match 표현식에서 사용이 되는 값
  • 패턴 매치에는 case 키워드로 시작하는 여러 대안(alternative)들이 들어간다.
  • 각각의 대안에서는 패턴과 셀렉터가 일치하였을 때 계산되는 하나 이상의 표현식을 포함한다.
  • 패턴과 표현식을 분리하는 것은 화살표(=>)이다.
  • 따라서, match를 통해서 패턴을 하나씩 검사하게 되면 이 중 첫 번째로 일치한 패턴을 선택해서 화살표 뒤의 표현식을 실행하게 된다.

패턴 형식

  1. 상수 패턴
  • "+" 문자열이나 1과 같은 상수 패턴은 ==을 적용해서 매치
  1. 변수만 사용한 패턴
  • e와 같이 오른쪽에 있는 표현식에 변수가 있으면 그 변수는 매치된 값을 가르킨디ㅏ.
  1. 와일드카드 패턴
  • case+ => expr와 같은 형태
  • 언더스코어(_)를 활용하면 모든 값 매치 가능
  • but, 해당 값에 이름을 붙이지 않기 때문에 표현식에서는 사용 X
  1. 생성자 패턴
  • case UnOp("-", UnOp("-", e)) => e 와 같은 형태
  • 어떤 값의 타입이 UnOp이고, 첫 번째 인자가 "-"이며, 두 번째 인자를 e에 매치시킬 수 있다면 해당 생성자 패턴과 매치 가능

switch와 match 비교

  • match식은 자바 스타일 switch를 일반화
  • 하지만 switch와 다르게 주의할 점 3가지
    * 스칼라의 match는 표현식, 따라서 항상 결과값을 내놓는다.
    • 스칼라의 대안 표현식은 다음 케이스로 빠지지 않는다.
      -> switch에서 각 case에 대해 break가 있다는 전제로 생각하면 된다.
    • 매치에 성공하지 못한 경우, MatchError 예외 발생
      따라서 default 케이스는 반드시 추가해야한다.
      case _ => // default case

패턴의 종류

와일드카드 패턴

expr match {
    case BinOp(_, _, _) => println(expr + " is a binary operation")
    case _ => println("It's something else")
}
  • 와일드카드패턴은 (_) 어떤 객체라도 매치 가능
  • default case로 사용하거나 BinOp에 대한 인자로 사용 가능

상수 패턴

x match {
    case 5 => "five"
    case true => "truth"
    case "hello" => "hi!"
    case Nil => "the empty list"
    case _ => "something else"
}
  • 상수 패턴은 자신과 똑같은 값과 매치, 어떤 리터럴이든 상수로 사용 가능
  • val이나 싱글톤 객체도 상수로 사용 가능. ex) Nil은 오직 빈 리스트에만 매치시킬 수 있는 패턴

변수 패턴

// 변수 패턴을 사용한 패턴 매치
expr match {
  case 0 => "zero"
  case somethingElse => "not zero: " + somethingElse
}
  • 와일드카드 패턴처럼 변수 패턴은 어떤 객체와도 매치 됨
  • 하지만 변수 패턴은 와일드카드 패턴과 아래와 같은 점들이 다르다.
    언더스코어 (_)가 아닌 변수에 객체를 바인딩한다.
    변수를 표현식에서 활용할 수 있다.

변수 또는 상수 ?

상수 패턴이 기호로 이뤄진 이름일 수도 있다. ex) Nil

scala> import math.{E, Pi}
import math.{E, Pi}
 
scala> E match {
         case Pi => "strange math? Pi = " + Pi
         case _ => "OK"
       }
res11: String = OK

컴파일러는 Pi가 셀럭터와 매치시킬 변수가 아니라, scala.math 로 부터 import 한 상수인지 알 수 있을 까?

소문자로 시작하는 간단한 이름은 패턴 변수로 취급하고 다른 모든 참조는 상수로 간주한다.


scala> val pi = math.Pi
pi: Double = 3.141592653589793
 
scala> E match {
         case pi => "strange math? Pi = " + pi
       }
res12: String = strange math? Pi = 2.718281828459045

※ 소문자 이름을 상수 패턴으로 사용하고 싶으면 두 가지 방법

  • 상수가 어떤 객체의 필드인 경우에는 지정자(qualifier)를 앞에 붙일 수 있다.

  • 역따옴표(`)를 사용한다.

생성자 패턴

expr match {
    case BinOp("+", e, Number(0)) => println("a deep match")
    case _ =>
}
  • 깊은 매치(deep match)를 지원
  • 어떤 패턴이 제공받은 최상위 객체를 매치시킬 뿐만 아니라, 추가적인 패턴으로 객체의 내용에 대해서도 매치를 시도
    ex)
    - 최상위 객체가 BinOP인지 검사
    - BinOP 객체의 세번째 생성자 인자가 Number 타입의 객체인지 확인
    - Number 타입 객체의 값 필드가 0인지 확인
  • 실제로는 객체 트리 구조를 세 단계 내려가면서 패턴을 매치

시퀀스 패턴

배열이나 리스트 같은 시퀀스 타입에 대해서도 매치가 가능

expr match {
    case List(0, _, _) => println("found it")
    case _ =>
}
  • 0부터 시작하여 오직 세 원소만 가진 리스트 검사
// 길이와 관계 없이 매치할 수 있는 시퀀스 패턴
expr match {
    case List(0, _*) => println("found it")
    case _ =>
}
  • 리스트 길이가 한정적이지 않을 떄는 마지막 원소를 _*로 표시

튜플 패턴

expr match {
    case (a, b, c)  =>  println("matched " + a + b + c)
    case _ =>
}
  • 튜플도 패턴 매치 가능, 각 튜플에 맞는 원소들을 extract하여 표현식에서 활용 가능

타입 지정 패턴

val x : Any = ...
 
x match {
    case s: String => s.length
    case m: Map[_, _] => m.size
    case _ => -1
}
  • 타입 검사나 타입 변환을 간편하게 하기 위해 타입 지정 패턴 사용
  • s나 x 모두 동일한 값 참조하지만, x는 Any 타입, s는 String 타입 -> 모든 String 인스턴스와 매치 가능
  • Map[_,_]은 와일드 카드를 활용한 타입 지정 패턴
    * 키와 값의 타입과 관계없이 Map 타입의 값과 매치
    • m은 그 값을 가리킨다.

변수 바인딩

// (@ 기호를 사용한) 변수 바인딩이 있는 패턴
expr match {
    case UnOp("abs", e @ UnOp("abs", _)) => e
    case _ =>
}
  • 패턴 매칭에 성공하면 변수 e에는 UnOp("abs", _)) 구문에서 생성된 객체가 할당

그 외 기타

Option type

def optionsPatternMatching(option: Option[String]): String = {
  option match {
    case Some(value) => s"I'm not an empty option. Value $value"
    case None => "I'm an empty option"
  }
}

정규식 패턴 사용

def regexPatterns(toMatch: String): String = {
  val numeric = """([0-9]+)""".r
  val alphabetic = """([a-zA-Z]+)""".r
  val alphanumeric = """([a-zA-Z0-9]+)""".r
 
  toMatch match {
    case numeric(value) => s"I'm a numeric with value $value"
    case alphabetic(value) => s"I'm an alphabetic with value $value"
    case alphanumeric(value) => s"I'm an alphanumeric with value $value"
    case _ => s"I contain other characters than alphanumerics. My value $toMatch"
  }
}

패턴 가드

문법적인 패턴 매치만으로 case에 대한 구분이 부족할 때, 패턴 가드를 사용
case 문을 좀 더 구체화하고 명확하게 하기 위한 boolean 표현식 이라고 생각

  • 패턴 가드는 패턴 뒤에 오고 if로 시작
    어떤 불리언 표현식이든 가드가 될 수 있음.
// 패턴 가드가 있는 match 표현식
scala> def simplifyAdd(e: Expr) = e match {
         case BinOp("+", x, y) if x == y =>
           BinOp("*", x, Number(2))
         case _ => e
       }
simplifyAdd: (e: Expr)Expr

패턴 겹침

패턴 매치는 코드에 있는 순서를 따른다. 그래서 case 문에 대한 순서가 굉장히 중요하다.
아래 예시를 보면 알 수 있다.

// case의 순서가 중요함을 보여주는 match 표현식
def simplifyAll(expr: Expr): Expr = expr match {
    case UnOp("-", UnOp("-", e)) => simplifyAll (e)  // Double negation
    case BinOp("+", e, Number(0)) => simplifyAll (e)     // Adding zero
    case BinOp("*", e, Number(1)) => simplifyAll (e) // Multiplying by one
    case UnOp(op, e) => UnOp(op, simplifyAll(e))
    case BinOp(op, 1, r) => BinOp(op, simplifyAll(1), simplifyAll(r))
    case _ => expr
}
 
def simplifyTop(expr: Expr): Expr = expr match {
   case UnOp("-", UnOp("-", e))  => e   // Double negation
   case BinOp("+", e, Number(0)) => e   // Adding zero
   case BinOp("*", e, Number(1)) => e   // Multiplying by one
   case _ => expr
}
  • simplifyAll 함수는 이전에 정의된 simmplifyTop과 달리 산술식의 최상위 위치뿐 아니라 식의 모든 곳에 간소화 규칙을 적용

  • simplilfyTop 함수에서 2가지의 케이스를 추가하였다.
    case UnOp(op, e) => UnOp(op, simplifyAll(e))
    모든 단항 연산과 매치
    case BinOp(op, 1, r) => BinOp(op, simplifyAll(1), simplifyAll(r))
    모든 이항 연산과 매치

  • 마지막으로 모든 경우를 처리하는 case 문이 더 구체적인 규칙 다음에 온다는 점이 중요

  • 만약 순서를 바꾸게 되면 구체적인 매치는 시도 자체를 하지 않을 것

scala> def simplifyBad(expr: Expr): Expr = expr match {
         case UnOp(op, e) => UnOp(op, simplifyBad(e))
         case UnOp("-", UnOp("-", e)) => e
       }
<console>:21: warning: unreachable code
         case UnOp("-", UnOp("-", e)) => e
                                         ^
simplifyBad: (expr: Expr)Expr

봉인된 클래스

패턴 매치를 작성할 때, 모든 경우를 다 다뤘는 지 확인할 필요가 있다. 그래서 default 케이스를 추가해서 할 수 도 있지만, 이는 합리적인 디폴트 케이스가 있을 때만 가능 -> 만약 디폴트 동작이 없다면 ??

  • 아무곳에서 케이스 클래스를 정의할 수 있기 때문에, 컴파일러에게 놓친 패턴 조합을 찾아달라고 요청할 수 없음...

  • 패턴 매치를 작성할 때 match 식에서 놓친 패턴 조합을 찾을 수 있는 방법은 sealed 키워드를 활용

  • 즉, 패턴 매치에서 셀렉터에 대한 타입을 sealed 클래스로 정의하고 매치를 하는 클래스는 모두 sealed 클래스를 상속받은 클래스로 정의하는 것이다.

// 봉인된 케이스 클래스 계층
sealed abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class UnOp(operator: String, arg: Expr) extends Expr
case class BinOp(operator: String, left: Expr, right: Expr) extends Expr
  • sealed 클래스를 대상으로 패턴 매치를 하게 되면 상속받은 나머지 클래스에 대하여도 모두 case 구문을 만들어줘야 함
  • 하지만 아래와 같이 패턴 매치를 하게 되면 warning이 발생한다.
    컴파일러의 경고, 몇가지 가능한 패턴(UnOp, BinOp)을 처리하지 않았기 때문에 예외 발생
  • 나머지 case 구문을 언더스코어(_)로 처리하여도 문제는 없지만 이상적이진 않으므로 추천은 하지 않는다.
  • 또 다른 방법으로 셀렉터에 @unchecked 어노테이션을 추가
    그러면 컴파일러는 그 match 문의 case 문이 모든 패턴을 다 다루는지 검사하는 일을 생략
def describe(e: Expr): String = e match {
	case Number(_) => "a number"
    case Var(_) => "a variable"
}

// 컴파일러의 경고
warning: match is not exhaustive!
missing combination UnOp
missing combination BinOp
// 셀렉터에 @unchecked 어노테이션을 추가
// 컴파일러는 그 match 문의 case 문이 모든 패턴을 다 다루는지 검사하는 일을 생략
def describe(e: Expr): String = (e: @unchecked) match {
	case Number(_) => "a number"
    case Var(_) => "a variable"
}

Option 타입

  • 스칼라에서는 Option이라는 표준 타입 존재
  • 이 타입은 선택적인 값을 표현하며, 두 가지 형태가 있다.
    x가 실제 값이라면 Some(x) 라는 형태로 값이 있음을 표현할 수 있다.
    반대로 값이 없으면 None이라는 객체가 된다.
  • 스칼라 컬렉션의 몇몇 표준 연산에서는 Option이라는 선택적인 값을 생성한다. 그 중 Map의 get 메소드를 예시로 들 수 있다.
scala> val capitals =  Map("France"->"Paris", "Japan"->"Tokyo")
capitals: scala.collection.immutable.Map[String,String] = Map(France -> Paris, Japan -> Tokyo)
 
scala> capitals get "France"
res23: Option[String] = Some(Paris)
 
scala> capitals get "North Pole"
res24: Option[String] = None
  • java에서는 값이 없을 경우, null을 주로 사용하는 데, 스칼라에서 Option을 활용하면 다음과 같은 장점이 있다.
    * Option[String] 타입의 변수가 null이 될 수도 있는 String 타입의 변수보다
    선택적인 String이라는 사실을 더 명확하게 드러내준다

Option[String] 타입의 변수가 null이 될 수도 있는 String 타입의 변수보다 선택적인 String이라는 사실을 더 명확하게 드러내준다.

패턴은 어디에나

독립적인 match 표현식뿐 아니라, 스칼라의 여러 곳에서 패턴을 사용할 수 있다.

변수정의에서 패턴 사용하기

val이나 var 정의할 때, 단순 식별자 대신 패턴 사용 가능

scala> val myTuple = (123, "abc")
myTuple: (Int, String) = (123,abc)
 
scala> val (number, string) = myTuple
number: Int = 123
string: String = abc

case를 나열해서 부분 함수 만들기

함수 리터럴이 쓰일 수 있는 곳이라면, 중괄호 사이에 case를 나열한 표현식 작성 가능
본질적으로 case의 나열도 함수의 리터럴 값

  • 각각의 case가 함수의 진입점이고, 패턴은 파라미터를 명시한다.
  • 각 진입점에 따른 함수의 본문은 case의 오른쪽 (화살표 =>의 오른쪽)
val withDefault: Option[Int] => Int = {
    case Some(x) => x
    case None => 0
}

아래 예시는 아카 엑터(Akka actors) 라이브러리에서 사용하는 receive 메소드

var sum = 0
 
 // case의 나열은 부분 함수 이다.
def receive = {
    case Data(byte) =>
        sum += byte
    case GetChecksum(requester) =>
        val checksum = ~(sum & OxFF) + 1
        requester ! checksum
}
  • case 나열의 부분 함수에서 그 함수가 처리하지 않는 값을 전달해서 호출하면 실행 시점에서 예외가 발생
scala> val second: List[Int] => Int = {
     |   case x :: y :: _ => y
     | }
                                      ^
       warning: match may not be exhaustive.
       It would fail on the following inputs: List(_), Nil
val second: List[Int] => Int = $Lambda$1061/638249818@4f6e663b
 
scala> second(List(5,6,7))
val res3: Int = 6
 
scala> second(List())
scala.MatchError: List() (of class scala.collection.immutable.Nil$)
  at $anonfun$second$1(<console>:1)
  at $anonfun$second$1$adapted(<console>:1)
  ... 32 elided
  • 처리하지 않는 값을 전달하여 예외가 발생시에 처리 방법
    예외를 던져서 호출자에게 전달한다.
    스칼라의 Option이나 Either 등을 사용하여 정상적인 값과 그렇지 않은 값을 처리할 수 있다.
    부분 함수를 사용하여 처리하지 않는 값에 대하여는 작업을 하지 않도록 할 수 있다.
    여기선, 처리하지 않는 값을 다루기 위해서 부분 함수를 포함하는 타입인 PartitionFunction을 이용한다.

    		* PartitionFunction 클래스는 isDefinedAt 함수를 사용함으로써 파라미터가 case 구문에 속해있는지 먼저 확인을 할 수 있다.
    		* 아래 예시에서 second는 PartitionFunction 타입의 객체로서 정수형 리스트를 받아서 정수 값을 반환한다.
scala> val second : PartialFunction[List[Int], Int] = {
     |   case x :: y :: _ => y
     | }
val second: PartialFunction[List[Int],Int] = <function1>
 
scala> second.isDefinedAt(List(5,6,7))
val res5: Boolean = true
 
scala> second.isDefinedAt(List())
val res6: Boolean = false
 
scala> second(List(5,6,7))
res0: Int = 6
 
// 하지만, PartitionFunction에서도 예외가 발생하는 것은 여전하다.
scala> second(List())
scala.MatchError: List() (of class scala.collection.immutable.Nil$)
  at scala.PartialFunction$$anon$1.apply(PartialFunction.scala:259)
  at scala.PartialFunction$$anon$1.apply(PartialFunction.scala:257)
  at $anonfun$1.applyOrElse(<console>:11)
  at $anonfun$1.applyOrElse(<console>:11)
  at scala.runtime.AbstractPartialFunction.apply(AbstractPartialFunction.scala:38)
  ... 28 elided

for 표현식에서 패턴 사용하기

  • for 표현식 안에 패턴을 사용할 수 있다.

  • 아래 예시는 capitals 맵에서 모든 키/값 쌍을 가져온다.

  • 각 튜플은 country와 city 변수가 있는 패턴과 매치된다.

  • 두 번째 예시는 튜플 패턴과 정확히 매치하지 않을 경우에 해당 값을 버리는 경우에도 사용된다.

scala> for ((country, city) <- capitals)
         println("The capital of " + country + " is " + city)
The capital of France is Paris
The capital of Japan is Tokyo
  • 튜플 패턴과 정확히 매치하지 않을 경우에 해당 값을 버리는 경우에도 사용
scala> val results = List(Some("apple"), None, Some("orange"))
results: List[Option[String]] = List(Some(apple), None, Some(orange))
 
scala> for (Some(fruit) <- results) println(fruit)
apple
orange

결론

  • 스칼라의 케이스 크랠스와 패턴 매치를 활용하면 보통의 객체지향 언어에서 지원하지 않는 간결한 표현법의 이점이 있다.
  • 어떤 클래스에 대해 패턴 매치를 사용하고 싶지만, 케이스 클래스처럼 클래스 필드를 외부에 노출하고 싶지 않다면, 26장의 익스트렉터(Extractor)를 사용할 수 있다.
profile
어제보다 더 나은

0개의 댓글