패턴 매칭

패턴 매칭 (Pattern Matching)

패턴 매칭은 구조화된 값을 깊이 있게 매치하는 기능이에요. 값의 구조를 확인하고, 매치된 부분을 지역 변수에 바인딩해 주죠.

Ruby에서 패턴 매칭은 case/in 표현식으로 구현돼요.

case <expression>
in <pattern1>
  ...
in <pattern2>
  ...
in <pattern3>
  ...
else
  ...
end

(참고로 하나의 case 표현식 안에서 inwhen 브랜치를 섞어 쓸 수는 없어요.)

또는 독립 표현식으로 쓸 수 있는 => 연산자와 in 연산자로도 만들 수 있어요.

<expression> => <pattern>

<expression> in <pattern>

case/in 표현식은 완전(exhaustive) 해요. 표현식의 값이 case의 어떤 브랜치와도 매치되지 않으면(그리고 else 브랜치가 없으면) NoMatchingPatternError가 발생해요.

그래서 case 표현식은 조건부 매칭과 언패킹에 쓸 수 있어요.

config = {db: {user: 'admin', password: 'abc123'}}

case config
in db: {user:} # matches subhash and puts matched value in variable user
  puts "Connect with user '#{user}'"
in connection: {username: }
  puts "Connect with user '#{username}'"
else
  puts "Unrecognized structure of config"
end
# Prints: "Connect with user 'admin'"

반면 => 연산자는 기대하는 데이터 구조를 미리 알고 있을 때, 그 중 일부만 언패킹하는 데 가장 유용해요.

config = {db: {user: 'admin', password: 'abc123'}}

config => {db: {user:}} # will raise if the config's structure is unexpected

puts "Connect with user '#{user}'"
# Prints: "Connect with user 'admin'"

<expression> in <pattern>case <expression>; in <pattern>; true; else false; end와 같아요. 패턴이 매치됐는지 아닌지만 알고 싶을 때 쓸 수 있죠.

users = [{name: "Alice", age: 12}, {name: "Bob", age: 23}]
users.any? {|user| user in {name: /B/, age: 20..} } #=> true

더 많은 예시와 문법 설명은 아래에서 볼게요.

출처: Ruby 공식 문서

패턴 (Patterns)

패턴은 여러 종류가 될 수 있어요.

  • 임의의 Ruby 객체 — when에서처럼 === 연산자로 매치돼요. (값 패턴, Value pattern)
  • 배열 패턴: [<subpattern>, <subpattern>, <subpattern>, ...]. (Array pattern)
  • 탐색 패턴: [*variable, <subpattern>, <subpattern>, <subpattern>, ..., *variable]. (Find pattern)
  • 해시 패턴: {key: <subpattern>, key: <subpattern>, ...}. (Hash pattern)
  • |로 결합한 패턴의 조합. (대안 패턴, Alternative pattern)
  • 변수 포착: <pattern> => variable 또는 variable. (As pattern, Variable pattern)

어떤 패턴이든 <subpattern>이 지정되는 배열/탐색/해시 패턴 안에 중첩될 수 있어요.

배열 패턴과 탐색 패턴은 배열, 또는 deconstruct에 응답하는 객체를 매치해요(후자에 대해서는 아래에서). 해시 패턴은 해시, 또는 deconstruct_keys에 응답하는 객체를 매치해요. 해시 패턴에서는 심볼 키만 지원된다는 점을 기억하세요.

배열 패턴과 해시 패턴의 중요한 차이 하나는, 배열은 전체 배열이어야만 매치된다는 점이에요.

case [1, 2, 3]
in [Integer, Integer]
  "matched"
else
  "not matched"
end
#=> "not matched"

반면 해시는 지정한 부분 외에 다른 키가 있어도 매치돼요.

case {a: 1, b: 2, c: 3}
in {a: Integer}
  "matched"
else
  "not matched"
end
#=> "matched"

