Dart REPL 프로토타입(PoC)

Dart REPL 프로토타입(PoC)

Python의 인터랙티브 모드는 정말 훌륭해요. Dart에는 지금 인터랙티브 모드가 없지만, 아이디어를 빠르게 프로토타이핑하기에는 정말 좋은 언어라서, 직접 간단한 걸 만들어 보면 어떨까 싶었어요!

출처: A Dart REPL PoC

본문

고지: 저는 Google에서 일하지만, 이 글은 개인 프로젝트에 관한 것입니다. 저는 Dart 팀에 속해 있지도 않고 관련도 없어요. 이건 그저 제 소박한 의견과 제 이야기일 뿐입니다. 함께 가 보죠.

Python이나 Ruby 같은 언어의 인터랙티브 모드는 초보자에게 매우 친근하게 다가가는 데 큰 역할을 해 왔어요. 하지만 이 개념을 쓰는 건 이 언어들만이 아니에요. REPL은 read-eval-print 루프, 즉 읽고-평가하고-출력하는 루프예요. BASH나 zsh 같은 셸도 터미널에서 상호작용할 때 REPL을 사용해요. Jupyter(IPython Notebooks), Matlab, Mathematica, Maple에서 노트북으로 작업할 때도 바로 이 REPL을 쓰는 거예요. 이런 인터랙티브 컴퓨팅 방식은 연구자들에게 아주 인기가 많아요.

Python의 인터랙티브 모드는 이렇게 생겼어요:

[그림] 좋은 옛날 Python REPL

Dart는 Python처럼 전역 스코프(global scope)에서 문(statement)을 평가하는 것을 지원하지 않아서, 제대로 하려면 명확한 방법이 없어요. Dart에서는 문이 함수 안에 있어야 하고, main 함수가 프로그램의 진입점이에요. C++이나 C#처럼 말이죠. 개인적으로 저는 이 방식이 프로그램이 실행될 때 무슨 일이 벌어지는지 파악하기 쉬워서 좋아해요. 하지만… 그래도 인터랙티브 모드가 정말 갖고 싶어요. 아이디어를 이것저것 시험해 보고 훨씬 빠르게 시도할 수 있게 말이죠. 그럼 개념 증명(proof of concept)으로 REPL을 만들어 볼 수 있는지 확인해 봐요!

Dart에서 REPL을 어떻게 만들 수 있을까? (How can we create a REPL in dart?)

Dart는 프로토타이핑에 정말 좋아요. 그러니 바로 그걸 해 보면서, 언어 설계 질문에 얽매이지 말아요 :)

JavaScript나 Python에는 eval 함수가 있지만 Dart에는 없고, 직접 완전한 인터프리터를 작성해서 하나 구현하고 싶지도 않아요. 그건 빠르지도 않고 저도 시간이 많지 않거든요. 그런데 Intellij에서 Dart 코드를 디버그할 때, 코드를 단계별로 실행하면서 표현식을 평가해 볼 수 있어요. 표현식 평가가 바로 우리가 하고 싶은 일이잖아요, 그렇죠? 이 기능을 쓸 수 있을까요?

Dart의 디버깅 기능은 VM 서비스를 통해 노출돼요. 이건 Dart VM이 제공하는 JSON-RPC 서비스라서, 연결하면 애플리케이션을 디버그할 수 있어요. Natalie Weizenbaum 님이 VM 서비스와 상호작용할 수 있는 멋진 API를 제공하는 vm_service_client 패키지에 관한 글을 올린 적이 있어요: http://news.dartlang.org/2016/05/unboxing-packages-vmserviceclient.html

그럼 우리가 할 수 있는 건 이거예요: REPL이 자기 자신의 VM 서비스에 연결해서 터미널에서 읽은 표현식을 평가하는 거예요! 정말 말도 안 되는 것 같죠…

…하지만 실제로 동작해요! 저는 빠른 스파이크(spike)를 작성했고 정말 동작했어요. Dart는 비동기 프로그래밍을 지원하는데, 덕분에 자신의 VM 서비스와 대화할 때 프로그램이 스스로 블로킹되지 않게 유지할 수 있어 매우 유용해요.

스파이크(spike)는 테스트 주도 개발(TDD)에서 나온 개념이에요. 특정 기술적 질문을 파악하기 위해 작성하는 빠르고 대충 만든 실험이에요.

