본문 바로가기
코드잇 스프린트 6기/TS_Study Together

[타입으로 견고하게 다형성으로 유연하게] 서브타입에 의한 다형성

by 학습하는 청년 2024. 5. 28.

최종 수정 : 2024-06-08

서브타입에 의한 다형성

이 책에서 소개하는 개념은 그저 이론적으로만 존재하는 것이 아니라 '실제 개발 과정'에서 널리 사용되고 있다.


1. 객체와 서브타입

서브타입에 의한 다형성(subtype polymorphism)은 객체를 다룰 때 유용하다. 객체라는 개념이 있는 정적 타입 언어라면 대개 서브타입에 의한 다형성도 제공한다. 이것은 객체를 사용할 때의 불편을 크게 줄여주는 '윤활유 같은 존재'다. 서브타입이라는 개념을 통해 다형성을 실현하므로, '서브타입'에 대한 이해가 필요하다.

 

Q. 서브타입이란 무엇인가?

서브타입은 "A는 B다"라는 타입 사이의 관계다. "A는 B다"라는 설명이 올바르다면 A는 B의 서브타입이고, B는 A의 슈퍼타입이다.

 

서브타입이 뭔지 이해했다면 서브타입에 의한 다형성은 쉽다. A가 B의 서브타입일 때 A타입의 부품을 B 타입의 부품으로도 간주할 수 있게 하는 기능이 서브타입에 의한 다형성이다. 그런데, 사람은 "A는 B다"가 옳다고 생각하는데 타입 검사기는 그렇게 판단하지 않는 경우가 있다. ... 그래도 다행히 타입 검사기는 '명확한 규칙'에 따라 서브타입 관계를 판단한다. 그 규칙만 파악한다면 타입 검사기가 A를 B의 서브타입으로 판단할지 아닐지 알 수 있다. 실제 코드를 작성할 때는 타입 검사기가 사용하는 규칙을 고려하여 서브타입 관계를 판단해야 한다.

 

Q. 타입 검사기가 사용하는 사용하는 규칙은 무엇인가?

두 종류의 방식이 있다. 둘을 병용하는 언어도 있고 둘 중 하나만 사용하는 언어도 있다. 그러므로 자신이 사용하는 언어가 무슨 방식으로 사용하는지 알아 두는 게 좋다.

1) 이름에 의한 서브타입(nominal subtyping) : 타입이 알려 주는 이름을 바탕으로 서브타입 관계를 판단한다.
2) 구조에 의한 서브타입(structural subtyping) : 타입이 알려 주는 구조를 바탕으로 서브타입 관계를 판단한다.

 

Q. 타입스크립트는 어디에 해당하는가?

둘 다 병용한다. 각각 상속과 선언병합이 이에 해당한다.

이름에 의한 서브타입

클래스 이름과 클래스 사이의 상속 관계'만' 고려한다. 즉 그 클래스에 어떤 필드와 메서드가 있는지는 전혀 신경 쓰지 않는다. 예를 들어, 클래스 A가 클래스 B를 상속한다면 A가 B의 서브타입이다. 여기서 상속은 직접 상속(direct inheritance)과 간접 상속(indirect inheritance)을 모두 포함한다.

 

간접 상속은 어떤 클래스를 상속하는 클래스를 다시 상속하는 상황을 말한다.

class SchoolObject { ... }
class Person extends SchoolObject { ... }
class Student extends Person { ... }

 


구조에 의한 서브타입

이미 정의된 클래스를 수정할 수 없는 경우에는 이름에 의한 서브타입만으로는 부족할 수 있다. 그럴 때는 구조에 의한 서브타입이 필요하다. 구조에 의한 서브타입을 사용하는 경우, 타입 검사기는 클래스 사이의 상속 관계 대신 클래스의 구조, 즉 각 클래스에 어떤 필드와 메서드가 있는지 고려한다. 클래스 A가 클래스 B에 정의된 필드와 메서드를 모두 정의한다면 A는 B의 서브타입이다.

 

