[Scala] 패턴매칭과 케이스 클래스

smlee·2023년 8월 17일
0

Scala

목록 보기
20/37
post-thumbnail

이 글은 Programming in Scala 4/e Chapter 15를 읽고 작성한 글입니다.


케이스 클래스와 패턴 매치는 쌍둥이 구성요소로, 일반적으로 캡슐화되지 않은 데이터구조를 작성할 때 사용한다. 이 두 구성요소는 트리 같은 재귀적 데이터에 유용하다.
케이스 클래스는 아주 많은 코드를 작성하지 않고도 객체에 대한 패턴 매치를 하게 해주는 스칼라 구성요소이다. 대부분의 경우, 해야 할 일은 패턴 매치에 사용할 각 클래스 앞에 case 키워드를 추가해주는 것 뿐이다.

간단한 예시

본격적으로 케이스 클래스에 대해 공부하기 전에, 간단한 예시로 공부하는 것이 좋다.
도메인 특화 언어(DSL/Domain Specific Language)에 산술 표현식을 다루는 라이브러리가 필요하다고 생각해보자.

도메인 특화 언어 (Domain Specific Language)
도메인 특화 언어는 특정 기능이나 영역을 위해 만들어진 언어로, 다음과 같이 다양한 작업을 위해 소프트웨어 개발에 많이 사용된다.
1. 소프트웨어 설정 설명
2. 테스트 사양
3. 작업 흐름 규칙 정의
4. UI 디자인
5. 데이터 조작

이 문제를 해결하기 위해 가장 첫 단계는 입력 데이터를 정의하는 것이다. 문제를 간단히 하기 위해 변수와 숫자, 단항/이항 연산자로 이루어진 산술식만 다룰 것이다. 스칼라에서는 이러한 산술식을 밑과 같이 표현할 수 있다.

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

케이스 클래스(case class)

위의 간단한 예시 코드에서 case class라는 키워드로 클래스 선언이 되어 있는 것을 볼 수 있다. 이러한 키워드가 붙은 클래스를 케이스 클래스라고 부른다.
이러한 케이스 클래스는 스칼라 컴파일러에게 문법적으로 편리한 기능을 추가하라고 지시하는 것이다.

컴파일러가 케이스 클래스에 추가해주는 편리한 기능

1. 클래스 이름과 같은 이름의 팩토리 메서드 추가

case class로 선언된 클래스는 컴파일러가 자동으로 클래스 이름과 같은 이름의 팩토리 메서드를 추가한다. 따라서 다음과 같은 형태로 선언이 가능한 것이다.

case class Point(x:Int, y:Int)

val leftTop:Point = Point(1, 5)

위와 같이 new라는 키워드가 존재하지 않음에도 불구하고, 클래스가 선언된 것을 볼 수 있다. 이는 컴파일러가 자동으로 클래스 케이스에 대한 팩토리 메서드를 추가해주기 때문에 가능하다. 이러한 구조는 중첩해서 객체를 생성할 때 특히 좋다

2. 케이스 클래스 파라미터 목록의 모든 인자에 자동으로 val을 붙인다

우리는 앞쪽에서 클래스 쪽을 공부할 때, 클래스 생성 시 받는 파라미터 목록이 클래스 멤버로 사용하고 싶으면 파라미터 목록에 val을 붙여야 한다는 것을 공부했었다.

위의 예시에서 클래스 Rational의 파라미터 목록으로 val 없이 n과 d를 받았다. 그리고, Rational 객체 인스턴스를 가지는 변수 x의 n에 접근하려고 했지만, n에 접근할 수 없다는 오류가 발생한다. 이는 n이 Rational의 멤버가 아니기 때문이다.

반면 파라미터 목록 앞에 val을 붙이면 해당 객체의 멤버 필드로 인식해서 접근이 가능한 것을 알 수 있다.

case class로 선언하면 위처럼 클래스 내부 멤버 변수로 인식하도록 컴파일러가 자동으로 val 접두사를 붙여준다.

3. 케이스 클래스에 toString, hashCode, equals 메서드의 일반적인 구현 추가

case class도 하나의 클래스이므로 스칼라 컴파일러가 자동으로 해당 메서드들에 대한 일반적인 구현을 추가해준다.

4. 케이스 클래스에서 일부를 변경한 복사본을 생성하는 copy 메서드 추가

컴파일러는 자동으로 copy라는 메서드를 추가한다. 이 메서드는 기존의 인스턴스에서 하나 이상의 속성을 바꾼 새로운 인스턴스를 생성할 때 매우 유용하다. 이때, 변경하고 싶은 인자만을 명시하면 된다.


위의 행위들을 스칼라 컴파일러가 자동으로 진행함에 따라, 해당 클래스와 객체가 조금 커진다.
몇 가지 메서드가 생성되고, 객체 생성자 파라미터에 대한 암시적 필드가 추가되므로 크기가 커질 수 밖에 없다. 하지만, 케이스 클래스의 가장 큰 장점은 패턴 매치를 지원한다는 점이다.

패턴 매치 pattern match

