본문 바로가기

javascript/typescript

[typescript] 오버로딩

요점

  • 타입스크립트에는 C++, Java 등 정적 타입 언어에 대응되는 함수 본문에 대한 오버로딩은 존재하지 않는다.
  • 대신 함수에 대한 오버로드 시그니처(콜 시그니처)를 기반으로 오버로딩인 것 "처럼" 만들 수는 있다.

 메서드 오버로딩

 오버로딩은 이름은 같지만 입력 파라미터 타입 + 출력 형식이 다른 여러 개의 메서드를 중복 정의하는 것을 의미한다. 오버로딩이 성립하기 위해서는 다음과 같은 조건을 만족해야 한다.

  1. 메서드의 이름이 같아야 한다. 애초에 이름이 같지 않으면 오버로딩이 아니다.
  2. 메서드의 입력 파라미터의 타입 또는 형식이 반드시 달라야 한다.
  3. 출력 형식은 같든 다르든 입력 파라미터 형식만 다르다면 상관없다.

예를 들어, 아래 코드는 오버로딩에 해당한다.

int add(int a, int b) {
	return a + b;
}

int add(int a, int b, int c) {
    return a + b + c;
}

float add(float a, float b) {
    return a + b;
}

반면 아래 코드는 오버로딩이 아니다.

int getNumber();
float getNumber();

 오버로딩은 동일한 이름 + 다른 파라미터 타입 / 형식을 통해 구현된다.

자바스크립트의 경우

 타입스크립트의 모체가 되는 자바스크립트에는 타 언어에 대응되는 오버로딩의 개념이 직접적으로 존재하지는 않는다. 대신 인자의 개수나 typeof 연산자를 이용하여 하나의 함수 내부에서 이들을 구분하는 방식으로 구현할 수 있다.

express.js을 통해 살펴보는 자바스크립트 방식의 오버로딩

 이러한 모습을 잘 볼 수 있는 라이브러리 중 하나가 express.js이다. express.js의 use는 자바스크립트 방식의 오버로딩이 어떻게 구성되는지 잘 보여준다. use의 경우 첫 번째 파라미터의 타입이 무엇인지에 따라 다른 동작을 수행한다.

  1. 첫 번째 인자가 문자열인 경우
  2. 첫번째 인자가 함수인 경우
  3. 첫번째 인자가 배열인 경우
// 01. 첫 인자가 문자열
server.use('/', (req,res,next) => {
    res.send("hello,world!")
})

// 02. 첫 인자가 함수
server.use(e.urlencoded({extended: true}));

// 03. 첫 인자가 배열. 모든 경로에 대응됨
server.use(['/test1','/test2','/test3'],(req,res,next) => {
    res.send("test!");
} )

  express에서는 여러 개의 경로나 여러 개의 미들웨어를 하나의 배열에 넣어 관리할 수 있다(경로 따로 미들웨어 따로).
 경로가 배열에 들어가는 경우 해당 배열 내의 모든 경로에 대응하며, 미들웨어가 여러개 들어가는 경우 조건이 만족될 때 배열 내에 정의된 모든 미들웨어들을 순차적으로 실행한다. 이러한 기능은 재사용성 때문에 존재한다고 한다.

 아무튼 express의 use 함수가 어떻게 오버로딩을 구현하는지 보자.

 

use 코드: https://github.com/expressjs/express/blob/master/lib/router/index.js#L439