타당성(feasibility)이라는 질문이 해결되면, 제대로 된 개념 증명을 만드는 건 쉬워요. 1 + 1을 넘어서는 표현식을 평가하려면 변수를 지원해야 해요. 그런데 변수는 쉽게 만들 수 없어요. Dart에서 변수 선언은 표현식이 아니기 때문이에요. Python에서는 그냥 할당하면 변수를 즉석으로 선언할 수 있죠. Dart에서는 noSuchMethod를 오버로딩해서 동적 필드를 흉내 내어 딕셔너리에 요소를 즉석으로 만들 수 있어요. 저는 이 클래스를 Scope라고 이름 지었는데, 그 인스턴스 안에서 표현식을 평가하면 필드를 전역 변수처럼 접근할 수 있어요.

코드는 실제로 더 단순해요:

// Copyright (c) 2016, Andreas 'blackhc' Kirsch. All rights reserved. Use of
// this source code is governed by a BSD-style license that can be found in the
// LICENSE file.
import 'dart:mirrors';

Scope currentScope;

@proxy
class Scope {
  final _scope = <Symbol, dynamic>{};

  Scope();

  Scope.predefined(Map<Symbol, dynamic> symbols) {
    _scope.addAll(symbols);
  }

  factory Scope.clone(Scope other) => new Scope.predefined(other._scope);

  @override
  dynamic noSuchMethod(Invocation invocation) {
    if (invocation.isGetter) {
      if (_scope.containsKey(invocation.memberName)) {
        return _scope[invocation.memberName];
      } else {
        return super.noSuchMethod(invocation);
      }
    } else if (invocation.isSetter) {
      final variable = MirrorSystem.getSymbol(
          MirrorSystem.getName(invocation.memberName).split('=').first);
      _scope[variable] = invocation.positionalArguments.first;
      return null;
    } else if (invocation.isMethod) {
      return Function.apply(_scope[invocation.memberName] as Function,
          invocation.positionalArguments, invocation.namedArguments);
    } else {
      throw new UnsupportedError('Neither setter, nor getter, nor method!');
    }
  }

  @override
  String toString() => _scope.toString();
}

이렇게 하면 이미 a = 3이나 b = a*3 같은 것들을 할 수 있어요:

[그림] 간단한 표현식: DONE