match는 java의 switch와 비슷하다. 하지만, match를 셀렉터 표현식 바로 뒤에 써야 한다.

셀렉터 match {
	case 대안
    case 대안
}

위와 같은 형식으로 사용한다. case 키워드로 시작하는 여러 대안이 들어간다. 각 대안들은 패턴과 셀렉터가 일치했을 때 계산되는 하나 이상의 표현식을 포함한다. =>는 패턴과 계산할 표현식을 분리한다.
match 식은 코드에 쓰인 순서대로 패턴을 하나씩 검사한다.
상수 패턴(constant pattern)은 "+"나 1 같은 것들을 == 연산자를 적용해서 매치한다.
변수만을 사용한 패턴(variable pattern)은 모든 값과 매치할 수 있다.
그리고, case에서 와일드카드 패턴(wildcard pattern)인 언더바(_)는 모든 값과 매치할 수 있다. 즉, switch-case문에서 default라고 생각할 수 있다.

match vs switch
match 식은 자바 스타일의 switch를 일반화한 것이다. 하지만 주의해야 할 3가지가 있다.
1. 스칼라의 match는 표현식이다. 따라서 그 식은 결과값을 내놓는다.
2. 스칼라의 대안 표현식은 다음 테스트 케이스로 빠지지 않는다. 즉, java의 switch-case문에서 break가 없는 경우 다음 테스트 케이스로 빠지지만 scala는 그렇지 않는다.
3. 매치에 성공하지 못한 경우에는 MatchError가 발생한다. 따라서 디폴트 케이스를 반드시 추가해야 한다.

패턴의 종류

1. 와일드카드 패턴

와일드카드 패턴은 언더바(_)로, 어떤 객체라도 매치될 수 있다. 즉 다음과 같이 사용하는 것이다.

expr match {
	case BinOp(op, left, right) => println(s"$expr is a binary operation")
    case _ => // 디폴트 처리
}

위의 코드에서 알 수 있듯, 와일드카드 _는 자바의 switch-case문의 default처럼 사용한다.

2. 상수 패턴

상수 패턴은 셀렉터와 해당 값이 같은지를 확인하는 것이다.

def describe(x: Any) = x match {
	case 5 => "five"
    case true => "truth"
    case "hello" => "hi!"
    case Nil => "the empty list"
    case _ => "default"
}

위의 식을 실행시키면 다음과 같다.

위처럼 x와 같은 값들을 매칭시켜 동일한지 여부를 확인 후 출력하는 것이다.

3. 변수 패턴

