본문 바로가기

프로그래밍/TypeScript

11. 제네릭

11. 제네릭

any로 타입을 느슨하게 만들 경우 유연하지만, 특정 타입으로 강제하지 않아 타입 안전성이 떨어진다. 
그렇다고 string 타입으로 지정하면, 유연성이 떨어진다. 
제네릭을 사용하여 유연함과 타입 안전성의 장점을 모두 취할 수 있다. 

- 제네릭의 장점
1. 타입 검사를 컴파일 시간에 진행하여 타입 안전성을 보장한다.
2. 캐스팅 관련 코드를 제거할 수 있다.
3. 제네릭을 이용하면 재사용이 가능한 코드를 만들 수 있다. 

-----------------------------------------------------------
function arrayConcat (array1: T[], array2: T[]): T[] {
    return array1.concat(array2);
}

let array1 = [1, 2, 3];
let array2 = [4, 5, 6];
let resultConcat = arrayConcat(array1, array2);
-----------------------------------------------------------

T는 타입(type)의 약자로 를 타입 매개변수(type parameter) 또는 제네릭 타입 변수(generic type variables)라고 한다. 

arrayConcat 함수의 매개변수에 있는 T 들은 타입 매개변수 를 기준으로 타입이 정해진다.

let resultConcat = arrayConcat(array1, array2);

위 코드에서 를 타입 인수(type argument)라고 하고, 타입 인수가 함수의 타입을 결정한다.
이렇게 제네릭 함수의 타입이 결정되는 과정을 타입 바인딩 (type binding) 이라고 한다. 


1. 타입 제약이 없는 타입 매개변수

-----------------------------------------------------------
function concat3 (strs: T, strs2: T) {
    console.log(typeof strs, strs);
    console.log(typeof strs2, strs2);
    return String(strs) + String(strs2);
}

concat3("abc", "123");          // 타입인수를 생략
concat3("def","456");   // 타입인수를 추가하여 명시적인 타입이 선언됨
-----------------------------------------------------------

concat3함수에 타입인수를 전달하면 함수가 불필요한 추론을 하지 않아도 됩니다. 

그런데, 전달하는 인수중 하나가 string 타입이고, 다른 하나가 number 타입이라면 
타입을 결정할 수 없어 에러가 발생합니다. 

문제는 T+T 연산이 되지 않는다는 것입니다. 
만약 캐스팅을 하지 않고 "return strs + strs2;" 로 연산한다면, 
타입이 정해지지 않은 타입 매개 변수간에 연산 (T+T)이 되므로 컴파일 에러가 발생합니다.

2. 바운드 타입 매개변수

타입 매개변수 T는 어떤 타입이든 받아들이기 때문에 타입을 몇가지로 제한해야할 때가 있습니다. 
예를 들어 string 타입으로 제한하려면 해당 타입을 상속받으면 됩니다. 




위와 같이 타입 매개변수가 특정 타입으로 묶였다면 해당 타입 매개변수 T를
바운드 타입 매개변수(bounded type parameters)라고 부릅니다.

-----------------------------------------------------------
function concat4(strs: T, strs2: T) {
    return strs + strs2;
}
-----------------------------------------------------------

하지만 바운드 타입 매개변수의 경우에도 T+T 연산을 할 수 없다는 오류가 나타납니다. 

3. 오버로드 함수를 이용한 타입 매개변수 간의 연산

오버로드 함수를 이용하면 T+T와 같은 타입 매개변수간에 연산이 가능합니다.
오버로드 함수는 제네릭 함수가 더 유연하게 동작해야 할 때 선언합니다. 

-----------------------------------------------------------
function concat5(strs: T, strs2: T): T;
function concat5(strs: any, strs2: any) {
    return strs + strs2;
}
console.log(concat5("abc", "123"));
-----------------------------------------------------------

위 예제에서 Concat 함수는 정상적으로 문자열 결합을 수행합니다. 
왜냐하면 any 타입이기 때문입니다. 
Concat5 함수 위에 오버로드 함수를 추가함으로써 제네릭 함수가 되도록 했고, 
any 타입으로 T+T 연산도 가능하게 했습니다. 
이때 두 매개변수는 똑같은 타입으로만 호출되어야 합니다. 
다른 타입으로 호출할 경우 에러가 발생합니다. 


- 타입 매개변수의 확장

1. 유니언 타입

제네릭에서 타입을 특정할 경우 제네릭의 유연성을 잃어버립니다. 
제네릭의 유연성을 잃지 않으면서 타입을 적당하게 제약할 때는 
타입 매개변수에 유니언 타입을 상속해 선언합니다. 