{}는 이 규칙의 유일한 예외예요. 빈 해시가 주어졌을 때만 매치되죠.

case {a: 1, b: 2, c: 3}
in {}
  "matched"
else
  "not matched"
end
#=> "not matched"

case {}
in {}
  "matched"
else
  "not matched"
end
#=> "matched"

또한 **nil을 쓰면, 매치되는 해시에 패턴이 명시한 키 외의 다른 키가 없어야 한다고 지정할 수 있어요.

case {a: 1, b: 2}
in {a: Integer, **nil} # this will not match the pattern having keys other than a:
  "matched a part"
in {a: Integer, b: Integer, **nil}
  "matched a whole"
else
  "not matched"
end
#=> "matched a whole"

배열 패턴과 해시 패턴 모두 '나머지(rest)' 지정을 지원해요.

case [1, 2, 3]
in [Integer, *]
  "matched"
else
  "not matched"
end
#=> "matched"

case {a: 1, b: 2, c: 3}
in {a: Integer, **}
  "matched"
else
  "not matched"
end
#=> "matched"

두 종류 패턴 모두 괄호를 생략할 수 있어요.

 case [1, 2]
 in Integer, Integer
   "matched"
 else
   "not matched"
 end
 #=> "matched"

 case {a: 1, b: 2, c: 3}
 in a: Integer
   "matched"
 else
   "not matched"
 end
 #=> "matched"

[1, 2] => a, b
[1, 2] in a, b

{a: 1, b: 2, c: 3} => a:
{a: 1, b: 2, c: 3} in a:

탐색 패턴(Find pattern)은 배열 패턴과 비슷하지만, 주어진 객체에 패턴을 매치하는 요소가 어디에든 있는지 확인할 수 있어요.

case ["a", 1, "b", "c", 2]
in [*, String, String, *]
  "matched"
else
  "not matched"
end

변수 바인딩 (Variable Binding)

깊은 구조 검사와 별개로, 패턴 매칭의 아주 중요한 기능 중 하나는 매치된 부분을 지역 변수에 바인딩하는 거예요. 바인딩의 기본 형태는 매치된 (하위)패턴 뒤에 => variable_name을 지정하는 거예요. (rescue ExceptionClass => var 절에서 예외를 지역 변수에 저장하는 것과 비슷하다고 생각하면 돼요.)

case [1, 2]
in Integer => a, Integer
  "matched: #{a}"
else
  "not matched"
end
#=> "matched: 1"

case {a: 1, b: 2, c: 3}
in a: Integer => m
  "matched: #{m}"
else
  "not matched"
end
#=> "matched: 1"

추가 검사가 필요 없고 데이터의 일부를 변수에 바인딩하기만 한다면, 더 간단한 형태를 쓸 수 있어요.

case [1, 2]
in a, Integer
  "matched: #{a}"
else
  "not matched"
end
#=> "matched: 1"

case {a: 1, b: 2, c: 3}
in a: m
  "matched: #{m}"
else
  "not matched"
end
#=> "matched: 1"

해시 패턴에는 더 간단한 형태도 있어요. 키만 지정하면(하위 패턴 없이) 그 키 이름으로 지역 변수에 바인딩돼요.

case {a: 1, b: 2, c: 3}
in a:
  "matched: #{a}"
else
  "not matched"
end
#=> "matched: 1"

바인딩은 중첩 패턴에도 동작해요.

case {name: 'John', friends: [{name: 'Jane'}, {name: 'Rajesh'}]}
in name:, friends: [{name: first_friend}, *]
  "matched: #{first_friend}"
else
  "not matched"
end
#=> "matched: Jane"

패턴의 '나머지(rest)' 부분도 변수에 바인딩할 수 있어요.

case [1, 2, 3]
in a, *rest
  "matched: #{a}, #{rest}"
else
  "not matched"
end
#=> "matched: 1, [2, 3]"

case {a: 1, b: 2, c: 3}
in a:, **rest
  "matched: #{a}, #{rest}"