변수 패턴은 와일드카드처럼 어떤 객체와도 매치된다. 와일드카드와 다른 점은 변수에 객체를 바인딩한다는 것이다.
변수 패턴에서 주의해야할 점은 모호성을 없애기 위하여 소문자로 시작하는 간단한 이름은 패턴 변수로 취급하고, 다른 참조는 상수로 간주하는 것이다.
소문자 변수를 사용하고 싶다면 백틱(`)을 사용하여 감싼다.

E match {
	case `pi` => "strange math? Pi = " + pi
    case _ => "Ok"
}

위와 같이 백틱을 사용하여 패턴매칭에 변수를 사용할 수 있다.

4. 생성자 패턴

생성자는 패턴 매치가 실제로 큰 위력을 발휘할 수 있는 곳이다. 생성자 패턴은 BinOp("+", e, Number(0))과 같은 형태다. 이름이 케이스 클래스를 가리킨다면, 이 패턴의 의미는 어떤 값이 해당 케이스 클래스의 멤버인지 검사한 다음, 그 객체의 생성자가 인자로 전달받은 값들이 괄호 안의 패턴과 정확히 매치되는지 검사하는 것이다.
이런 추가적인 패턴이 있다는 것은 스칼라 패턴이 깊은 매치(deep match)를 지원하는 것을 알 수 있다. 즉, 어떤 패턴이 제공 받은 최상위 객체를 매치시킬 뿐만 아니라 추가적인 패턴으로 객체의 내용에 대해서도 매치를 시도한다.

expr match {
	case BinOp("+", e, Number(0)) => println("found it!")
    case _ => println("")
}

위와 같이 BinOp에 해당하는 케이스 클래스에 대해 객체의 내용에서도 매치되는지 확인할 수 있다.

5. 시퀀스 패턴

케이스 클래스에 대해 패턴 매치를 하는 것처럼, 배열이나 리스트 같은 시퀀스 타입에 대해서도 패턴 매칭을 할 수 있다.

expr match {
	case List(0, _, _) => println("found it!")
    case _ =>
}

위는 0으로 시작하며 총 3개의 원소를 가지고 있는 리스트인지 여부를 검사하는 것이다.

expr match {
	case List(0, _*) => println("found it!")
    case _ =>
}

길이를 한정하지 않고 시퀀스와 매치하고 싶다면, 패턴의 마지막 원소를 _*로 표시하면 된다. 위의 경우 0으로 시작하며 길이는 상관 없는 리스트를 찾는 것이다.

6. 튜플 패턴

튜플 역시 매칭 가능하다.

def tupleMatch (expr: Any) = 
	expr match {
    	case (a, b, c) => println ("matched " +a+b+c)
        case _ => 
    }

위는 3튜플인지 검사하고 매칭하는 것으로, 튜플 패턴 역시 검사 가능하다.

7. 타입 지정 패턴

타입 검사나 타입 변환을 간편하게 대신하기 위해 타입 지정 패턴 typed pattern을 사용할 수 있다.

def generalSize(x:Any) = x match {
	case s:String => s.length
    case m:Map[_, _] => m.size
    case _ => "nothing"
}

위의 코드는 모든 타입의 파라미터를 받을 수 있다. 그리고, String이거나 Map이면 해당 자료구조의 길이와 크기를 리턴하고, 그 외의 것을 리턴하면 "nothing"을 리턴하도록 했다.

즉, 타입을 검사할 수 있는 것이다.

타입 소거

스칼라는 자바와 마찬가지로 제네릭에서 타입 소거(type erasure) 모델을 사용한다. 이는 실행 시점에 타입 인자에 대한 정보를 유지하지 않는다는 뜻이다.
위와 같이 Map[Int,Int]인지를 검사하는 패턴 매칭이다. 하지만, 위의 Unchecked Warning을 보면, 경고가 뜬다.
이를 직접 확인해보자.

처음 Map(1->3)은 당연히 true를 반환한다. 반면, Map[String, String]을 넣으면 false가 나와야 하는데, true가 나오는 것을 확인할 수 있다.

타입 소거 (Type Erasure)
Type Erasure(타입 소거)란 제네릭 타입에 사용된 타입 정보를 컴파일 타임에만 사용하고 런타임에는 소거하는 것을 말한다.

타입 소거의 유일한 예외는 배열이다. 배열은 자바 뿐만 아니라 스칼라에서도 특별하게 다뤄지므로 원소 타입과 값을 함께 저장하기 때문이다.

8. 변수 바인딩

변수가 하나만 있는 패턴 말고 다른 패턴에 변수를 추가할 수 있다. 변수 이름 다음에 @ 기호를 넣고 패턴을 쓰면 된다.

expr match {
	case UnOp("abs", e @ UnOp("abs", _)) => e
    case _ =>
}

위의 코드는 "abs" 연산을 2번 적용한 패턴을 갖는 것을 의미한다.

패턴 가드

때때로, 문법적인 패턴 매치만으로 정확성이 부족한 경우가 있다. 스칼라가 패턴을 선형 패턴을 사용하므로 어떤 패턴 변수가 한 패턴 안에 오직 한 번만 나와야 한다는 것이다.

def simplifyAdd(e:Expr) = e match {
	case BinOp("+", x, x) => BinOp("*", x, Number(2))
    case _ => e
}

위 코드의 매치는 실패할 것이다. 왜냐하면 패턴 변수 x가 패턴 안에 2번 나타났기 때문이다.
이를 정상적으로 실행하게 하려면 어떻게 할까?

def simplifyAdd(e:Expr) = e match {
	case BinOp("+", x, y) if x == y =>  BinOp("*", x, Number)
    case _ => e
}

위와 같이 if 후에 조건을 걸면 되는 것이다. 이처럼 패턴 가드는 패턴 뒤에 오고 if로 시작한다. 어떤 Boolean 표현식이든 가드가 될 수 있다. 가드가 true가 될 때만 매치에 성공한다.

봉인된 클래스

패턴 매치를 작성할 때 match 표현식의 마지막에 디폴트 케이스를 추가할 수도 있지만, 디폴트 동작이 있을 때만 동작 가능하다.
하지만, 디폴트 동작이 없다면 어떻게 할까? 대안은 케이스 클래스의 슈퍼클래스를 봉인된 클래스(sealed class)로 만드는 것이다. 봉인된 클래스는 그 클래스와 같은 파일이 아닌 다른 곳에서 새로운 서브클래스를 만들 수 없다.

Option 타입

스칼라에는 Option이라는 표준 타입이 있다. 이 타입은 선택적인 값을 표현하며, 두 가지 형태가 있다.
x가 실제 값이라면 Some(x)라는 형태로 값이 있음을 표현할 수 있다. 반대로, 값이 없으면 None이라는 객체가 된다.

위와 같이 Some(Paris)로 나오는 것이 보인다.

Map 안에 존재하지 않는 원소를 가져오면 None을 리턴하는 것이 보인다.
이렇게 스칼라 컬렉션의 몇몇 표준 연산은 선택적인 값을 생성한다. 위의 예시처럼 Map의 get 메서드는 인자로 받은 키에 대응하는 값이 있다면 Some(값)을 반환하고, 그 키가 없으면 None을 리턴한다.
이러한 Option 타입을 패턴 매칭을 통해 옵션값을 분리할 수 있다.

def show(x:Option[String]) = x match {
	case Some => s
    case None => "?"
 }

저렇게 패턴 매칭을 통해 옵션값을 분리할 수 있다.

📚 Reference

0개의 댓글