구조에 의한 서브타입을 더욱 잘 활용하려면 '구조를 드러내는 타입(structural type)'에 대해 알아야 한다. 구조를 드러내는 타입은 타입에서부터 객체의 구조를 직접 드러낸다.

cf. 이름을 드러내는 타입(nominal type)

이름만 보여 주는 방식의 타입
class Person { email: string; }
class Student { email: string; grade: number; }
function sendEmail(person: Person): void {
  let email: string = person.email;
}
let st = new Student();
sendEmail(st);

Person 클래스에 정의된 모든 필드를 Student 클래스도 정의하므로 Student는 Person의 서브타입이다.

 

function sendEmail(person: { email: sring }): void {
  let email: string = person.email;
  ...
}
sendEmail(st);

Student 클래스가 string 타입의 필드 email을 정의하므로 Student는 { email: string }의 서브타입이다.


추상 메서드(abstract method)

필드가 각 객체가 가지고 있는 데이터라면, 메서드는 동작에 대한 기술이므로 각 객체가 제공하는 기능이다. ... 정적 타입 언어에서 메서드를 사용할 때는 '추상 메서드'라는 개념을 알면 편리하다.

 

Q. 추상 메서드는 무엇이며, 왜 필요한가?

정말로 메서드를 정의하지는 않되, '이 클래스를 상속하려면 특정 메서드를 반드시 정의해야 한다'는 사실을 표현하는 것이 추상 메서드의 용도다.

몸통 없이 이름, 매개변수 타입, 결과 타입만 작성된 메서드이며, 이 셋을 묶어 메서드의 시그니처(signature)라고 부른다. 메서드 시그니처는 타입 검사를 할 때 메서드의 서명 같은 존재로서 메서드의 특징을 알려 준다. 다시 말해, 타입 검사기가 원하는 정보인 메서드 이름, 매개변수 타입, 결과 타입을 제공하는 것이다.

추상 메서드를 가진 클래스는 객체를 직접 만들 수 없다. 오직 그 클래스를 상속해 새로운 클래스를 만들고 새로운 클래스로부터 객체를 만들 수 있을 뿐이다. 이렇게 클래스가 객체를 직접 만들 수 없다는 사실을 드러내는 키워드가 abstract이다. 추상 메서드를 가지는 대신 객체를 직접 만들 수 없는 클래스를 추상 클래스(abstract class)라 한다.

 

언어에 따라서 추상 클래스 대신 인터페이스(interface)나 트레이트(trait) 등의 용어를 사용하기도 한다. 용어도 다르고 때로는 기능도 조금씩 다르지만, 추상 멧허드를 가지는 대신 객체를 직접 만들 수 없다는 것은 모두 같다. 이처럼 추상 클래스를 만드는 목적은 '언제나' 메서드의 존재에 관한 정보를 타입 검사기에 제공하려는 것이다. 그 자체로 객체를 만들려는 게 아니다.

abstract class EmailDst {
  abstract sendEmail(content: string): void;
}
class Person {
  sendEmail(content: string): void { ... }
}
function sendNewYeatEmail(dst: EmailDst): void {
  dst.sendEmail(...);
}
let pr: Person = new Person();
sendNewYearEmail(pr);

구조에 의한 서브타입을 사용하므로 Person 클래스가 EmailDst 클래스를 상속하지 않아도 Person이 EmailDst의 서브타입이 된다.

function sendNewYearEmail(dst: { sendEmail: (content: string) => void }): void 
  { dst.sendEmail(...); }
sendNewYearEmail(pr);

2. 집합론적 타입

최대 타입(top type), 최소 타입(bottom type), 이거나 타입(union type), 이면서 타입(intersection type)은 서브타입에 의한 다형성을 바탕으로 만들어진 유용한 타입들이다.

  • 최대 타입 - 전체 집합
  • 최소 타입 - 공집합
  • 이거나 타입 - 합집합
  • 이면서 타입 - 교집합

최대 타입

최대 타입은 '가장 큰' 타입이다. 모든 값을 포함하는 타입이므로 어느 값이든 최대 타입에 속한다. 달리 말해, 모드 타입의 '슈퍼타입'이다.