else
  "not matched"
end
#=> "matched: 1, {:b=>2, :c=>3}"

변수 바인딩은 현재 |로 결합된 대안 패턴에서는 동작하지 않아요.

case {a: 1, b: 2}
in {a: } | Array
  "matched: #{a}"
else
  "not matched"
end
# SyntaxError (illegal variable in alternative pattern (a))

_로 시작하는 변수만 이 규칙의 예외예요.

case {a: 1, b: 2}
in {a: _, b: _foo} | Array
  "matched: #{_}, #{_foo}"
else
  "not matched"
end
# => "matched: 1, 2"

다만 바인딩된 값을 재사용하는 것은 권장하지 않아요. 이 패턴의 목적은 버려지는(discarded) 값을 표시하는 거거든요.

변수 핀(Pinning) (Variable Pinning)

변수 바인딩 기능 때문에, 기존 지역 변수를 하위 패턴으로 그대로 쓸 수는 없어요.

expectation = 18

case [1, 2]
in expectation, *rest
  "matched. expectation was: #{expectation}"
else
  "not matched. expectation was: #{expectation}"
end
# expected: "not matched. expectation was: 18"
# real: "matched. expectation was: 1" -- local variable just rewritten

이런 경우 핀 연산자 ^를 쓸 수 있어요. Ruby에게 "이 값을 패턴의 일부로 그냥 써"라고 알려주는 거죠.

expectation = 18
case [1, 2]
in ^expectation, *rest
  "matched. expectation was: #{expectation}"
else
  "not matched. expectation was: #{expectation}"
end
#=> "not matched. expectation was: 18"

변수 핀의 중요한 용도 하나는, 패턴 안에서 같은 값이 여러 번 나타나야 한다고 지정하는 거예요.

jane = {school: 'high', schools: [{id: 1, level: 'middle'}, {id: 2, level: 'high'}]}
john = {school: 'high', schools: [{id: 1, level: 'middle'}]}

case jane
in school:, schools: [*, {id:, level: ^school}] # select the last school, level should match
  "matched. school: #{id}"
else
  "not matched"
end
#=> "matched. school: 2"

case john # the specified school level is "high", but last school does not match
in school:, schools: [*, {id:, level: ^school}]
  "matched. school: #{id}"
else
  "not matched"
end
#=> "not matched"

지역 변수 핀에 더해, 인스턴스 변수·전역 변수·클래스 변수도 핀할 수 있어요.

$gvar = 1
class A
  @ivar = 2
  @@cvar = 3
  case [1, 2, 3]
  in ^$gvar, ^@ivar, ^@@cvar
    "matched"
  else
    "not matched"
  end
  #=> "matched"
end

괄호를 사용하면 임의의 표현식의 결과도 핀할 수 있어요.

a = 1
b = 2
case 3
in ^(a + b)
  "matched"
else
  "not matched"
end
#=> "matched"

비프리미티브 객체 매치하기: deconstruct와 deconstruct_keys

앞에서 언급했듯, 배열·탐색·해시 패턴은 리터럴 배열과 해시 외에도 deconstruct(배열/탐색 패턴용)나 deconstruct_keys(해시 패턴용)를 구현한 어떤 객체든 매치하려고 시도해요.

class Point
  def initialize(x, y)
    @x, @y = x, y
  end

  def deconstruct
    puts "deconstruct called"
    [@x, @y]
  end

  def deconstruct_keys(keys)
    puts "deconstruct_keys called with #{keys.inspect}"
    {x: @x, y: @y}
  end
end

case Point.new(1, -2)
in px, Integer  # sub-patterns and variable binding works
  "matched: #{px}"
else
  "not matched"
end
# prints "deconstruct called"
"matched: 1"

case Point.new(1, -2)
in x: 0.. => px
  "matched: #{px}"
else
  "not matched"
end
# prints: deconstruct_keys called with [:x]
#=> "matched: 1"

