[es2015+] 패턴 매칭(pattern matching)

개요top

현재(2018.04) tc39 – stage0의 제안 중에는 pattern matching 이란 게 올라와 있습니다.

유명한 스칼라의 패턴 매칭을 비롯하여 Rust, F#등 주로 함수형 언어에서 제공해주는 기능인데 이걸 JS에 도입하자는 안입니다.
전통적인 if가 문을 지향하므로 이를 식으로 바꾼 게 3항식입니다만 더 확장하여 switch문을 식으로 바꾸는 개념이 바로 패턴 매칭인 셈입니다.

JS에는 없던 개념이라 다른 언어의 패턴과 매칭에 대한 다양한 문서가 도움이 됩니다.

Swift Patterns

scala pattern matching

(사실 swift의 switch는 문이지만..)
이번 포스팅에서는 제안 상의 내용을 살펴보고 아직 표준이 되기엔 먼 JS의 패턴 매칭을 적당히 구현해보도록 하죠.

타 언어의 패턴매칭top

요즘 사랑하는 swift를 살짝 볼까요.

class Test{
  private let name:String
  init(_ n:String){name = n}
}

switch Test("hika") {
case let t as Test where t.name == "hika":
  print("ok")
default: break
}

위 구문에서 Test클래스의 인스턴스에 대해 형 검사를 할 뿐 아니라 where를 통해 내부 속성도 검사할 수 있습니다. 또한 한 번에 여러 개 값에 대한 조합도 가능하죠.

class Test1{
  private let name:String
  init(_ n:String){name = n}
}
class Test2{
  private let name:String
  init(_ n:String){name = n}
}

switch (Test1("hika"), Test2("kang")) {
case let (t1 as Test1, t2 as Test2) where t1.name == "hika" && t2.name == "kang":
  print("ok")
default: break
}

하지만 처음 설명 드린 대로 swift는 문으로 작동합니다. 값으로 할당이 되지는 않죠. 스칼라를 볼까요(제가 예제에 즐겨 사용하는 그래픽 계층구조로 ^^)


sealed trait Shape
case class Rect(w:Int, h:Int) extends Shape
case class Circle(r:Int) extends Shape

def getType(v1: Shape, v2: Shape): String = (v1, v2) match {
 case (Rect(w, h), _: Circle) if w * h > 10 => "type1"
 case (_: Circle, _: Circle) => "type2"
  case _ => "type3"
}

println(getType(new Rect(5, 5), new Circle(10))) //type1
println(getType(new Circle(10), new Circle(1))) //type2

스칼라는 식으로 작동하기 때문에 값으로 할당할 수 있습니다.

stage0제안의 패턴 매칭top

사실 문을 지향하는 컨텍스트에서 패턴 매칭은 비지터를 통해 구현하면 코드를 분산하여 구현할 수는 있지만 간단한 switch를 위해서 너무 많은 노력이 들어갑니다.
식을 지향하는 입장에선 언어 차원에서 지원하던가 아니면 고차함수를 통해 구현해야겠죠. 위에 언급했던 stage0 제안에 등장하는 패턴 매칭 구문은 다음과 같습니다.

const res = await fetch(jsonService)
const val = match (res) {
  {status: 200, headers: {'Content-Length': s}} => `size is ${s}`,
  {status: 404} => 'JSON not found',
  {status} if (status >= 400) => throw new RequestError(res)
}

전반적으로는 스칼라와 굉장히 비슷합니다(아니 완전히 똑같은..) match 구문 내에 들어가는 타겟을 해체 구문과 결합한 형태로 값을 처리하며 중간에 if가 개입하는 것도 완전히 똑같은 꼴입니다(개인적으로는 스위프트 방식이 보다 이해하기 쉽고 더 광범위하게 사용할 수 있다고 생각하지만 이미 이쪽으로 기울었으니 어쩔 수 없죠)

이 프로젝트에는 무려 챔피온이 세 명이나 관련되어있는데 대부분 프론트 개발 장인들인데다가 MS에 좀 친화적인 인간들이라 딱히 스칼라를 선호할 사람들 같지 않았는데 어쩌다가…

collection literalstop