proto.use = function use(fn) {
  var offset = 0;
  var path = '/';

  // default path to '/'
  // disambiguate router.use([fn])
  if (typeof fn !== 'function') {
    var arg = fn;

    while (Array.isArray(arg) && arg.length !== 0) {
      arg = arg[0];
    }

    // first arg is the path
    if (typeof arg !== 'function') {
      offset = 1;
      path = fn;
    }
  }
  ...

  use 함수는 첫 인자로 fn을 받는다. 해당 인자를 분석하여 어디부터 미들웨어에 속하는지 평가하게 된다.

  • if ( typeof fn!== 'function' ) : 함수 여부를 판단한다. 문자열과 배열인 경우가 남는다.
  • while(Array.isArray(arg) && arg.length!== 0 ) : 배열인 경우 내부 문자열을 찾거나 텅 빌 때까지 인덱스 [0]을 뒤진다.
  • if ( typeof arg!== 'function') : 미들웨어 기반 배열인지 검사한다

 미들웨어를 설정하는 부분에서는 최신 자바스크립트 문법에서는 별로 사용되지 않는 arguments 속성이 이용된다. 현재 use 함수의 입력 파라미터는 fn 하나밖에 존재하지 않는다.

  es6 문법에서는 스프레드 연산자를 이용하여 인자가 여러 게임을 명시할 수 있지만, 과거에는 arguments 속성을 이용하여 여러 인자를 받았다. arguments 속성은 모든 인자를 포함하므로 fn 역시 arguments에 속한다. fn이 경로를 의미하는 경우를 구분하기 위해 offset 값을 조정하는 코드가 포함된다.  

  var callbacks = flatten(slice.call(arguments, offset));

  if (callbacks.length === 0) {
    throw new TypeError('Router.use() requires a middleware function')
  }

  for (var i = 0; i < callbacks.length; i++) {
    var fn = callbacks[i];

    if (typeof fn !== 'function') {
      throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
    }

    // add the middleware
    debug('use %o %s', path, fn.name || '<anonymous>')

    var layer = new Layer(path, {
      sensitive: this.caseSensitive,
      strict: false,
      end: false
    }, fn);

    layer.route = undefined;

    this.stack.push(layer);
  }

 이전 코드에서는 첫 번째 인자가 함수가 아닌 경우 offset을 1로 지정했다. callbacks 함수는 미들웨어들을 담은 배열로, slice 함수는 Array.prototype.slice이다. 배열로 담은 미들웨어들에 대해 각각 타입이 function이 맞는지 확인하고 Layer 객체를 생성하는 모습을 볼 수 있다.


 위 코드를 보면 알 수 있듯이 자바스크립트로도 타 언어의 오버로딩에 대응되는 기능을 구현할 수 있긴 하지만 딱 "기능"적으로 유사할 뿐, 흔히 말하는 오버로딩에 대응되지는 않는다. 

 예를 들어 C++, java와 같은 언어들은 컴파일 타임에 오버로딩 된 함수들을 컴파일러 수준에서 다른 이름으로 분리한다. 일반적으로 설명하는 오버로딩은 대부분 이러한 부류에 속하지만, 자바스크립트는 단순히 하나의 함수에서 조건문을 사용하여 여러 동작을 수행할 뿐이다. 언급한 정적 언어 기준으로 보면 단순히 하나의 함수 내에서 if문을 나열한 것이다.

 사실 이런 특징은 타입 평가를 런타임에 수행하는 동적 타입 언어들 모두가 공통적으로 가지고 있다. 동적 타입 언어들은 런타임에 시스템 추론 또는 코드 분기를 통해 각 객체 및 원시 값의 타입을 평가하며, 컴파일 타임에는 따로 타입을 명시하지 않는다. python의 type hint 나 typescript 등의 방식으로 타입을 명시하며 코딩을 할 수는 있지만, 이건 편의성 측면의 도움일 뿐 언어 환경 자체가 정적으로 컴파일하도록 바꾸지는 않는다.

https://stackoverflow.com/questions/74637458/how-to-do-function-overloading-in-python

from typing import overload

@overload
def parse(query: None = None, data: None = None) -> None:
    ...

@overload
def parse(query: str, data: list[object]) -> None:
    ...

def parse(query: str | None = None, data: list[object] | None = None) -> None:
    if query is None and data is None:
        print("something")
    else:
        print(f"something else with {query=} and {data=}")


parse()            # something
parse("foo", [1])  # something else with query='foo' and data=[1]

  위 글에서 설명하는 이야기가 딱 맞는 것 같다. 파이썬 역시 type hint을 사용하는 경우 @overload 데코레이터를 통해 함수가 마치 오버로딩한 것 "처럼" 보이게 만들 수 있다. 그러나 이 것은 정적 타입 언어에서 기대하는, 컴파일 타임에 각 오버로딩 함수를 구분하여 코드로 생성하는 방식이 아니라, 단순히 함수에 대한 인자가 이런 구조를 가질 수 있다고 미리 언급해 두는 수준일 뿐이다. 파이썬도 자바스크립트와 마찬가지로 동적 타입 언어이며, 오버로딩 대신 오버로딩 시그니처를 구현하여 마치 여러 개의 함수를 둔 것처럼 보이게 만들 수 있다. 


타입스크립트의 오버로딩

 위 언급했듯이 타입스크립트의 모체가 되는 자바스크립트는 동적 타입 언어이므로 함수 오버로딩이라는 개념이 필요하지 않다. 다만 타입스크립트 자체는 정적 타입 언어인 것처럼 기능하기 때문에 오버로딩의 개념이 필요하다. express의 use 함수의 타입을 명시할 때 모든 상황을 any 타입으로 퉁치고, 사용자가 알아서 외워 사용해야만 한다면 타입스크립트는 오히려 타입을 명시하라고 빽빽 소리만 지르면서 개발 속도만 늦추는 짐덩이 었을 것이다.

 다행히도 타입스크립트에는 오버로딩 개념이 존재한다. 정확히 말하면 오버로딩 시그니처를 명시할 수 있다. 구현 자체는 자바스크립트에서 했던 것처럼 하나의 함수에서 몰아서 작성하되, 해당 함수에 대해 특정 파라미터와 반환형을 가지는 경우가 존재할 수 있다고 알려주는 것이다.

  • 오버로딩 시그니처: 서로 다른 방법으로 호출될 수 있다고 알려주는 부분
  • 구현 시그니처: 모든 구현 사항이 포함되는 함수. 오버로딩 시그니처가 존재하는 경우 구현 시그니처는 가려진다. 대응되는 모든 오버로딩 시그니처에 대응되는 입출력 파라미터를 가져야 하며, 하나만 존재한다.

 예를 들어 덧셈에 대한 오버로딩 함수 add를 number, string 및 bigint에 대해 작성해 보자. C++ 같은 정적 타입 언어에 기대하는 오버로딩은 각 타입에 대해 각각 구현한 함수 3개를 가지는 것이다.

function add(a: number, b: number): number {
    return a + b;
}

function add(a: string, b: string): string {
    return a + b;
}

function add(a: bigint, b: bigint): bigint {
    return a + b;
}

 위에서 계속 말했듯이, 위 코드는 성립하지 않는다. 타입스크립트는 하나의 구현 시그니처만을 가질 수 있다. 따라서 위 구현 사항들은 하나의 구현 시그니처로 통합되어야 한다.

function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: bigint, b: bigint): bigint;

