2019-07-14

[TypeScript] Promise.all에는 인자 개수 제한이 없다

트위터에서 이상한 글을 봤다. TypeScript가 선언한 Promise.alliterable을 받지 않고 인자 개수 제한이 있다는 것이다. 근데 그럴 리가. TypeScript쯤 되는 프로젝트에서 무언가가 스펙과 다른 것으로 보인다면, 코드를 잘못 이해했을 확률이 높다. 코드를 다시 확인해보자.

당연히 iterable을 인자로 받는 Promise.all 선언도 있다. 다만, 이 선언이 es2015.promise.d.ts가 아닌 es2015.iterable.d.ts에 있을 뿐이다. 타입스크립트 컴파일러 옵션 중 --lib 으로 사용할 라이브러리를 지정할 수 있다. 대부분 es2015, es2018 같은 식으로 버전을 지정하여 포함하는 것이 일반적이라 잘 알려지지 않았지만, es2015.symbolSymbol에 대한 선언만 포함하거나, es2015.promisePromise에 대한 선언만 포함하는 것이 가능하다. 각 라이브러리는 서로 간에 의존성이 없도록 작성돼 있으며, 특히 iterable에 관련된 선언은 전부 es2015.iterable에서 선언한다. 예를 들어 Map이나 Setes2015.collection에 선언돼 있지만, iterable을 반환하는 entries, values, keys 등의 선언은 전부 es2015.iterable에 선언돼 있다. 마찬가지로 iterable을 인자로 받는 Promise.all 함수도 es2015.iterable에 선언돼있다.

iterable을 받는 Promise.all 함수 선언은 es2015.iterable에 선언돼 있는데, es2015.promise에 10개가 더 선언돼있다. 이들은 무엇일까? 사실 이 함수들은 배열을 인자로 받는 함수가 아니라 tuple을 인자로 받는 함수 선언이다. JavaScript에는 임의 개수의 원소를 가지는 배열은 있지만, 고정된 개수의 원소를 가지는 tuple은 없다. 그래서 tuple이 필요할 때 배열을 사용한다. 이를 TypeScript에서는 별도의 타입으로 표시할 수 있도록 했다. T라는 타입의 배열은 T[]로 표시하고, T1, T2, T3라는 타입의 원소 3개를 가지는 tuple은 [T1, T2, T3]로 표현하는 식이다. 즉, es2015.iterable에는 iterable을 인자로 받는 Promise.all을 선언하고, es2015.promise에는 tuple을 인자로 받는 함수를 선언한 것이다.

그렇다면 왜 길이가 11개 이상인 tuple을 인자로 받고 싶을 때는 어떻게 해야 할까? 11개 짜리 tuple을 받는 함수는 선언되지 않았기 때문에 iterable로 해석하여 결과 값을 tuple이 아닌 배열로 돌려준다. 반환되는 배열의 타입은 tuple 각 원소의 union type이다. 다시 말해서 타입 정보를 잃는다. 강제로 타입 변환을 하거나 타입 가드를 사용하면 되지만 이는 코드를 verbose하게 만든다.

그럴 때는 그냥 11개 이상인 tuple을 인자로 받는 함수를 선언해서 사용하면 된다. Promise의 전역 함수 all을 선언하는 것이 아닌 PromiseConstructor의 함수 all을 선언하는 것만 기억하면 된다. Promise.race도 같은 방식으로 확장할 수 있다.

하지만 사용할 수 있는 tuple의 크기를 확장하기 전에 한 번 더 고민해봐야 한다. tuple은 각 원소가 무엇을 의미하는지 코드에 명시적으로 드러나지 않고 숨겨져 있다. 이런 암시적인 코드는 수정 시 놓치기 쉽고, 버그의 원인이 된다. 개인적으로는 10개짜리 tuple도 크다고 생각한다. tuple의 크기는 3개만 넘어가도 다른 표현법이 없는지 찾아봐야 한다. 하지만 절대적인 것은 아니다. tuple로밖에 처리하지 못할 때에는 새 선언을 이용해 함수를 확장해서 사용하면 된다.

2019-05-12

[Rust] 타입 변환하기