let res: unknown = ... ? 1 : false;

타입스크립트에서 최대 타입의 이름은 'unknown'이다.


최소 타입

예외(exceoption)는 개발자가 throw를 의도적으로 발생시킬 수 있는 오류다. 일종의 사용자 정의 오류라고 볼 수 있다. 예외 처리기(exception handler)를 통해 예외를 처리함으로써 예외가 발생해도 실행을 이어 나가는 방법이 있지만, 일반적으로 예외로 인한 종료는 타입 오류로 분류되지 않는다. 다시 말해 타입 검사를 통과한 프로그램이더라도 예외가 발생해 실행이 갑작스럽게 중단될 수 있다.

 

최소 타입은 예외를 다루는 데 유용한 타입인데, 어떤 값도 속하지 않는 타입이다. 그렇기에 '가장 작은' 타입이라 할 수 있다. 달리 말하자면, 모든 타입의 서브타입'인 것이다. 어떤 값도 속하지 않는다는 점에서 Void와 같다고 느낄 수 있지만 최소 타입과는 전혀 다르다. Void는 함수가 계산을 끝낼 때 아무 값도 반환하지 않는다는 사실을 나타내지만, 최소 타입은 함수가 계산을 끝마치지 못한다는 사실을 나타낸다.

function error(): never {
  throw new Error();
}
function asserNonzero(num: number): number {
  return (num != 0) ? num : error();
}
function asserShort(str: string): string {
  return (str.length <= 10) ? str : error();
}

타입스크립트에서 최소 타입의 이름은 'naver'이다.

 

최대 타입과 최소 타입은 정반대 개념이면서도 비슷한 면이 있다. 최대 타입이 '아무 값이나 될 수 있다'를 의미한다면 최소 타입은 '아무 곳에나 사용될 수 있다'를 의미한다. 두 문장 모두 '아무 ... 될 수 있다'의 형식이다. 하지만 전혀 다른 역할을 한다.

 

최대 타입의 부품은 만들기 쉽다. 아무 부품이나 최대 타입의 부품으로 간주할 수 있기 때문이다. 그런 만큼 아무 값이나 결과로 낼 수 있기에 결과로 나온 값이 어떤 능력을 가지는지 모른다. 그래서 최대 타입의 부품은 조심스럽게, 별다른 특별한 능력을 요구하지 않는 곳에만 사용해야 한다. 반대로 최소 타입의 부품은 계산을 끝마치지 못한다. 따라서 결과로 나온 값이 사용되는 순간이 영원히 오지 않는다. 그렇기에 최소 타입은 아무렇게나 어느 곳에든 사용할 수 있다. 대신 최소 타입의 부품은 만들기가 어렵다. 예외를 발생시키거나 무한히 재귀 호출을 하는 등의 특별한 방법으로만 최소 타입의 부품을 만들 수 있다.


이거나 타입

합집합 타입이라 말할 수 있다. 아무 타입 A와 B가 있을 때 언제나 A와 B 모두 A | B의 서브타입이다. 이거나 타입을 사용할 떄는 한 가지 주의할 점이 있다. 이를 위해서는 '위치에 민감한 타입 검사(flow-sensitive type checking)'라는 개념을 이해해야 한다. 이거나 타입을 제공하는 언어는 위치에 민감한 타입 검사라는 정교한 방식의 타입 검사를 사용하기 때문이다. 즉, 해당 변수가 정의된 곳의 타입 표시만 보는 것이 아니라, 그 변수가 어디서 사용되는지도 고려하는 것이다. 이를 위해서는 위치에 민감한 타입 검사가 잘 작동하도록 프로그램의 구조를 단순하게 만들어야 한다.

function write(data: string | number): void {
  if (typeof data === 'string') {
    let str: string = data;
    ...
  } else {
    let num: number = data;
    ...
  }
}
write("a");
write(1);

Q. 타입 가드의 종류들과 차이점은 무엇인가?

https://young-taek.tistory.com/295

 