곧 마주치는 한계가 하나 있어요. Scope 클래스를 선언한 파일에서 import한 심볼에만 접근할 수 있다는 점이에요. import '…';는 VM 서비스를 통해 평가할 수 없거든요. 그래서 명시적으로 import하지 않으면 dart:io도 쓸 수 없고, 사용자 정의 라이브러리도 쓸 수 없어요 :(

아, 잠깐! Dart는 URI로 Isolate.spawnUri를 사용해 새 isolate(독립 워커)를 생성할 수 있어요. 사용자가 커맨드라인에서 추가 import를 지정하면, REPL이 이 import들을 포함하는 소스 코드를 생성하고, 사용자가 사용할 수 있는 import가 준비된 코드로 새 isolate를 생성할 수 있게 할 수 있어요.

그리고 동작했어요 \o/

[그림] 사용자 정의 import: DONE

더 많은 Dart 지원하기 (Supporting more Dart)

이제 또 다른 문제는 표현식만 평가할 수 있다는 점이에요. if/else 블록이나 while 루프 같은 제어문은 표현식이 아니거든요. 문(statement)은 클로저로 감싸서 클로저를 실행하면 되는데, 함수 호출은 표현식이니까요. 그래서 if (a == 1) print('a is 1!!');은 이렇게 바뀌어요:

() { if (a == 1) print('a is 1!!'); }();

입력이 표현식인지 문인지 알아내기만 하면 돼요. 이건 어려운데, 이걸 위해 Dart 파서를 직접 작성해야 하기 때문이에요. 그런데 Dart는 Dart로 작성되어 있고, analyzer 패키지가 아무 Dart 코드나 파싱할 수 있는 파서를 공짜로 제공해 줘요!

// Copyright (c) 2016, Andreas 'blackhc' Kirsch. All rights reserved. Use of
// this source code is governed by a BSD-style license that can be found in the
// LICENSE file.
import 'dart:io';

import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/src/dart/scanner/reader.dart';
import 'package:analyzer/src/dart/scanner/scanner.dart';
import 'package:analyzer/src/generated/parser.dart';

bool _tryParse(String code, Function parse) {
  final reader = new CharSequenceReader(code);
  final errorListener = new BooleanErrorListener();
  final scanner = new Scanner(null, reader, errorListener);
  final token = scanner.tokenize();
  final parser = new Parser(null, errorListener);
  final node = parse(parser, token) as AstNode;

  return !errorListener.errorReported &&
      node != null &&
      node.endToken.next.type == TokenType.EOF;
}

bool isExpression(String code) => _tryParse(
    code, (Parser parser, Token token) => parser.parseExpression(token));

bool isStatements(String code) => _tryParse(code, (Parser parser, Token token) {
      final statements = parser.parseStatements(token);
      if (statements.isEmpty) {
        return null;
      }
      return statements.last;
    });

이렇게 해서 그 문제도 해결됐어요.

[그림] 문과 표현식: DONE

더 많은 import (More imports)

아무 라이브러리나 import하지 못하게 막는 마지막 장애물은, 기본적으로 새 Isolate가 그것의 pubspec.yaml에 언급된 패키지만 본다는 점이에요. 우리는 어떤 라이브러리든 import하는 것을 지원하고 싶어요. Isolate.spawnUri에는 packageConfig 파라미터가 있는데, 패키지 이름에서 패키지 경로로 가는 맵을 지정할 수 있게 해 줘요. 다른 커맨드라인 파라미터로 다른 패키지를 대상으로 지정하고, 우리 Isolate에서 그 패키지 구성을 사용하면 돼요. 우후!

곧 마주치는 문제는, 우리 Isolate가 analyzer 패키지(그리고 다른 것들)에 접근해야 하는데, 그것들이 REPL 세션에서 갖고 놀고 싶어 하는 패키지에 로드되지 않을 수도 있다는 거예요. package_resolver 패키지가 구원 투수예요! 이걸 쓰면 패키지 구성을 쉽게 조작할 수 있어요.

import 'package:package_resolver/package_resolver.dart';

Future<PackageResolver> mergePackageConfigs(String otherPackageRoot) async {
  final currentConfig = await PackageResolver.current.packageConfigMap;
  final otherConfig =
      (await SyncPackageResolver.loadConfig(otherPackageRoot + '/.packages'))
          .packageConfigMap;
  // We do something horrible here: We simply merge the current config with
  // the other. Hoping it will still work..
  final config = <String, Uri>{};
  config.addAll(currentConfig);
  config.addAll(otherConfig);
  return new PackageResolver.config(config);
}

이 모든 것을 갖추면 완전한 워크플로가 구현돼요:

[그림] 다른 패키지의 아무 라이브러리나 import: DONE

다음은 무엇인가요? (What's next?)

긴 글이었죠… 이 개념 증명 전체는 대략 451줄의 코드예요. 이 글만큼이나 긴 셈이에요.

코드는 https://github.com/BlackHC/dart_repl에서 찾을 수 있어요. Dart가 설치되어 있다면 쉽게 시도해 볼 수 있어요:

pub global activate dart_repl
pub global run dart_repl

저는 이 개념 증명을 여가 시간에 만드는 것을 정말 즐겼어요. 모든 조각이 몇 시간 안에 제자리에 맞춰졌거든요. IntelliJ의 Dart IDE 지원은 훌륭하고, 문서와 기사도 이제 아주 많아요. 예를 들어 Natalie Weizenbaum 님의 Unboxing Packages 시리즈를 확인해 보세요: http://news.dartlang.org/2016/04/unboxing-packages-async-part-3.html 등이 있어요. Dart에서 로우레벨 해킹은 재미있고, 창의적으로 쓸 수 있는 훌륭한 라이브러리가 많아요. code_builder는 아주 유망해 보이고, built_collection은 불변 컬렉션을 제공해요. David Morgan 님도 Dart의 불변 컬렉션에 관한 글을 계속 올리고 있어요.

dart_repl 관점에서 보면, 재시작 없이 런타임에 추가 라이브러리를 import할 수 있으면 좋겠어요. Dart 팀은 최근 VM에 핫 리로드(hot reload) 지원을 추가했어요. 이건 주로 모바일 앱 개발자를 위한 Flutter 경험을 개선해 줘요. 아마도 이것을 임시 import와 REPL에서 함수·클래스 정의에도 쓸 수 있을지도 몰라요.

전반적으로 Dart는 연구와 연구자에게 훌륭할 수 있고, 저는 Dart를 위한 Jupyter 지원을 정말 보고 싶어요. 그런 Dart REPL을 호환되게 만들려면 커널 인터페이스만 구현하면 되는데… :) 그건 쉽겠죠, 그렇죠?

더 알아보기