저 간단한 해체 형태의 매칭 외에도 제안서에는 collection literals라는 제안과 결합할 예정이라고 써 있습니다. 이 제안 역시 현 시점에는 stage 0 상태고 위의 제안 챔피온 중에 한 명이 제안자이자 챔피온입니다. 이 제안만 해도 복잡하기 때문에 이번 포스팅에서 자세히 다루지는 않겠지만 해체만으로는 컬렉션의 내부를 완전히 가져오거나 설정하기 힘들기 때문에 이를 위해 별도의 리터럴을 정의하는 형태입니다.
예를 들어 Map을 초기화하는 다음의 구문을 생각해보죠.

const map = new Map;
map.set('a', 2).set('three', 4);

이걸 좀 편하게 하려면 생성자에 엔트리 형태의 2차원 배열을 넘기는 방법이 있습니다.

const map = new Map([['a',2], ['three', 4]]);

하지만 이건 좀 귀찮은 느낌입니다. 게다가 es6초기에는 함수형에 대한 컨텍스트가 강해 다분히 불필요한 리스트 형태로 초기화하고 있어 전혀 js스럽지 않습니다(lisp이냐) 이 제안은 #을 이용해 다음과 같이 초기화하는 것을 제안합니다.

const map = Map#{a:2, three:4};

이제 좀 js같네요. 개인적으로는 찬성하고 있습니다만 이미 #이 class의 private에 예약되어 혼란을 야기할 것 같아 걱정이 되긴합니다.
여튼 이러한 구문은 보다 확장되어 이터레이션이나 set까지 확장하여 쓸 수 있고 해체식에까지 확장됩니다. 위의 map으로부터 다시 a와 three를 추출하려면 다음과 같이 하면 됩니다.

const map = Map#{a:2, three:4};
const Map#{a, three} = map;
//a == 2, three == 4

이러한 해체로 확장이 바로 match와 연결되는 부분입니다.

match (input) {
  Map#{a:2, three: 4} => console.log('ok')
}

아이디어 스케치top

값에 대해 확인할 조건은 여러가지를 줄 수 있을 것입니다.
예를 들어 숫자형이면서 3이하라던가, 문자열인데 5자 이내라던가.
하지만 더 깊게 생각해보면 이러한 여러 조건들의 결합이 and이거나 or일 수 있다는 걸 알 수 있습니다.

  • 숫자형이거나 문자열 : or
  • 숫자형이면 3이하 : and
  • null이 아닌 Object : and

이런 식으로 더 나아가 생각해보면 해당 case를 처리하는 방식은 제 생각엔 아예 처음부터 caseOne, caseAnd, caseOr이 있는 것으로 보입니다.
그 외엔 match가 받아들일 값이 몇 개냐에 따라 조합조건을 확인해가는 방식으로 전개하면 될 것입니다.
이러한 아이디어를 기반으로 완성된 코드를 사용해보면

//다양한 유틸제공
const {is, tuple, range} = matcher;

//그냥 문으로 사용할 때
matcher({a:3, b:4})
  .caseAnd({a:3}, {b:5}, _=>console.log("case1"))
  .caseOr({a:3}, {b:3}, _=>console.log("case2"));
  .caseOne(is(Date, Array, Object), _=>console.log("case3"));

//식으로 사용할 때
const a = matcher({a:3, b:4})
  .caseAnd({a:3}, {b:5}, _=>"case1")
  .caseOr({a:3}, {b:3}, _=>"case2");
  .caseOne(is(Date, Array, Object), _=>"case3");
  .result;
console.log(a);

//다수의 값이 들어오는 경우
matcher({a:3, b:4}, {c:5})
  .caseOne(tuple({a:3}, {c:2}), _=>console.log("case1"))
//두 개 이상의 값에 대응하는 케이스는 배열로 대응하기 애매하므로 matcher.tuple이용

빌더패턴을 따르지만 result를 불러내지 않아도 일단 문으로는 작동할테고 굳이 값이 필요할 때만 빌딩절차를 밟으면 될 것 같은 느낌입니다.

Matcher 기본 구현top

스케치에 나온 내용을 바탕으로 클래스의 기본 구조를 잡아보죠.