Rust에서 타입 변환은 특별한 것이 아니다. 그저 단순히 하나의 값을 소유권을 받아 다른 타입의 값을 반환하는 함수다. 따라서 자신이 원하는 이름으로 아무 함수나 만들면 된다. 하지만 가독성을 위해 as_, to_, into_를 prefix로 사용하는 method를 만들거나, from_가 prefix로 붙는 생성자를 만들어 converting constructor처럼 만들어 사용한다.

From

타입 변환을 보다 일반적으로 구현하고 싶으면 From 트레잇을 구현하면 된다. 예를 들어 A라는 타입을 B라는 타입으로 변환하고 싶을 때는, B 타입에 From<A>를 구현하는 것이다. From 트레잇은 from이라는 associated function을 제공하기 때문에 A타입의 변수 aB타입으로 변환시킬 경우 From::from(a)이라는 식으로 사용한다. 만약, 컴파일러가 타입 추론을 못 해주는 경우 변환할 타입을 명시적으로 적어 B::from(a)이라는 식으로 사용하면 된다.

Into

From 트레잇은 변환될 타입의 associated function을 제공한다. 덕분에 변환될 타입을 명시할 수 있다. 이는 From 트레잇의 장점이지만, 구체적인 타입을 많이 적을수록 코드의 범용성을 떨어뜨린다. 범용성을 떨어뜨리지 않기 위해 From::from(a)와 같은 식으로 사용할 수도 있지만, from은 associated function이기 때문에 a.from() 같은 식으로 호출할 수는 없다. 이 경우 Into 트레잇이 유용하게 사용된다.

From 트레잇이 변환될 타입의 associated function을 제공하는 반면, Into into라는 method를 제공한다. 따라서 A 타입에서 B로 가는 Into 트레잇을 구현하고 있으면, A 타입인 변수 a가 있을 때, a.into() 같은 식으로 변환 함수를 호출할 수 있다. 물론, A::into(a)Into::into(a) 같은 식으로 호출하는 것이 문법적으로 틀린 것은 아니지만, 굳이 이렇게 의미 없이 긴 코드를 사용할 이유는 없다.

실패할 수 있는 타입 변환

위에서 말 한 From이나 Into는 실패하지 않는 타입 변환을 일반화하기 위한 트레잇이다. 하지만 어떤 변환의 경우 실패할 수도 있다. 이런 경우 TryFromTryInto를 사용해야 한다. 예를 들면 i128i32로 변환하는 것 같이 더 작은 타입으로 변환하는 함수는 실패할 수 있다. 이런 변환은 실패하면 TryFromIntError를 반환하는 TryFrom을 통해 구현된다.

TryFromTryInto의 관계는 FromInto의 관계와 같다. TryFromtry_from이라는 associated function을 제공하고, TryIntotry_into라는 method를 제공한다.

FromInto의 관계

FromInto는 method를 제공하는가 associated function을 제공하는가의 차이일 뿐 둘 사이의 동작이 다르면 안 된다. 사실 다른 동작을 구현할 수 없다. A 타입의 From<B>B 타입의 Into<A>를 같이 구현하려고 하면, 다음과 같은 에러가 나온다.

error[E0119]: conflicting implementations of trait `std::convert::Into<A>` for type `B`:
From을 구현하는 타입은 자동으로 Into도 구현하기 때문이다. 즉, 타입 변환 함수를 원하면 From만 구현하면 된다. Into를 구현하여 into 함수만 존재하는 타입을 만드는 것도 가능하지만, 이런 타입은 필요 없다. 사용자는 Into 트레잇을 구현하는 타입은, From 트레잇도 구현했을 것이라고 생각한다. 따라서 into 함수가 필요한 경우 From 트레잇을 구현하는 것이 좋다.

사실 Into를 구현해야 하는 케이스가 단 하나 있다. 만약 변환된 타입이 generic 타입이고, 현재 crate에서 선언된 타입이 아닐 때다. 이 경우 From을 구현하는 코드는 컴파일되지 않기 때문에, Into를 구현해야 한다. 이것이 유일하게 From이 아닌 Into를 구현해야 하는 경우다. 이는 TryFromTryInto에서도 마찬가지다.

2019-04-14

Garbage collection과 memory leak