타입 좁히기 / 타입 가드, 사용자 정의 타입 가드 / 서로소 유니온 타입

최종 수정 : 2024-05-28타입 좁히기조건문 등을 이용해 타입을 상황에 따라 좁히는 방법 타입 가드타입 좁히기를 도와준다. typeof . instanceof, in, 서로소 유니온 타입type Person = { name: string; age: number;};

young-taek.tistory.com


이면서 타입

이면서 타입은 다중 상속(multiple inheritance)을 다룰 때 유용하다. 다중 상속은 한 클래스가 여러 클래스를 직접 상속하느는 것을 말한다.

class Student {
  Grades greads;
}
class Teacher {
  String course;
}
class TA extends Student, Teacher {
  Int pay;
}

 

이면서 타입은 이거나 타입과 비슷하면서도 반대되는 역할을 하며, 교집합 타입이라 말할 수 있다. 타입 A와 B로 만든 타입은 A & B라 쓰며 직관적으로 'A이면서 B를 나타낸다. 즉, 어떤 값이 A에도 속하고 B에도 속해야지만 A & B에 속한다.

interface Student { grade: number; }
interface Teachter { course: string; }
class TA implements Student, Teacher {
  grade: number;
  course: string;
}
class Volunteer implements Student, Teacher {
  grade: number;
  course: string;
}
function getGrade(st: Student & Teacher): number {
  let grade: number = st.grade;
  let course: string = st.course;
  ...
}
getGrade(new TA());
getGrade(new Volunteer());

3. 함수와 서브타입

함수를 값으로 사용한다는 말은 함수를 변수에 저장하거나 다른 함수에 인자로 전달하거나 다른 함수에서 반환한다는 뜻이다. 이렇게 값으로 사용되는 함수를 '일급 함수(first-class function)'라고 부른다.

 

정적 타입 언어에서 일급 함수를 사용하려면 우선 함수 타입을 어떻게 표현하는지 알아야 한다. 함수 타입은 매개변수 타입과 결과 타입을 차례로 쓴 것이다. 함수 타입은 그 자체만으로는 서브타입에 의한 다형성을 필요로 하지 않느다. 하지만 언어에 객체와 서브타입에 의한 다형성이 존재하면 함수 타입 사이의 서브타입 관계를 따질 필요가 생긴다.

 

일반적으로는 A가 B의 서브타입일 때 B => C가 A => C의 서브타입이다. 그런데, 함수 타입은 매개변수 타입의 서브타입 관계를 뒤집는다. 결과 타입의 서브타입 과계가 유지된다는 사실은 나름 직관적인 것에 비해, 매개변수 타입의 서브타입 관계가 '뒤집힌다'는 사실은 처음 봤을 때 다소 이상하게 들릴 수 있다.

 

함수 타입 사이의 서브타입 관계가 없으면 당연히 타입 검사를 통과해야 할 것 같은 코드가 그러지 못하는 불편함이 생긴다. 일급 함수를 사용하는 경우에는 함수 타입 사이의 서브타입 관계를 타입 검사시가 잘 판단하는 게 필수다. 함수 타입 사이의 서브타입 관계는 "함수 타입은 매개변수 타입의 서브타입 관계를 뒤집고 결과 타입의 서브타입 관계를 유지한다"라고 정리할 수 있다.

class Person { email: string; }
class Student { email: string; grade: number; }
function startMentoring(select: (s: Student) => Person): void {
  let st: Student = ...l
  let mentor: Person = select(st);
  ...
}
function selectStudentMentor(st: Student): Student { ... }
startMentoring(selectStudentMentor);
function sendEmails(needEmail: (s: Student) => Boolean): void {
  let st: Student = ...;
  if (needEmail(st)) {
    ...;
  }
}
function isHacked(pr: Person): Boolean { ... }
sendEmails(isHacked);

(이에 대해서는 반공변성과 공변성에 대해 알아야 한다.)

머릿속으로는 이해하겠으나, 글로 풀어내지 못하고 있다. 답답하다..
2024-06-08ver

댓글