const Matcher = class{

  constructor(values){
    this._values = values; //다수의 값 처리가능!
    this._isEnd = false; //default를 통해 종결되었는지
    this._isMatched = false; //이미 매칭된 케이스가 있는지
    this._result = null; //결과값이 없음
  }

  //매칭 시 처리
  _matched(v){
    this._isMatched = true;
    this._result = v;
  }
  //하나의 조건에 대한 매칭판단
  _match(val, cond){
    switch(true){
      //함수인 경우 호출하여 참 거짓 판단
      case typeof cond == 'function': return cond(val);
      //기본형인 경우 값과 같은지 비교
      case typeof cond != 'object' || cond === null: return val === cond;
      //객체인 경우 엔트리 비교, _인 경우는 건너뛰기 가능
      default: return Object.entries(cond).every(([k, v])=>v === _ || this._match(val[k], v));
    }
  }
  //케이스처리기
  _case(cases, mode){
    //종결된 후 case를 호출하면 예외임
    if(this._isEnd) throw 'ended';

    //이미 케이스가 매칭되었다면 아무것도 안함
    if(this._isMatched) return this;

    //cases인자의 마지막이 처리기임
    const f = cases.pop();

    //검사할 대상값
    const val = this._values;

    //모드에 따라 and = every, or = some 메소드에 매칭됨
    if(cases[mode](c=>c instanceof Tuple ? 
        c.every((c, i)=>this._match(val[i], c)) :
        this._match(val[0], c)
      )) this._matched(f());
    return this;
  }
  //실제 공개될 메소드
  caseOne(v, f){return this._case([v, f], 'every');}
  caseAnd(...arg){return this._case(arg, 'every');}
  caseOr(...arg){return this._case(arg, 'some');}
  //기본케이스처리
  default(f){
    if(this._isEnd) throw 'ended';
    if(!this._isMatched) this._matched(f());
    this._isEnd = true;
    return this;
  }
  //결과반환
  get result(){return this._result;}
};

구현은 그리 어렵지 않습니다. 코드도 간단하니 주석을 보시면 충분히 이해 가능한 레벨입니다. _match에서 실제 매칭 처리를 할 때 세 가지 매쳐를 제공합니다.

  1. 함수로 오는 경우는 그 함수에 확인할 값을 인자로 주고 결과로 참 거짓을 받아 판단합니다.
  2. 기본값은 당연히 값과 직접 동가비교를 합니다.
  3. 객체인 경우는 다양한 비교법이 있겠지만 일단은 해체를 염두해두어 entries로 비교하기로 했습니다.

이제 위 클래스를 바탕으로 간단한 함수 유틸들과 함께 팩토리 함수를 작성하면 됩니다.

const matcher =(()=>{
  const Matcher = class{..상동..}
  const matcher =(...arg)=>new Matcher(arg);
  //건너뛰기 연산자
  const _ = matcher._ = Symbol();
  //형비교
  matcher.is =(...cls)=>v=>cls.some(c=>v instanceof c);
  //범위비교
  matcher.range =(a, b)=>v=>a < v && v < b;
  //튜플처리기
  const Tuple = class extends Array{constructor(v){super(...v);}};
  matcher.tuple =(...v)=>new Tuple(v);
  return matcher;
})();

본격적으로 사용하기top

우선 일반적인 배열 매칭부터 해볼까요. _로 건너뛰기를 제공하고 있으니 다양한게 매칭시킬 수 있을 것입니다.

//배열 매칭
const {_} = matcher;
const target = [1,2,3,4,5];
matcher(target)
.caseOne([_,2,_,3], _=>console.log("case1"));
.caseOne([_,2], _=>console.log("case2"));

_를 활용하여 다양한 형태로 매칭 패턴을 만들 수 있게 되었습니다. 객체 패턴은 이미 위에서 많이 나왔죠.