Garbage collection(a.k.a. GC)은 프로그램이 더 이상 접근할 수 없는 메모리를 자동으로 해제시켜 주는 기술을 의미한다. John McCarthy가 Lisp에 처음 구현한 이후 많은 언어가 사용하여 현대 프로그래머 중에 모르는 사람이 없다고 해도 좋을 정도로 널리 알려진 개념이다. 근데 이 GC에 대해서 사람들이 자주 착각하는 것이 있다. GC를 사용하는 이유가 memory leak을 잡아주기 위해서라고 생각하는 것이다. 만약 이렇게 생각했다면 GC에 대해 큰 착각을 하는 것이다.

GC는 memory leak을 막지 못한다. 사실 튜링 완전한 언어에서 memory leak을 막아주는 방법은 세상에 존재할 수 없다. 이것을 설명하기 위해서는 우선 memory leak이 무엇인지 알아야 한다. Wikipedia는 memory leak을 다음과 같이 설명한다.

a type of resource leak that occurs when a computer program incorrectly manages memory allocations in such a way that memory which is no longer needed is not released.

쉽게 설명해 memory leak이란, 사용하지 않을 메모리를 해제하지 않는 현상이다. 결국 memory leak을 잡기 위해서는 사용하지 않는 메모리를 찾아내는 것이 먼저다. 그리고 사용하지 않는 메모리를 완벽하게 찾아내는 건 불가능하다. 자세한 설명은 아래 코드를 통해서 하도록 하겠다.

위 코드에서 x는 언제 해제해야 할까? 보통은 use(x)가 끝난 뒤 해제해야 한다고 생각할 것이다. 하지만 some_function이 종료되지 않는 함수라면 어떨까? some_function이 내부에서 무한 루프를 돌고 절대 종료되지 않는 함수라면 use(x)는 호출될 일이 없다. 따라서 x는 앞으로 접근할 일이 없는 메모리고, some_function이 실행되고 있는 동안 x를 해제하지 않고 있는 것은 memory leak이다. 따라서 이 함수가 memory leak이 없다고 하려면 some_function이 실행되기 전에 이 함수가 종료되는 함수인지 아닌지 분석해서 x의 메모리 해제 시점을 결정해야 한다. 어떤 함수를 실행하기 전에 종료되는지 알 수 있다는 것은 정지 문제를 풀 수 있다는 것이므로 모순이다. 따라서 실행 전에 some_function이 종료되는지 알 방법은 없다. 즉, memory leak을 완벽히 잡는 방법은 존재할 수 없다.

그래서 GC를 비롯한 메모리 관리 기술들은 전부 앞으로 사용하지 않는 메모리를 전부 해제하는 것을 보장하지 않는다. 대신 앞으로 사용하지 않는 것이 확실한 메모리만 해제하는 방식으로 동작한다. 즉, memory leak을 모두 잡는 대신 해제해도 안전한 메모리만 해제하는 것이다. 이를 조금 더 formal 하게 표현하면, '이 메모리를 해제해도 안전한가?'라는 질문에 completeness는 포기하지만, soundness를 보장하는 알고리즘으로 해제할 메모리를 찾는다고 할 수 있다.

모든 memory leak을 잡지는 못하지만, 해제해도 안전한 메모리만을 해제하는 것. 이것이 GC의 역할이다. GC를 사용하면 사용해야 할 메모리를 해제하지 않는다. 즉, dangling pointer가 생기지 않는다는 것이고, 이에 따라서 double free나 use-after-free 같은 문제가 생기지 않는다. 즉, GC는 메모리 사용의 효율성이 아닌, 소프트웨어의 안정성을 올리는 도구라는 것이다.

GC를 썼더니 결과적으로 memory leak이 줄었다는 일은 있을 수 있다. 하지만 memory leak을 잡기 위해 GC가 있는 언어를 선택한다거나, GC가 있는 언어를 쓴다고 memory leak이 없을 것이라 생각하면 안 된다. 아쉽게도 memory leak은 사람이 소스를 분석해서 잡는 수밖에 없다. GC를 비롯한 메모리 관리 시스템은 전부 memory leak을 잡기 위한 기술이 아니라 해제해도 안전한 메모리만 해제하기 위한 기술로써 사용해야 한다.

2019-04-06

managed language와 unmanaged language?