function add(a: number|string|bigint, b: number|string|bigint):number|string|bigint {
    if(typeof a ==='number' && typeof b==='number') {
        return a + b;
    } else if (typeof a ==='string' && typeof b==='string') {
        return a + b;
    } else if (typeof a ==='bigint' && typeof b === 'bigint') {
        return a + b;
    } else {
        throw new Error("Attempting addition to different type variables");
    }
}

오버로딩 시그니처가 존재하는 함수의 경우 구현 시그니처는 자동으로 가려진다. 공식 문서 참고.

https://www.typescriptlang.org/docs/handbook/2/functions.html#overload-signatures-and-the-implementation-signature


추가

function wrongAdd<T extends number|string|bigint>(a: T, b: T): T {
    return a + b;
}

 위 코드는 동작하지 않는다. 처음 봤을 때는 혹시 동작할까 싶었지만 역시 에러가 발생한다. T는 number | string | bigint 유니온 타입을 상속하고 있으며, a와 b는 각각 T를 상속한다. 따라서 a와 b는 각각 number, string 또는 bigint 타입을 가질 수 있으므로 덧셈이 반드시 성립한다는 보장을 할 수 없다.

function wrongAdd(a: number | string | bigint, b: number | string | bigint): number|string|bigint {
    if (typeof a === typeof b) {
        return a + b;
    } else {
        throw new Error("type not same");
    }
}

  위 코드도 아직은 안된다. a와 b의 타입이 같다는 보장이 있으므로 정상적인 결과가 나올 것으로 기대했지만, 현재는 에러가 발생한다. 논리적으로는 a의 타입과 b의 타입이 동일한 경우 number + number, string + string 또는 bigint + bigint라고 추론할 수 있지만, 아직 typescript 컴파일러는 그 정도까지 추론해주지는 않는 것 같다.

function okAdd(a: number | string | bigint, b: number | string | bigint): number|string|bigint {
    if (typeof a === 'number' && typeof b ==='number') {
        return a + b;
    } else if  (typeof a === 'string' && typeof b ==='string') {
        return a + b;
    } else if (typeof a ==='bigint' && typeof b ==='bigint') {
        return a + b;
    }
    throw new Error("cannot reach!");
}

  코드를 위와 같이 수정하면 동작한다. 정확하게 a와 b의 타입을 각각 typeof 연산자를 이용하여 narrowing 해줘야 정상적으로 동작하는 모양이다. 참고로 이전 wrongAdd 내부에서 a와 b 둘 중 하나의 타입만 다시 narrowing 하는 것은 이 글을 쓰는 시점에서는 의미가 없다. 둘 다 동일한 타입에 대해 처리해 줘야 동작한다.