패턴 매칭
패턴 매칭 (Pattern Matching)
패턴 매칭은 구조화된 값을 깊이 있게 매치하는 기능이에요. 값의 구조를 확인하고, 매치된 부분을 지역 변수에 바인딩해 주죠.
Ruby에서 패턴 매칭은 case/in 표현식으로 구현돼요.
case <expression>
in <pattern1>
...
in <pattern2>
...
in <pattern3>
...
else
...
end
(참고로 하나의 case 표현식 안에서 in과 when 브랜치를 섞어 쓸 수는 없어요.)
또는 독립 표현식으로 쓸 수 있는 => 연산자와 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"
keys는 deconstruct_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#deconstruct와MatchData#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/when과case/in의 관계, 그리고 표현식 문법은 조건식 (Control Expressions) 문서를 보세요.- 구조 분해에 쓰이는
MatchData#deconstruct,Date#deconstruct_keys같은 메서드는 각 클래스 문서에서 더 볼 수 있어요.