-----------------------------------------------------------
function concat6(strs: T, strs2: T): T;
function concat6(strs: any, strs2: any) {
    return strs + strs2;
}
console.log(concat6("abc", 123));
-----------------------------------------------------------

2. 타입 매개변수를 2개 이상 선언하기

매개변수의 타입이 여러개인 경우 타입 매개변수를 하나 더 추가해 선언합니다. 

-----------------------------------------------------------
let mapArr = [];
function put<T, T2>(strs: T, strs2: T2): T;
function put(idx: any, str: any) {
    mapArr[idx] = str;
}
function get<T, T2>(idx: T): T2;
function get(idx: any) {
    return mapArr[idx];
}
put<number, string>(1, "hello");
console.log(get<number, string>(1));
-----------------------------------------------------------

제네릭을 사용해 map 형태를 구현한 것이다.


11.3 제네릭 클래스와 인터페이스

자료구조나 알고리즘은 타입에 의존적이면 범용으로 사용할 수 없습니다.
자료구조나 알고리즘을 범용으로 사용하려면 제네릭을 적용해야 합니다.

1. 제네릭 클래스 (generic class)

클래스 명 뒤에 타입 매개변수 를 선언해 줍니다. 

-----------------------------------------------------------
class ArrayConvertor {
    elements: Array;
    constructor(elms: Array) {
        this.elements = elms;
    }
    array2String(): string {
        let text = "";
        for (let i = 0; i < this.elements.length; i++) {
            if (i > 0) {
                text += " ";
            }
            text += this.elements[i].toString();
        }
        return text;
    }
    getValue(elms: Array, index: number): T {
        return elms[index];
    }
}

let arr = [1, 2];
let numConvertor = new ArrayConvertor(arr);
console.log(numConvertor.array2String());
console.log(numConvertor.getValue(arr, 0));

let arr2 = new Array();
arr2.push("a");
arr2.push("b");

let stringConvertor = new ArrayConvertor(arr2);
console.log(stringConvertor.array2String());
console.log(stringConvertor.getValue(arr2, 0));
-----------------------------------------------------------

ArrayConvertor 클래스는 외부에서 전달된 타입 인수 T를 받아들여
클래스 내부에서 사용할 제네릭 타입으로 결정합니다. 

또한 배열의 인수는 여러 타입이 들어갈 수 있지만,
들어갈때 같은 타입으로 통일되어야 합니다. 


2. 타입 매개변수에 인터페이스를 상속하기 

(무슨 말인지 도무지 모르겠는데..)
제네릭 클래스에 다른 클래스를 매개변수로 전달할 경우
코드 어시스트를 받지 못할 때가 있습니다. 이유는 타입 매개변수 에 타입이 없기 때문입니다.
만약 코드 어시스트를 지원을 받고, 더욱 명시적으로 선언하려면
 처럼 클래스에 대한 인터페이스를 상속해주면 됩니다. 

- 특정 메소드만 제네릭 메소드로 사용하기

제네릭 클래스를 사용하면 클래스 전역에 걸쳐 타입 매개변수가 적용됩니다. 
만약 특정 메서드에만 제네릭을 적용하려면 해당 메소드를 제네릭 메소드로 선언하면 됩니다.

-----------------------------------------------------------
interface IName {
    name: string;
}

class Profile implements IName {
    name: string = "happy!";
}

class Accessor1 {
    getKey(obj: T) {
        return obj["name"];
    }
    getKey2(obj: T) {
        return obj["name"];
    } 
    getKey3(obj: T) {
        return obj["name"];
    }
    get(obj) {
        let objName = obj instanceof Profile ? obj.name : obj;
        return objName;
    }
}

let ac = new Accessor1();
console.log(ac.getKey(new Profile()));
console.log(ac.getKey2(new Profile()));
console.log(ac.getKey3(new Profile()));
console.log(ac.get(new Profile()));
console.log(ac.getKey(new Profile()));
-----------------------------------------------------------

getKey() 는 제네릭 메서드 이므로 호출될 때 타입 인수를 전달받습니다.
만약 제네릭 메소드에서 타입 인수를 생략하려면 getKey2() 처럼 
타입 매개변수 T가 IName을 상속받게 해야 합니다. 
get() 메서드는 느슨한 타입을 받고 있기 때문에 메서드 내부에 타입 가드를 추가해야 합니다. 

-----------------------------------------------------------
class Accessor2 {
    getKey(obj: T) {
        return obj.name;
    }
}