//Map 매칭을 위해 is와 커스텀함수 동원
const {is} = matcher;
const target = new Map([['a', 3]]);
matcher(target)
.caseOne({a:6}, _=>console.log("case1"))
.caseAnd(is(Map), v=>v.get("a") === 3, _=>console.log("case2"));
.default(_=>console.log("case3");

결론top

간결한 화살표 함수 덕에 커스텀으로 matcher를 만들어도 충분한 표현력이 있네요 ^^
50줄도 되지 않는 전체 코드는 다음과 같습니다. 각 언어별로 도움을 주신 승철님, 케빈님께 감사를 드립니다.

const matcher =(()=>{
  const Tuple = class extends Array{constructor(v){super(...v);}};
  const Matcher = class{
    constructor(values){this._values = values;}
    _matched(v){
      this._isMatched = true;
      this._result = v;
    }
    _match(val, cond){
      switch(true){
      case typeof cond == 'function': return cond(val);
      case typeof cond != 'object' || cond === null: return val === cond;
      default: return Object.entries(cond).every(([k, v])=>v === _ || this._match(val[k], v));
      }
    }
    _case(cases, mode){
      if(this._isEnd) throw 'ended';
      if(this._isMatched) return this;
      const f = cases.pop(), val = this._values;
      if(cases[mode](c=>c instanceof Tuple ? 
        c.every((c, i)=>this._match(val[i], c)) :
        this._match(val[0], c)
      )) this._matched(f());
      return this;
    }
    caseOne(v, f){return this._case([v, f], 'every');}
    caseAnd(...arg){return this._case(arg, 'every');}
    caseOr(...arg){return this._case(arg, 'some');}
    default(f){
      if(this._isEnd) throw 'ended';
      if(!this._isMatched) this._matched(f());
      this._isEnd = true;
      return this;
    }
    get result(){return this._result;}
  };
  const matcher =(...arg)=>new Matcher(arg);
  const _ = matcher._ = Symbol();
  matcher.is =(...cls)=>v=>cls.some(c=>v instanceof c);
  matcher.range =(a, b)=>v=>a < v && v < b;
  matcher.tuple =(...v)=>new Tuple(v);
  return matcher;
})();

P.S
그 이후에도 여러가지 고민을 많이 하게 되었습니다.

  1. caseOne, caseAnd, caseOr : 이건 사실 처음보는 분들을 위한 설명용이라 case로 통일하여 and모드를 돌리고(보통 그러니까) caseOr만 남겨두는 걸로.
  2. Map 또는 WeakMap 검사 : 이게 오면 편하게 오브젝트로 비교할 수 있게 해주었습니다. match(map).case({a:3}, _=>’ok’); 이런 식으로 대상은 map이지만 그냥 obj로 평가할 수 있게..
  3. Set 또는 WeakSet 검사 : 마찬가지 값이나 배열로 보내면 포함되어있는지 찾을 수 있게 해주었습니다. match(set).case([3, a], _=>’ok’);
  4. _match는 사실 메소드일 필요가 없어서 자유변수쪽으로 빼버렸습니다.

반영된 코드는 다음과 같습니다. 기능은 늘었지만 40줄 정도로..

const mat =(()=>{
  const Tuple = class extends Array{constructor(v){super(...v);}};
  const matcher =(...arg)=>new Matcher(arg);
  const is = matcher.is =(...cls)=>v=>cls.some(c=>v instanceof c);
  const isTuple = is(Tuple), isMap = is(Map, WeakMap), isSet = is(Set, WeakSet);
  const _ = matcher._ = Symbol();
  matcher.range =(a, b)=>v=>a < v && v < b;
  matcher.tuple =(...v)=>new Tuple(v);
  
  const match =(val, cond)=>
    typeof cond == 'function' ? cond(val) :
    typeof cond != 'object' || cond === null ? val === cond :
    isMap(val) ? Object.entries(cond).every(([k, v])=>v === _ ? val.has(k) : match(val.get(k), v)) :
    isSet(val) ? Array.isArray(cond) ? cond.every(v=>val.has(v)) : val.has(cond) :
    Object.entries(cond).every(([k, v])=>v === _ || match(val[k], v));
  const Matcher = class{
	  constructor(values){this._values = values;}
	  _matched(v){
      this._isMatched = true;
      this._result = v;
	  }	  
	  _case(cases, mode){
      if(this._isEnd) throw 'ended';
      if(this._isMatched) return this;
      const f = cases.pop(), val = this._values;
      if(cases[mode](c=>isTuple(c) ? c.every((c, i)=>match(val[i], c)) : match(val[0], c))) this._matched(f());
      return this;
	  }
    case(...arg){return this._case(arg, 'every');}
	  caseOr(...arg){return this._case(arg, 'some');}
	  default(f){
      if(this._isEnd) throw 'ended';
      if(!this._isMatched) this._matched(f());
      this._isEnd = true;
      return this;
	  }
	  get result(){return this._result;}
  };
  return matcher;
})();