얼마 전 우연히 이런 글을 보게 됐다. 프로그래밍 언어를 managed language와 unmanaged language로 구분한 것인데 그 기준을 garbage collection(a.k.a. GC)을 하는가 아닌가로 잡았다. 난생처음 들어보는 기준이었다. 인생이 힘들어서 노느라 바쁜 사이 뭔가 새로운 논문이 나왔나 하고 찾아봤다.

역시나 이런 경우 대부분 그렇듯이 다른 나라에서는 안 쓰이고 다른 나라에서는 안 쓰이고 우리나라에서 작성된 블로그만 보였다. 그 블로그들이 공통으로 언급하는 것으로 보아 어떤 사람이 유튜브에서 처음 사용한 것 같다. 사실 다른 나라에서는 안 쓰이는 기준이라는 건 별로 중요하지 않다. 그보다 중요한 건 이 managed language라는 것이 잘못 붙여진 이름이라는 것이다.

일단 managed/unmanaged라는 용어 자체가 없는 것은 아니다. 이건 MS 진영에서 만든 용어다. MS는 managed language 대신 managed code라는 단어를 주로 쓰기는 했지만 말이다. MS에서 만든 managed code는 Common Language Runtime(a.k.a. CLR)에서 실행되는 코드를 의미한다. 반대로 CLR에 의존하지 않는 코드를 unmanaged code라고 부른다.

물론 CLR이 제공하는 기능에 GC가 포함되기 때문에 managed code는 GC을 사용한다. 하지만 GC에 초점을 맞추면 안 된다. 여기서 중요한 것은 virtual machine 위에서 코드가 실행된다는 것이다. virtual machine 위에서 돌기 때문에 컴퓨터 아키텍쳐에 따라 새로 컴파일할 필요 없다거나, 다른 머신에서도 같은 동작을 기대할 수 있다는 등 CLR의 장점에 주목해야 한다.

한때는 managed code를 생성할 것을 목표로 설계된 C#, Visual Basic 같은 언어를 managed language라고 부르기도 했지만, 요새는 잘 안 보인다. 안 쓰이게 된 이유는 모르겠다. 그저 C++을 이용해 CLR을 쓸 수 있게 해주는 C++/CLI 같은 프로젝트가 나오면서 구분할 필요가 없어진 것이 이유가 아닐까 추측할 뿐이다. 그렇다면 MS의 managed code는 둘째치고 GC를 사용하는가 아닌가를 기준으로 managed language라는 새로운 기준을 만들면 안 되는 것일까?

결론부터 말하면 오해를 줄 수 있기 때문에 안 된다. GC를 쓰는가 아닌가를 기준으로 언어를 나눌 수는 있다. 이건 의미 있는 기준이다. 하지만 그 기준으로 붙여진 이름이 managed language이면 안 된다. 이 이름은 메모리를 자동으로 관리하는 방법이 GC뿐이라는 오해를 주게 된다.

10년 전에는 GC를 사용하는 언어를 기준으로 managed/unmanaged를 나눠도 이상하지 않았을 것이다. 당시에는 메모리를 자동으로 관리하는 방법이 GC를 쓰는 것밖에 없었기 때문이다. 하지만 2002년 David Walker 교수가 정리한 linear type system이나 affine type system에 대한 개념을 기반으로 리소스를 안전하게 사용하는 방법에 대한 많은 연구가 있었고, 현대 프로그래밍 언어들은 이에 관한 개념을 적극적으로 반영하고 있다. 심지어 C++마저도 말이다. 특히 저 글에서 unmanaged로 구분하고 있는 Rust 같은 경우가 affine type system을 적극적으로 사용하여 메모리를 관리하는 대표적인 언어다. 즉, 이제는 GC가 메모리를 자동으로 관리하는 유일한 방법이 아니다.

분류는 새로운 개념을 쉽게 배우게 해주는 좋은 도구다. 분류라는 것은 추상적이고 많은 개체의 속성을 분명하고 외우기 쉽게 해준다. 하지만 이것은 어디까지나 기준이 명확하고 용어가 올바를 때의 얘기다. 잘못된 기준으로 분류하거나 잘못 도니 이름을 사용하게 되면 오히려 배움에 방해가 될 뿐이다.