keysdeconstruct_keys에 전달돼서 매치 대상 클래스에 최적화 여지를 줘요. 전체 해시 표현을 계산하는 게 비싸다면 필요한 하위 해시만 계산해도 되는 거죠. **rest 패턴을 쓸 때는 keys 값으로 nil이 전달돼요.

case Point.new(1, -2)
in x: 0.. => px, **rest
  "matched: #{px}"
else
  "not matched"
end
# prints: deconstruct_keys called with nil
#=> "matched: 1"

추가로, 커스텀 클래스를 매치할 때 기대하는 클래스를 패턴의 일부로 지정할 수 있고, 그건 ===로 확인돼요.

class SuperPoint < Point
end

case Point.new(1, -2)
in SuperPoint(x: 0.. => px)
  "matched: #{px}"
else
  "not matched"
end
#=> "not matched"

case SuperPoint.new(1, -2)
in SuperPoint[x: 0.. => px] # [] or () parentheses are allowed
  "matched: #{px}"
else
  "not matched"
end
#=> "matched: 1"

이런 코어·라이브러리 클래스들이 구조 분해(deconstruction)을 구현하고 있어요.

  • MatchData#deconstructMatchData#deconstruct_keys.
  • Time#deconstruct_keys, Date#deconstruct_keys, DateTime#deconstruct_keys.

가드 절 (Guard Clauses)

패턴이 매치됐을 때 추가 조건(가드 절)을 달아주려면 if를 쓸 수 있어요. 이 조건은 바인딩된 변수를 사용할 수 있죠.

case [1, 2]
in a, b if b == a*2
  "matched"
else
  "not matched"
end
#=> "matched"

case [1, 1]
in a, b if b == a*2
  "matched"
else
  "not matched"
end
#=> "not matched"

unless도 동작해요.

case [1, 1]
in a, b unless b == a*2
  "matched"
else
  "not matched"
end
#=> "matched"

부록 A. 패턴 문법 (Pattern syntax)

대략적인 문법은 이렇습니다.

pattern: value_pattern
       | variable_pattern
       | alternative_pattern
       | as_pattern
       | array_pattern
       | find_pattern
       | hash_pattern

value_pattern: literal
             | Constant
             | ^local_variable
             | ^instance_variable
             | ^class_variable
             | ^global_variable
             | ^(expression)

variable_pattern: variable

alternative_pattern: pattern | pattern | ...

as_pattern: pattern => variable

array_pattern: [pattern, ..., *variable]
             | Constant(pattern, ..., *variable)
             | Constant[pattern, ..., *variable]

find_pattern: [*variable, pattern, ..., *variable]
            | Constant(*variable, pattern, ..., *variable)
            | Constant[*variable, pattern, ..., *variable]

hash_pattern: {key: pattern, key:, ..., **variable}
            | Constant(key: pattern, key:, ..., **variable)
            | Constant[key: pattern, key:, ..., **variable]

부록 B. 정의되지 않은 동작 예시 몇 가지

미래의 최적화 여지를 남겨 두기 위해, 스펙에는 몇 가지 정의되지 않은(undefined) 동작이 있어요.

매치되지 않은 패턴에서 변수를 사용하기:

case [0, 1]
in [a, 2]
  "not matched"
in b
  "matched"
in c
  "not matched"
end
a #=> undefined
c #=> undefined

deconstruct, deconstruct_keys 메서드 호출 횟수:

$i = 0
ary = [0]
def ary.deconstruct
  $i += 1
  self
end
case ary
in [0, 1]
  "not matched"
in [0]
  "matched"
end
$i #=> undefined

더 알아보기 (Learn more)

  • case/whencase/in의 관계, 그리고 표현식 문법은 조건식 (Control Expressions) 문서를 보세요.
  • 구조 분해에 쓰이는 MatchData#deconstruct, Date#deconstruct_keys 같은 메서드는 각 클래스 문서에서 더 볼 수 있어요.