ReScript in Korean

자바스크립트 함수에 바인딩하기

원문

자바스크립트 함수를 바인딩 하는 것은 다른 어떤 값을 바인딩하는 것과 같습니다:

/* nodejs의 path.dirname 을 가져옵니다. */
@bs.module("path") external dirname: string => string = "dirname"
let root = dirname("/User/github") // returns "User"
var Path = require('path');
var root = Path.dirname('/User/github');

아래에 특별한 다른 기능을 공개합니다.

이름이 있는 인자

ReScript는 이름이 있는 인자 (물론 옵셔널입니다) 를 가지고 있습니다. external 에서도 동작합니다! 자바스크립트 함수의 명확하지 않은 사용법을 수정하는데 사용됩니다. 아래 내용을 모델링한다고 가정해봅시다:

// MyGame.js
function draw(x, y, border) {
// `border` 는 옵셔널이고 기본값은 false 라고 가정합니다.
}
draw(10, 20);
draw(20, 20, true);

ReScript 코드에서 draw 에 이름을 지정하고 호출할 수 있다면 좋을 것입니다.

@module("MyGame")
external draw: (~x: int, ~y: int, ~border: bool=?, unit) => unit = "draw"
draw(~x=10, ~y=20, ~border=true, ())
draw(~x=10, ~y=20, ())
var MyGame = require('MyGame');
MyGame.draw(10, 20, true);
MyGame.draw(10, 20, undefined);

같은 함수로 컴파일 되었지만, ReScript 코드에서는 인자에 이름을 넣었기 때문에 더욱 명확해졌습니다!

참고: 마지막 인자가 옵셔널인 border 와 같은 특수한 경우에는 바로 뒤에 유닛(unit) () 이 필요합니다. 함수가 완료되었음을 알려주는 유닛이 없다면 경고를 냅니다.

ReScript 코드에서 이름이 있는 인수는 순서를 자유롭게 변경할 수 있습니다. 자바스크립트 결과는 항상 올바른 순서로 표시됩니다.

@bs.module("MyGame")
external draw: (~x: int, ~y: int, ~border: bool=?, unit) => unit = "draw"
draw(~x=10, ~y=20, ())
draw(~y=20, ~x=10, ())
var MyGame = require('MyGame');
MyGame.draw(10, 20, undefined);
MyGame.draw(10, 20, undefined);

객체 메소드

자바스크립트 모듈을 제외한 자바스크립트 객체는 send 를 사용하는 특별한 바인딩 방법이 필요합니다.

type document // document 객체를 위한 추상 타입
@send external getElementById: (document, string) => Dom.element = "getElementById"
@val external doc: document = "document"
let el = getElementById(doc, "myId")
var el = document.getElementById('myId');

send를 사용하는 경우 객체는 언제나 첫번째 인자입니다. 메소드의 실제 인자는 다음과 같습니다. (this is a bit what modern OOP objects are really)

체이닝

자바스크립트 OOP 에서 foo().bar().baz() 와 같은 체이닝 ("흐름을 가지는 API") 를 써본적 있나요? ReScript 도 물론 파이프 오퍼레이터를 이용해서 똑같이 할 수 있습니다.

가변 함수 인자

임의의 한개 이상의 인자를 가진 자바스크립트 함수가 있다고 가정할 수 있습니다. ReScript는 임의의 인자들 모두가 같은 타입이라는 전제하에 이러한 모델링을 지원합니다. 필요한 경우에 variadic 키워드를 external 에 추가하세요.

@module("path") @variadic
external join: array<string> => string = "join"
let v = join(["a", "b"])
var Path = require('path');
var v = Path.join('a', 'b');

moduleImport from/Export to JS 에서 다루겠습니다.

폴리모픽 함수 모델링

위의 특수한 경우를 제외하고, 자바스크립트 함수의 인자는 일반적으로 인자의 갯수나 타입이 임의로 오버로드 되는 경우가 많습니다. 그러면 어떻게 바인딩 할 수 있을까요?

트릭 1: 여러개의 external 을 사용하세요

오버로드된 자바스크립트 함수가 취할 수 있는 많은 경우의 수를 철저히 열거할 수 있다면, 간단한 방법으로는 모두 각각 다르게 바인딩하세요:

@module("MyGame") external drawCat: unit => unit = "draw"
@module("MyGame") external drawDog: (~giveName: string) => unit = "draw"
@module("MyGame") external draw: (string, ~useRandomAnimal: bool) => unit = "draw"
// Empty output

세가지 external 이 모두 같은 자바스크립트 함수 draw 에 어떻게 바인딩 되었는지 확인하세요.

트릭 2: 폴리모픽 배리언트 + unwrap 을 사용하세요

"만약 자바스크립트의 함수 인자가 비공식적으로 string 또는 int 가 아닌 배리언트였다면" 이라고 말하고 싶은 충동이 들었다면, 좋은 소식이 있습니다. 우리는 인자에 주석을 달아 이러한 external 기능을 제공합니다. 폴리모픽 배리언트라고 합니다! 바인딩하려는 다음 자바스크립트 함수가 있다고 가정해봅시다:

function padLeft(value, padding) {
if (typeof padding === 'number') {
return Array(padding + 1).join(' ') + value;
}
if (typeof padding === 'string') {
return padding + value;
}
throw new Error(`Expected string or number, got '${padding}'.`);
}

이제 padding 은 실제로 개념적으로 배리언트가 되었습니다. 이렇게 모델링 해봅시다.

@val
external padLeft: (
string,
@unwrap [
| #Str(string)
| #Int(int)
])
=> string = "padLeft"
padLeft("Hello World", #Int(4))
padLeft("Hello World", #Str("Message from ReScript: "))
padLeft('Hello World', 4);
padLeft('Hello World', 'Message from ReScript: ');

명백하게, 자바스크립트는 폴리모픽 배리언트를 가질 수 없습니다! 우리는 이제 폴리모픽 배리언트의 타입 체크와 문법을 얻었습니다. 이것의 비밀은 @unwrap 어노테이션 때문입니다. 컴파일 타임에 배리언트 생성자를 제거하고 페이로드의 값만 출력합니다. 자바스크립트 결과를 확인하세요

더 나은 인자 제한 방법

Node의 fs.readFileSync 의 두번째 인자를 생각해보세요. 문자열을 인자로 사용할 것입니다. 그러나 실제로는 "ascii" 그리고 "utf8" 두가지의 정의된 집합을 사용합니다. 물론 문자열로 바인딩 할 수 있지만 폴리모픽 배리언트 + string 을 통해 우리의 사용법을 조금 더 정확하게 만들 수 있습니다:

@module("fs")
external readFileSync: (
~name: string,
@string [
| #utf8
| @as("ascii") #useAscii
],
) => string = "readFileSync"
readFileSync(~name="xx.txt", #useAscii)
var Fs = require('fs');
Fs.readFileSync('xx.txt', 'ascii');
  • 전체 폴리모픽 배리언트 타입에 @string 을 추가하면 생성자에서 같은 이름의 문자열로 컴파일합니다.
  • @as("bla") 를 생성자에 추가하면 최종 문자열을 사용자 정의할 수 있습니다.

이제 "myOwnUnicode" 또는 기타 배리언트 생성자 이름을 readFileSync 에 전달하면 오류가 발생합니다.

문자열 외에도 비슷한 방식으로 string 대신에 int 를 사용하여 인자를 int로 컴파일할 수 있습니다.

@val
external testIntType: (
@int [
| #onClosed
| @as(20) #onOpen
| #inBinary
])
=> int = "testIntType"
testIntType(#inBinary)
testIntType(21);

onClosed0 으로 컴파일되고, onOpen20 으로, 그리고 inBinary21 로 컴파일 됩니다.

특수한 경우: 이벤트 리스너

폴리모픽 배리언트의 마지막 트릭입니다:

type readline
@send
external on: (
readline,
@string [
| #close(unit => unit)
| #line(string => unit)
]
)
=> readline = "on"
let register = rl =>
rl
->on(#close(event => ()))
->on(#line(line => Js.log(line)));
function register(rl) {
return rl
.on("close", function($$event) {})
.on("line", function(line) {
console.log(line);
});
}

고정된 인자

때때로 인자의 기본값을 자바스크립트 함수에 전달하면서 external 을 사용하면 편리합니다.

@val
external processOnExit: (
@as("exit") _,
int => unit
) => unit = "process.on"
processOnExit(exitCode =>
Js.log("error code: " ++ Js.Int.toString(exitCode))
);
process.on('exit', function(exitCode) {
console.log('error code: ' + exitCode.toString());
});

@as("exit") 그리고 플레이스홀더 _ 인자는 첫번째 인자가 컴파일되면 "exit" 로 컴파일 되기를 원한다는 것을 나타냅니다. 그리고 as: @as(json`true`), @as(json`{"name": "John"}`), 등 과 같은 모든 JSON 리터럴을 사용할 수 있습니다.

커리 & 언커리

커리는 맛있는 인도 음식입니다. 더 중요한 것은 ReScript의 컨텍스트안에서 (그리고 일반적인 함수형 프로그래밍에서) 커리는 여러 인자를 취하는 함수가 모든 인자가 적용될 때 까지 한번에 여러개의 인자를 취할 수 있는 것을 말합니다.

중간에 있는 addFive 함수가 보이시나요? add 는 3개의 인자를 받을 수 있지만 단 한개만 받습니다. 인자 5 를 "커링" 하고 나중에 적용될 다음 두개의 인자를 기다리는 것으로 해석할 수 있습니다.

let add: (int, int, int) => int
let addFive: (int, int) => int
let twelve: int

(자바스크립트와 같은 동적 언어에서는 실수로 인자 전달을 하지 않더라도 오류가 발생하지 않기 때문에 커링을 사용하는 것이 위험할 수 있습니다.)

취약점

불행하게도 자바스크립트에는 앞서 언급한 이유 때문에 커링이 없어 ReScript 다중 인자 함수가 자바스크립트 함수에 100% 깔끔하게 매핑되는 것은 어렵습니다.

  1. 함수의 모든 인자가 제공되면 (aka 커링이 아님) ReScript는 컴파일을 위해 최선을 다합니다. 예 : 3개의 인자가 있는 일반적인 자바스크립트 호출에대한 3개 인자를 호출.

  2. 함수 애플리케이션이 완전한 것*을 판단하기 어렵다면 ReScript는 런타임 매커니즘(Curry 모듈)을 사용하여 가능한 한 많은 인자를 커리하고 결과가 완전히 적용되었는지 확인합니다.

  3. throttle, debounce 그리고 promise 와 같은 일부 자바스크립트 API는 컨텍스트를 엉망으로 만들 가능성이 있습니다. bind 매커니즘이나 this 를 수행하는 등의 작업을 말합니다. 이러한 구현은 이전의 커리 로직과 충돌합니다.

* 호출한 곳에서 3개의 인자를 가지고 있다면 커리되는 함수인지 아니면 원래 인자가 실제 3개뿐인지 알기 어려운 경우가 있습니다.

ReScript는 가능한한 #1 번의 내용을 시도합니다. #2의 커리 매커니즘을 사용하여도 일반적으로 무방합니다.

그러나 #3의 경우를 만난다면 휴리스틱은 충분히 좋지는 않습니다: 커리의 중간 단계 함수를 완전히 적용할 수 있는 확실한 방법이 필요합니다. 함수 선언 및 호출하는 곳에서 "언커리드 함수"를 이용해 이를 보장하는 방법을 제공하고 있습니다.

해결책: 보장된 언커링을 사용하세요

언커리드 함수 어노테이션은 external 을 사용하는 경우에서도 작동합니다:

type timerId
@val external setTimeout: ((. unit) => unit, int) => timerId = "setTimeout"
let id = setTimeout((.) => Js.log("hello"), 1000)
var id = setTimeout(function() {
console.log('hello');
}, 1000);

추가 해결책

위에서 제시한 해결책은 안전하고 보장되며 성능이 좋습니다. 그러나 때로는 시각적으로 부담스럽습니다. 다음과 같은 경우 대안을 제공합니다.

  • external 을 사용하고 있습니다.
  • external 함수가 다른 함수를 인자로 받습니다.
  • 사용자가 호출하는 곳에서 점(dot)과 함께 호출하는 것을 원하지 않습니다.

그러면 @uncurry 를 사용해보세요:

@send external map: (array<'a>, @uncurry ('a => 'b)) => array<'b> = "map"
map([1, 2, 3], x => x + 1)
// Empty output

일반적으로 uncurry 는 추천됩니다. 컴파일러는 컴파일 타임에 커링을 언커링으로 바꾸기 위한 많은 최적화를 합니다. 그러나 일부 경우에는 최적화를 할 수 없는 경우가 있습니다. 이러한 경우에는 런타임에 확인하는 방식으로 변경됩니다.

this 기반 콜백 모델링

많은 자바스크립트 라이브러리는 this에 의존하는 콜백이 있습니다. 예를 들면:

x.onload = function(v) {
console.log(this.response + v);
};

여기서 thisx 를 가리킵니다. (실제로 onload 가 어떻게 호출 되는지에 따르지만 우선은 넘어갑시다). (. unit) -> unit 타입인 x.onload 를 선언하는 것은 올바르지 않습니다. 대신, x를 다음과 같이 입력할 수 있는 특수한 속성인 this 를 도입했습니다.

type x
@val external x: x = "x"
@set external setOnload: (x, @this ((x, int) => unit)) => unit = "onload"
@get external resp: x => int = "response"
setOnload(x, @this ((o, v) => Js.log(resp(o) + v)))
x.onload = function(v) {
var o = this;
console.log((o.response + v) | 0);
};

this는 첫 번째 인자로 항상 this를 가지며, 인자가 없는 함수일 경우 unit 타입을 표시할 필요는 없습니다.

함수의 Nullable 반환 값 래핑

undefined 또는 null을 반환하는 자바스크립트 함수를 위해 @return(...) 을 제공합니다. 해당 값을 option 타입으로 자동으로 변환하려면 (ReScript option 타입의 None 값은 null이 아닌 undefined 로만 컴파일됩니다.)

type element
type dom
@send @return(nullable)
external getElementById: (dom, string) => option<element> = "getElementById"
let test = dom => {
let elem = dom->(getElementById("haha"))
switch (elem) {
| None => 1
| Some(_ui) => 2
}
}
function test(dom) {
var elem = dom.getElementById("haha");
if (elem == null) {
return 1;
} else {
console.log(elem);
return 2;
}
}

return(nullable) 속성은 자동으로 nullundefinedoption 타입으로 바꾸어줍니다.

현재 4개의 지시문을 제공합니다. null_to_opt, undefined_to_opt, nullable 그리고 identity.

identity는 컴파일러가 반환된 값에 대해 아무것도 하지 않아도 되도록 합니다. 거의 사용하지는 않지만 디버깅 목적으로 소개합니다.