let ac2 = new Accessor2();
console.log(ac2.getKey(new Profile()));
-----------------------------------------------------------

Accessor1 과 달리 Accessor2 클래스는 제네릭 클래스로 선언했습니다. 
이때 타입 매개변수 T가 IName 인터페이스로 제약되므로 메서드 단위에서는 더이상 타입 제약을 할 필요가 없습니다. 

* 정리 *
메소드 단위로 제네릭을 적용하는 것은
특정 메서드에만 타입 인수를 전달할 수 있으므로 타입의 재활용성이 다소 떨어집니다.
하지만, 클래스 단위로 제네릭을 적용하면 
클래스 내에 존재하는 불특정 메소드에 대해 일괄적으로 제네릭을 적용할 수 있어 더 편리합니다 


11.4 제네릭의 여러 활용 방법

1. 룩업 타입을 제네릭 클래스에 적용

룩업(lookup) 타입 : keyof 로 속성을 포함하는 대상을 탐색해 유니언 타입처럼 동작함.

-----------------------------------------------------------
interface INumber {
    one: number;
    two: number;
    three: number;
}
type NumberKeys = keyof INumber; // "one" | "two" | "three"
let myNum: NumberKeys = "one";
-----------------------------------------------------------
룩업타입 예제.

-----------------------------------------------------------
function getValue<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}
let numbersKeys = { one : 1, two : 2, three : 3 };
console.log(getValue(numbersKeys, "one"));
-----------------------------------------------------------

" K extends keyof T "
타입 매개변수 K는 타입 매개변수 T에 의해 정해지는 룩업 타입이다.
따라서 타입 매개변수 K는 keyof를 이용해 타입 매개변수 T의 속성을 탐색해 하나의 속성만 허용하도록 제약한다. 


2. 인터페이스를 상속해 제네릭 확장하기

-----------------------------------------------------------
interface IFilter {
    unique(array: Array): Array;
}
class Filter implements IFilter {
    unique(array: Array): Array {
        return array.filter((v, i, array) => array.indexOf(v) === i);
    }
}

let myFilter = new Filter();
let resultFilter = myFilter.unique(["a", "b", "c", "a", "b"]);
console.log(resultFilter);
-----------------------------------------------------------

Filter 클래스는 제네릭 인터페이스에 맞춰 구현했습니다. 


3. 맵 객체의 선언과 타입 지정 방법

자바스크립트의 맵 객체는 타입을 지정할 수 없어 타입 안전성이 없었습니다.
반면 타입스크립트에서는 맵 객체에 타입을 지정할 수 있어 타입 안전성이 있습니다. 

-----------------------------------------------------------
let myMap = new Map();
myMap.set(1, "one");
myMap.set("2", "two");

// 내장 이터레이터와 for of를 이용해 맵 순회
for (let v of myMap) {
    console.log(v);
}
// 내장 이터레이터를 이용해 맵 순회
let mapIter = myMap[Symbol.iterator]();
console.log(mapIter.next().value);
console.log(mapIter.next().value);
-----------------------------------------------------------

위 예제는 타입 안전성이 없습니다.

- 맵 객체 사용시 타입을 지정하기

let list: Map<number, string> = new Map<number, string>();

맵 객체를 할당받을 변수에 
맵 객체 타입이 선언되어 있으면 
타입 안전성이 보장되므로 
아래와 같이 타입 인수를 생략할 수 있습니다. 

let list: Map<number, string> = new Map();

-----------------------------------------------------------
let list: Map<number, string> = new Map<number, string>();
list.set(1, "one");
list.set(2, "two");
list.set(3, "three");

console.log(list);

if (list.delete(2)) {
    console.log(list);
}

list.clear();
console.log(list);
-----------------------------------------------------------

4. 제네릭 기반의 자료구조 만들기

맵 객체는 내장 객체로 지원되지만, stack, queue, ArrayList 등과 같은 자료구조는 내장 객체로 지원되지 않습니다.
개발 중에 맴 이외에 다른 자료구조가 필요하다면 제네릭을 이용해 정의하면 좋습니다. 

ArrayList 는 제네릭 클래스로 타입 안전성이 보장되게 할 것입니다. 


'프로그래밍 > TypeScript' 카테고리의 다른 글

10. 타입선언과 변경, 호환  (0) 2019.10.23
09. 고급 타입  (0) 2019.10.16
08.모듈  (0) 2019.10.16
07.클래스와 인터페이스 기반의 다형성  (0) 2019.10.15
07. 클래스와 인터페이스  (0) 2019.10.15