타입으로 견고하게 다형성으로 유연하게

탄탄한 개발을 위한 씨줄과 날줄

이 책은 아카데미 컨퍼런스 2025년 1회차 책이다. 총 5회 스프린트로 진행했으며 약 2달간 진행했다.

책의 전반적인 내용은 타입 검사기에 대한 내용이며 처음에는 단순하게 객체지향에 대해서 이야기하는 줄 알았지만 많은 개발자가 놓치고 있는 ‘기본’ 타입에 대해서 다룬다. 타입이 뭐냐고 물었을 때 속시원하게 대답할 수 없다면, 타입 검사가 왜 중요한지 어떻게 동작하는지 모른다면 꼭 읽어보길 추천한다.

일고나서의 감상은 비어있던 지식의 한쪽을 탄탄하게 채워주는 느낌이다. 개발자라면 아는 타입부터 시작하여 다형성으로 나아가 다양한 개념들이 섞였을 때 발생하는 예시와 해결방법과 고전적 언어, 객체지향, 함수형 언어등의 개념들을 다룬다.

타입 검사 훑어보기

타입 검사의 정의와 필요성

타입 검사가 불편함에도 필요한 이유는 버그 때문이다. 버그 중에서도 가장 흔하며 많이 발생하는 오류는 타입 오류이기에 저자는 타입 검사가 필요하다고 말하고 있다. 프로그램을 실행하며 버그(타입 오류)를 검사하는 방법에는 분명한 한계가 있기에 이미 작성된 코드를 잘 활용해야 한다.

따라서 사람들은 코드를 입력받아 프로그램이 타입오류를 일으키는지 자동으로 판단해 주는 프로그램인 타입 검사기를 사용한다.

  • 이상적인 타입 검사기: 주어진 프로그램이 타입 오류를 일으키지 않는다면 통과, 타입 오류를 일으키는 경우의 수가 단 하나라도 있다면 거부(오류 메시지)
  • 현실적인 타입 검사기: 오류를 일으키는 경우의 수가 단 하나라도 있다면 거부, 주어진 프로그램이 타입 오류를 일으키지 않는다면 통과 + 간혹 가다 거부를 내는 경우도 있다.

이상적인 타입 검사기는 절대 만들 수 없다는 사실이 논리적으로 이미 증명되었다.

현실적인 타입 검사기로도 충분히 유용하다. 그 이유는 타입 안정성(type safety)를 보장하기 때문이다. 만약 타입 검사기에서 통과를 준다면 이는 타입 오류가 없음을 확신할 수 있다. 거부인 경우에는 타입 오류가 없음에도 나올 수 있지만 오류 메시지로 정보를 주기 때문에 개발자가 판단할 수 있다.

이처럼 타입 검사기는 프로그램에서 가장 흔하게 발생하는 (인간이 실수하는 영역)에서 아주 쉽게 버그를 찾아준다. 약간의 불편함이 있기는 하지만 불편함보다 더 큰 가치를 가지는 사례이다.

정적 타입 언어

타입 검사기는 대부분 해당 언어를 만든 사람들이 제작한다. 개발자는 타입 검사기를 통해 자신의 프로그램이 타입 오류를 일으킬 수 있는지만 확인하면 된다. C, C++, C#모두 정적 타입 언어에 해당한다.

정적이라는 단어는 프로그램이 시작하기 전에라는 뜻으로 타입이 올바르게 사용되었는지 확인하는 언어이다. 실행 중에 타입 오류가 절대 일어나지 않는다는 보장과 함께 프로그램이 타입 검사를 통과하지 못하면 언어가 실행되지 않는다.

반대로 동적 타입 언어는 타입 검사기를 제공하지 않는다. 검사기가 없으니 프로그램을 실행하기 전에 타입 검사를 해 볼 수 없다. 정적과 반대의 의미로 프로그램을 실행하는 중에라는 뜻이다.

타입 검사의 원리

타입 검사기의 오류 메시지를 이해하기 위해선 어떻게 작동하는지 알아야 한다. 저자는 쉽게 이해할 수 있도록 자동차 조립 순서에 빗대어 타입 검사 과정을 설명한다. 쉽게 일을 처리하기 위해 더 작은 단위로 나누어 처리하는 것과 같은 원리로 프로그램에서 다루는 단위인 타입을 하나로 바라볼 수 있을 때 까지 분할 후 병합하는 과정을 재귀적으로 거친다. 병합 정렬과 유사한 방식이다.

가장 기본이 되는 이러한 이론을 처음 코딩을 배울 땐 얇고 가볍게 이해했다면 이런 당연한 흐름도 컴퓨터의 동작 원리를 바라봐야 함을 많이 깨닫게 되는 것 같다.

책에서는 타입 검사의 원리를 수학적 증명을 하듯 리터럴, 함수, 삼항연산자 등의 경우로 설명한다.

타입 검사 결과의 활용

앞서 다룬 정적 타입 언어의 장점인 타입 오류를 찾을 수 있다는 점과 오류 메시지로 빠르게 수정가능하다는 점 말고도 정적 타입 언어의 장점은 두 가지 더 있다. 첫 째는 코드 편집기의 유용함이며, 두 번째는 프로그램 성능이다.

코드 편집기는 동적 타입 언어도 지원하긴 하지만 정적 타입 언어에서 더욱 더 유용하다. 자동 완성의 기능만 봐도 타입 검사의 도움을 받아 개발자가 처리해야 하는 정보의 양이 확 줄어든다. 타입 검사기가 프로그램에 대한 각 부품에 대한 탕비을 알아내기에 이를 활용한다. 또한, 리팩터링 과정에서도 앞서 말한 코드 검사기의 이점으로 수정이 편리하다. (정적 타입 한정)

프로그램 성능이 뛰어나다는 것은 결국 프로그램 실행 시간이 짧다는 것을 의미한다. 쉽게 생각하여 add()함수의 경우 동적 타입은 해당 타입이 일치하는지 확인해야 하지만 정적 타입은 이미 타입이 일치함을 알고 있기에 더 빠르게 실행된다. 결국은 트레이드 오프다.

타입 추론

정적 타입 언어가 동적 언어 타입보다 불편한 점은 앞서 다룬 이점들로 인해 타입을 개발자가 직접 표시해야 한다는 점이다. 이러한 단점을 개선하고자 등장한 추론 타입은 정적 타입 언어에서 많이 활용되고 있다. 다만 추론 타입이 사용되면 유용한 지점과 안되는 지점을 명확하게 구분하는 능력이 필요하다.

function readfile(file) {...}이런 코드를 개발자가 본다면 이름을 보고 쉽게 이해는 가능하지만, 인자로 넘겨야 하는 값이 뭔지 알 수 없다. 특정 파일을 나타내는 타입의 값인지, 파일 시스템의 경로를 나타내는 값인지 알 수 없다. 따라서 이런 경우에는 명확하게 타입 표시를 해주는 것이 좋다.

주석이라는 방법이 있지만, 주석은 틀릴 수 있다는 단점이 있다.(주석은 타입을 검사하지 않음, 문서화는 실제 코드를 못 따라감 대부분) 타입 표시는 이러한 단점이 없고 그 자체로 문서화라고 할 수 있다. ‘절대로 낡지 않는 주석’

반대로 타입 추론이 좋은 경우에는 사용자 정의 타입이 길어 오히려 정보의 과부화를 주는 경우나 타입이 네이밍으로 명확한 경우 등에 해당될 것 같다. C#의 경우 IL에서 실제 타입으로 치환되니 이러한 경우에는 추론을 사용하는 것이 좋다. 타입 추론의 좋은 점은 개발자가 타입 추론의 원리를 이해하지 않고도 혜택을 누릴 수 있다는 점이다.

정적 타입에서 타입 표시는 양날의 검이다. 타입 표시를 모든 곳에 붙였다가는 코드가 너무 장황해지기 십상이다. 그렇다고 모든 타입을 추론이 허용하는 범위 안에서 다 사용하는 것도 올바르지 않다. 이는 항상 트레이드 오프를 생각하며 사용하는 지혜가 필요하다.

더 세밀한 타입

더 세밀한 타입이란 기존 타입보다 더 세밀하게 분류된 타입을 말하는 것으로 세밀하게 분류될수록 버그를 차단하기 쉬워진다고 말한다. 0으로 나누거나 null을 참조하는 등의 오류를 방지할 수 있다. 이는 null을 타입으로 분류하는 언어와 그렇지 않은 언어에서 해당 에러를 어떻게 분류하는지를 참고하면 알 수 있다.

그렇다고 세밀한 타입이 항상 좋은 것은 아니다. 세밀한 타입은 개발자의 편의성과 연관성이 있기 때문에 세밀하게 분류할수록 사용에 불편함을 겪는다. 이는 일종의 균형 잡기, 트레이드 오프의 문제이다.

정적 타입 언어의 장단점

정적 타입 언어의 장점만 다룬 것 같지만 장점만 있었다면 동적 타입 언어가 존재할 필요가 없었을 것이다. 정적 타입 언어는 타입 오류를 찾을 수 있고, 생산성을 높이기에도 좋으며 성능적으로도 유리하다. 다만 타입 표시라는 양날의 검과 타입 검사기의 ‘거부’가 틀릴 가능성이 있다는 것이다.

이를 종합하면 동적 타입 언어와 정적 타입 언어가 언제 유용한지 알 수 있다. 정적 타입 언어는 큰 프로그램에 적합하다. 프로그램이 크면 코드가 커지고 버그가 많이 발생하니 찾기 어렵다. 코드가 길어 이해하거나 수정하기도 쉽지 않다. 따라서 타입 오류를 찾고 코드 편집기의 능력을 최대로 이끌어내는 정적 타입 언어가 최적인 것이다.

반면 이러한 복잡한 실험실은 작은 프로그램을 만들 때는 쓸모가 없다. 작은 프로그램은 버그도 적고, 금방 만들 수 있으며 코드도 간단하다. 오히려 타입 표시를 붙이거나 제대로 짰지만 타입 검사에 걸려 시간을 낭비한다. 따라서 작은 프로그램을 만들 때는 동적 타입 언어가 적합하다.

다형성

정적 타입 언어의 단점인 타입 검사기가 거부라고 틀리게 말하는 경우는 절대로 완벽하게 해결할 수 없다. 따라서 거부라고 잘못 말하는 가능성을 줄이는 방법이 필요하다. 그 방법 중 안전하며 대부분을 커버하는 방법이 다형성이다.

다형성은 프로그램의 한 개체가 여러 타입에 속하게 만든다. 여기서 개체라 함은 값, 함수, 클래스, 메서드 등 여러 가지가 될 수 있다. 하나의 값이 여러 타입에 속할 수도 있고, 한 함수를 여러 타입의 함수로 사용할 수 있다는 것이다.

다형성은 거의 모든 정적 타입 언어에서 발견할 수 있는 매우 널리 사용되는 개념으로 타입 안정성을 해치지 않으면서도 타입 검사기의 오판을 획기적으로 줄이며, 동시에 개발자가 쉽게 이해할 수 있는 오류 메시지를 제공하기 때문이다. (코드의 양도 줄여준다.)

정리

처음 책을 구매하고 표지와 제목을 봤을 땐, 단순하게 객체지향에 관한 내용인줄 알았지만, 1장을 읽고 좀 생각을 해보니 좀 더 넒은 의미의 프로그램에 관한 내용이라는 생각이 들었다. 표지에 있는 그림은 마치 뜨개질로 촘촘하게 즉, 견고하게 만들어진 방직물이 떠올랐다. 유연하지만 견고한, 높은 기술에 가까이 갈수록 기본기가 중요해지는 생각도 들고 실생활에도 많은 교훈을 주는 말이지 않을 까 싶다.

유연하게 만들기 위해선 더욱 더 견고하게 만들어야 하며, 견고하게 만들기 위해선 유연하게 만들어야 한다. 이러한 두 가지의 상반된 특성을 가진 것이 객체지향이라는 것인가? 라는 생각이 든다.

논의사항

  • AI의 도움으로 책에서 다루는 정적 타입 언어의 장점에서 속도를 제외한다면 대부분 동적 언어에서도 해결할 수 있지 않을까? 라는 생각이 듭니다. 대부분이 휴먼 에러에 가깝다는 생각이 드는데 이런 것들은 AI도움이면 대부분 해결이 가능하지 않을까요? 다른 분들의 생각이 궁금합니다!

서브타입에 의한 다형성

객체와 서브타입

객체라는 개념이 있는 정적 타입 언어라면 대개 서브타입에 의한 다형성도 제공한다. 타입 검사에서 서브타입에 의한 다형성은 실제로 같은 타입이 아니더라도 코드에서 이를 인식할 수 있게 해준다.

서브타입에 의한 다형성은 서브타입이라는 개념을 통해 실현한다. 그러므로 서브타입에 의한 다형성을 이해하려면 서브타입부터 이해해야 한다. 서브타입은 A is a B라는 관계를 의미한다. 이 관계는 A는 B의 서브타입이라는 것을 의미한다. 서브타입에 의한 다형성은 A가 B의 서브타입일 때 A 타입의 부품을 B 타입의 부품으로도 간주할 수 있게 하는 기능이 서브타입에 의한 다형성이다.

문제는 A가 B의 서브타입이다라는 사실을 사람은 그렇다고 할 수 있지만 타입 검사기는 이를 인정하지 않아 통과하지 못하는 경우가 생길 수 있다는 것이다. 따라서 A는 B다가 사실이면 A가 B의 서브타입이다라는 설명을 통해 서브타입이라는 개념을 직관적으로 이해하되, 실제 코드를 작성할 때는 타입 검사기가 사용하는 규칙을 고려하여 서브 타입의 관계를 파악해야 한다.

타입 검사기가 객체 타입의 서브타입 관계를 판단할 때 사용하는 규칙에는 이름에 의한 서브 타입 관계와 구조에 의한 서브타입이 있다.

이름에 의한 서브 타입 관계

이름에 의한 서브타입을 사용하는 경우, 타입 검사기는 타입이 보여주는 클래스 이름과 클래스 사이의 상속 관계만 고려한다. 해당 클래스가 어떻게 생겼는지, 그 클래스에 어떤 필드와 메서드가 있는지는 전혀 신경 쓰지 않는다.

1
2
3
4
5
6
7
8
9
class Person { public string email; }
class Student : Person { public int grade; }
void sendEmail(Person person) 
{
    string email = person.email;
    ...
}
Student student = new Student();
sendEmail(student);

구조에 의한 서브 타입 관계

이미 정의된 클래스를 수정할 수 없는 경우에는 이름에 의한 서브타입으로는 부족할 수 있다. 그럴 때는 구저에 의한 서브타입이 필요하다. 사용중인 라이브러리에 동일한 이름을 가진 클래스가 존재한다면 코드는 오류를 발생시킨다. 이러한 불편을 해소시켜주는 개념이 구조에 의한 서브타입이다.

구조에 의한 서브타입을 사용하는 경우 타입 검사기는 클래스 사이의 상속 관계 대신 클래스의 구조(각 클래스가 어떤 필드와 메서드가 있는지 고려한다.) 만약 클래스 A가 클래스 B에 정의된 필드와 메서드를 모두 정의한다면 A는 B의 서브타입이다.

추상 메서드

추상메서드는 메서드를 정의하지 않되 이 클래스를 상속하려면 특정 메서드를 반드시 정의해야 한다는 사실을 표현하는 것이 추상 메서드의 용도다. 또한 추상 메서드를 가지는 대신 객체를 직접 만들 수 없는 클래스를 추상 클래스라고 부른다. 언어에 따라 추상 클래스 대신 인터페이스, 트레이드등의 용어로 사용한다.

집합론적 타입

집합론적 타입이란 수학 집합론에서 나오는 전체 집합, 공집합, 합집합, 교집합으로부터 유래되었다.

최대 타입

‘아무 값이나 가능하다’라는 표현이 없는 것은 여러 불편함을 가져온다. true ? 1 : false이와 같은 코드는 아무런 오류가 없음에도 타입 검사를 통과하지 못한다. 이는 ‘결과값이 아무 값이나 될 수 있다’를 표현하는 타입이 있다면 검사를 통과할 수 있을 것이다. print함수의 경우

최대 타입은 결국 ‘가장 큰’ 타입이다. 즉, 모든 값을 포함하는 타입을 말하며 어느 값이든 최대 타입에 속한다. 가장 큰 부모, C#으론 object

최소 타입

최소 타입은 예외를 다루는 데 유용한 타입이다. error함수는 어디서든 호출할 수 있다. 그 이유는 error가 계싼이 끝나지 않는 함수이기 때문이다. error를 호출하면 값을 반환하지 못한 채 예외가 발생해 실행이 종료된다. 따라서 어떤 타입의 값이 필요한 곳이든 항상 괜찮다.

계산을 끝마치지 못한다를 표현한느 타입이 바로 최소 타입이다. 최소 타입의 특징은 모든 타입의 서브타입이라는 점이다.

최대 타입과 최소 타입은 정반대의 개념이면서 비슷한 면이 있다. 최대 타입이 ‘아무 값이나 될 수 있다’를 의미한다면 최소 타입은 ‘아무 곳에나 사용될 수 있다’를 의미한다. 하지만 둘은 전혀 다른 뜻이고 다른 역할을 한다.

이거나 타입

프로그래밍을 하다 보면 한 함수가 받는 인자의 타입이 여러 가지가 되어야 하는 경우가 생긴다. 이거나 타입은 이러한 경우 유용한 개념이다. 같은 기능을 하는 함수를 사용할 때, 매개변수의 타입에 따라 함수를 분리하는 경우가 있는데 이때 사용하면 유용한 개념이다.

이거나 타입을 사용할 때는 민감한 타입 검사라는 개념을 이해해야 한다. 실제로는 문제 없지만 코드상으로 직관적인 오류가 있을 때, 정의된 곳의 타입만 보는 것이 아닌 그 변수의 사용 위치도 고려하는 것 하지만 민감함 타입 검사도 프로그램이 복잡해지면 위치로부터 정보를 얻을 수 없게 된다.

따라서 이거나 타입을 사용할 때는 민감한 타입 검사가 잘 작동하도록 프로그램 구조를 단순하게 만들어야 한다.

이면서 타입

이면서 타입은 다중 상속을 다룰 때 유용하다. 학생과 선생의 클래스를 상속받아 조교의 역할을 할 때 이면서 타입은 이거나 타입과 비슷하면서도 반대되는 역할을 한다. 이거나 타입처럼 이면서 타입도 두 개이 타입으로부터 만들어진다.

함수와 서브타입

여러 언어에서 함수를 값으로 사용할 수 있다. 함수를 값으로 사용한다는 말은 함수를 변수에 저장하거나 다른 함수에 인자로 전달하는, 다른 함수에서 반환한다는 뜻이다. Delegate 함수의 타입은 매개변수 타입과 결과 타입을 차례대로 쓴 것이다. int -> string

함수 타입은 그 자체만으로는 서브타입에 의한 다형성을 필요로 하지 않는다. 하지만 언어에 객체와 서브타입에 의한 다형성이 존재하면 함수 타입 사이의 서브타입 관계를 따질 필요가 생긴다.

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

정리

실제 내가 사용하는 언어에서는 서브타입에 의한 다형성이 어떻게 나타나는지를 고민해보면서 읽어봤다. 델리게이트나 인터페이스, 최대/최소 등 어느정도 이해가 되고 당연하다는 생각도 드는 한편 처음 접한 타입도 있어서 찾아본 내용도 있다.

좋았던 점은 내가 알고 있는 다형성의 개념들을 타입이라는 틀로 다시 한 번 정확하게 짚어가며 타입 검사기의 과정 당연한게 왜 당연한지 A 와 B 그리고 C와 D의 관계들로 풀어준 부분이 나에게 도움이 된 것 같다.

논의사항

이름에 의한 서브 타입은 익숙한 반면 구조에 의한 서브 타입은 낮설게 느껴졌습니다. 두 서브타입 모두 목적은 결국 다형성을 제공한다는 것이지만 구조에 의한 서브 타입은 지나치게 유연한게 아닌가 라는 생각도 듭니다. 한편으로는 직관적이고 더 단순하고 명확하다는 생각도 듭니다

제가 사용하는 언어는 C#이면서 이름에 의한 서브 타입 관계를 사용합니다. 또한, 인터페이스라는 개념으로도 서브타입을 나타낼 수 있는데 인터페이스는 어떠한 행위를 추상적으로 정의하여 다형성을 나타냅니다. C#의 관점으로 책에서 보여지는 구조에 의한 서브 타입이 가능한가? 라는 생각이 듭니다.

책에서 보여주는 예제는 필드에 해당되기에 속성만 같으면 같은 타입으로 인정한다 라는 개념이 이해는 되지만, 그것이 메서드나 행위에 해당될 때는 다르지 않을까? 궁금합니다. 같은 메서드를 시그니처로 판단하는지, 함수 내부 구현부가 다르다는 점으로 다형성을 나타내는건지? 저와 같은 생각을 하신 분이나 생각이 있으시다면 같이 이야기해보면 좋을 것 같습니다.

매개변수에 의한 다형성

제네릭 함수

매개변수에 의한 다형성은 타입 매개변수를 통해 다형성을 만드는 기능으로 제네릭스(genericss)라고도 부른다. 매개변수에 의한 다형성 즉, 제네릭은 대부분 현대의 언어에서 지원하기에 매우 유용하게 사용되고 있다. 기본적인 STL, 컨터에너 등 언어 차원에서 지원하기에 이점을 체감하기엔 없는 상황을 가정하는 것이 더 효과적일 것 같다.

책에서도 마찬가지로 기계를 예로, 없는 상황과 실제 없을 경우 함수를 제작하는 과정의 복잡함을 보여준다. 일종의 중복성을 줄이는 궁극의 기술같은 느낌이다. 제네릭의 핵심은 사실 매개 변수를 통해 타입을 넘겨줌으로서 타입 검사기가 해당 타입을 인식하도록 하는 것이다.

하지만, 항상 트레이드 오프가 따라오는 것 처럼 만능이 아니다. 예제에 나오는 return v1 * v2 * v3;의 경우 정수값은 문제 없지만 무슨 타입이든 가능하기에 * 연산자를 정의하지 않은 사용자 정의 클래스는 말이 되지 않는다. 이런 문제를 방지하기 위해 애초에 정의하는 것을 허락하지 않는다.

1
2
3
4
T choose<T>(T v1, T v2) {return ... ? v1 : v2;}

string str = choose<string>("Korean", "Foreigner");
int num = choose<int>(1, 2);
  • 다만 C#의 경우 where을 사용하여 제약을 통해 기본 연산자를 가진 제네릭을 만들 수 있다.
1
2
3
4
public static T Multiply<T>(T v1, T v2, T v3) where T : INumber<T>
{
    return v1 * v2 * v3;
}

제네릭 메서드

제네릭 함수와 제네릭 메서드의 차이는 클래스 안에 정의된다는 점만 제외하면 동일하다.

타입 인자 추론

위 매개변수에 의한 다형성은 코드 중복을 크게 줄여주는 대신 매번 타입 인자를 써 줘야 하는 점이 불편하다. 이러한 불편함을 해소하기 위해 매개변수에 의한 다형성을 제공하는 대부분의 언어는 타입 인자 추론을 함께 제공한다. 타입 인자 추론이란 타입 추론의 일종으로 제네릭 함수나 제네릭 메서드를 호출할 때 개발 자가 타입 인자를 생략할 수 있도록 하는 기능이다.

타입 검사기 기준으로 앞서 다룬 타입 추론과 동일하게 동작한다. 마찬가지로 코드가 복잡해질 경우 타입 검사기가 타입 인자 추론에 실패할 수 있다.

힌들리-밀너 타입 추론

대부분의 언어는 제네릭 함수를 사용할 때 타입 추론을 해서 타입 인자를 찾는다. 하지만 일부 언어에서는 더 나아가 제네릭 함수를 정의할 때 타입 추론을 한다. 이런 방식을 힌들리-밀너 타입 추론이라고 한다.

타입 매개변수를 쓰지 않아도 함수가 자동으로 제네릭 함수가 될 수 있다는 말로 매개변수 타입과 결과 타입까지도 추론한다. 즉, 일반적인 함수처럼 정의해도 저절로 제네릭 함수가 되는 것이다. 위에서 말한 곱하기 제네릭의 경우에는 타입 검사기가 제네릭으로 만들지 않는다.

이러한 과도한 추상화에 항상 따라오듯 힌들리-밀너 타입 추론은 양날의 검이다. 이러한 기능은 편리하지만 그 의도나 동작 방식을 이해하지 못한다면 수정하기 매우 어려워진다.

제네릭 타입

타입 매개변수를 추가할 수 있는 곳은 함수뿐이 아니라 타입에 타입 매개변수를 추가하면 제네릭 타입이 된다. 제네릭 타입은 필수적으로 사용하는 자료형에도 유용하게 사용된다.

List의 예제와 같이 제네릭 타입의 필요성은 단순한 함수에서도 볼 수 있다. 타입 검사기가 있는 한 동작하는 함수에서 무슨 타입인지 인지할 수 있어야 한다.

제네릭 함수는 제네릭 타입을 다룰 때 더욱 유용하다. 실제로 리스트를 다루는 함수들은 아무 리스트나 인자로 받기 때문인데, 리스트의 + 연산을 사용하는 경우 같은 타입은 문제없이 동작하기 때문에 타입 매개변수만 존재한다면 문제가 없다. 단, 대부분의 언어는 타입 인자 추론을 제공하기에 List(1, 2, 3);만 써도 대개 타입 검사를 통과한다.

제네릭 클래스

개발자가 직접 제네릭타입을 정의하고 싶은 경우도 있기에 제네릭 클래스를 사용하여 정의할 수 있다.

무엇이든 타입

무엇이든 타입은 보편 양화 타입이라고 불리기도 한다. 이 타입은 제네릭 타입을 받는 함수 자체를 매개변수로 전달할 때 유용하다. 책에서 나오는 예제의 경우 simulate의 매개변수 rand에는 무엇이든 타입이 들어가야 한다. List<int> => intany또한 문제가 생긴다. 제네릭 함수로 변경해도 마찬가지

1
void simulate(forall T.(List<T> => T) rand) { ... }

rand의 타입인 forall T.(List<T> => T)forall T.A 형태로 부른다. T는 타입 매개변수를 의미하고 A는 타입을 의미한다. List => T 형태는 T가 무엇이든 될 수 있다는 것이다.

무엇이든 타입을 제네릭 함수의 타입이라고 생각하면 된다. 제네릭 함수를 값으로 사용하면 그 타입이 무엇이든 타입이 되고, 무엇이든 타입의 값을 사용할 때는 제네릭 함수를 사용하듯이 하면 된다.

무엇이든 타입이 없는 언어라도 제네릭 메서드가 있다면 비슷한 코드를 작성할 수 있다.

무엇인가 타입

무엇인가 타입은 ‘존재 양화 타입’이라고 번역할 수 있다. 무엇인가 타입이 필요한 경우는 프로그램이 상당히 복잡할 때 제대로 드러난다.

책에서 보여주는 타임스탬프 예제의 문제를 해결하기 위해선 타임 스탬프 타입을 라이블ㄹ리 사용자에게 숨겨야 한다. Int임을 알려 주는 대신 타임 스탬프의 타입이 되는 ‘무엇인가’가 존재한다고만 알려주는 것이다. 따라서 사용자는 타임스탬프의 타입이 되는 ‘무엇인가’가 있다는 사실은 알지만, 그 타입이 특정한 타입이라는 기대를 가지고 코드를 짤 수는 없다.

이런 일을 가능하게 하는 것이 무엇인가 타입이다. 라이브러리의 입장에선 교체되는 타입에 대해서 자유롭지만, 사용자 입장에서 추가적인 구현은 타입 검사를 통과하지 못한다.

일반적으로 정적 타입 언어가 제공하는 기능들은 더 많은 프로그램이 문제없이 타입 검사를 통과할 수 있도록 돕는다. 반면 무엇인가 타입의 역할은 정반대다. 오히려 타입 검사를 통과할 수 있는 프로그램이 타입 검사를 통과하지 못하도록 방해한다. 필요없는 능력같아 보이지만 실상은 매우 중요하고 필요하다.

라이브러리 제작자 입장에서는 사용자의 행동을 제약할 필요가 있다. 사용자가 특정 조건을 만족한다고 가정해야 코드를 작성하기 쉽거나 앞으로 코드를 고치기 유리하기 때문이다.

두 다형성의 만남

제네릭 클래스와 상속

1
2
3
4
abstract class List<T>
{
    T get(int index);
}
  • C#의 제네릭 클래스는 불변이다.

다음과 같은 추상 클래스를 상속받아 서브타입 클래스를 만들었을 때, 앞서 다룬 제네릭과 같이 서브타입 클래스의 제네릭 타입은 부모 클래스의 제네릭 타입을 따른다.

1
2
3
4
class ArrayList<T> : List<T>
{
    T get(int index) { ... }
}

여기서 핵심은 상속받는 추상 클래스의 제네릭 타입을 T로 정의했기에 서브타입 클래스에서도 제네릭의 특성을 이용하고 싶다면 T로 정의해야 한다는 것이다. A가 B를 상속하면 A가 B의 서브타입이기 때문에 이 원리 그대로 제네릭 클래스에도 적용된다.

반대로 제네릭의 특성을 이용할 필요가 없기 타입이 명확한 서브타입을 만들고 싶다면 제네릭 타입을 명시적으로 정의해야 한다.

1
2
3
4
class IntArrayList : List<int>
{
    int get(int index) { ... }
}

타입 매개변수 제한

앞서 3장에서 정리한 내용과 같이 제네릭 함수의 경우 매개 변수가 아무 타입이나 나타낼 수 있다고 가정하기에 타입 변수 T에 대해서 출력하거나 반환할 수는 있어도 덧셈이나 곱셈과 같이 특별한 능력이 필요한 곳에는 사용할 수 없다고 했다. 이 내용은 사용 의도 자체를 잘 나타내는 것으로 제네릭 함수를 정의하는 것은 결국 여러 타입으로 사용될 수 있는 함수를 만드는 것이고, 인자가 특별한 능력을 가져야 한다면 그 함수는 여러 타입으로 사용될 수 없다.

3장에 대한 추가 정리 내용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person
{
    int age;
    ...
}

class Student : Person {...}

Person Elder(Person p1, Person p2)
{
    return p1.age > p2.age ? p1 : p2;
}

Student s = elder(s1, s2);

위 코드는 타입 검사기를 통과하지 못한다. 정확하게는 elder(s1, s2)까지는 타입 검사를 통과한다. Student는 Person의 서브타입이기 때문에 Person 타입으로 반환할 수 있다. 하지만 결과를 Person으로 반환하는 순간 Student 타입으로 다운 캐스팅해야 하기 때문에 타입 검사를 통과하지 못한다.

이 경우 실제 메모리에 적재된 객체가 분면 Student 타입임을 알지만 문제가 된다. 정적 타입 검사이기 때문 이를 해결하기 위해 제네릭 함수로 교체한다고 해도 .age 필드 값을 읽으려 했기 때문에 여전히 타입 검사를 통과하지 못한다.

이 상황을 해결하기 위해선 타임 매개변수 제한이라는 기능이 필요하다. C#에서는 where 절을 사용한다. 직관적인 해석은 T가 최대 Person타입까지 커질 수 있음을 나타낸다. 3장에서 예로 INumber를 사용한 것과 같은 방식이다.

이전까지 제네릭 함수가 아무 타입이나 인자로 받을 수 있던 것과 달리, 타입 매개변수 제한을 사용한 제네릭 함수를 사용할 때는 정의된 상한을 타입 인자가 따라야 한다. 함수뿐만 아니라 클래스도 사용 가능하다.

재귀적 타입 매개변수 제한

타입 매개변수가 자기 자신을 제한하는 데 사용될 수 있다. 이를 재귀적 타입 매개 변수 제한(F-bounded quantification)이라 부른다. 재귀 함수가 자기 자신을 호출하는 함수인 것과 비슷하다.

만약 정렬 함수가 있다면 이를 일반화하기 위해서 제네릭을 사용할 수 있다. 여기서 문제는 위와 같이 타입 제한이 없다면 코드 검사를 통과하지 못할 것이라는 것이다. 따라서 이에 맞는 Compareble 인터페이스(or 추상 클래스)를 정의한다.

여기까지는 단순한 구현이지만 Comparable의 반환 타입이 Boolean인 것은 맞지만 실제 값을 구별할 클래스가 단순 자료형인지 사용자 정의 클래스인지 달라질 수 있기 때문에 이를 제네릭으로 확장하는 것이다.

단순 자료형으로 이를 사용하기 위해서는 확장 메서드를 사용해야 할 것 같다.

이를 C#으로 나타내면 다음과 같다.

1
2
3
4
void sort<T>(List<T> lst) where T : IComparable<T>
{
    if (lst[...].gt(lst[...])) { ... }
}

여기서 T의 상한에 T 자기 자신이 사용되었으니 이 코드는 재귀적 타입 매개변수 제한이다.

결국 이 코드는 T가 반드시 Comparable의 서브타입이어야 한다는 뜻(인터페이스를 구현해야 한다.)으로 해석할 수 있다. 이것은 재귀적 개념과 동일하다.

가변성

가변성(variance)은 제네릭 타입 사이의 서브타입 관계를 추가로 정의하는 기능이다.

앞서 다룬 List<Person>List<Student>의 관계를 생각해보자. List는 List은 서로 다른 타입이다. 동시에 그렇다고 서브타입이 아니라고도 볼 수 없다. 만약 함수에서 주어진 Person의 값을 평균내는 함수라면 Student도 동작해야 마땅하다.

앞서 배운 방법대로 제네릭 함수로 만들되 타입 매개변수 제한을 사용하면 해결이 된 것처럼 보인다. 하지만 매번 추가적인 타입 인자를 정의할 때마다 매개변수 타입을 Student로 바꿔야 한다. 이는 매우 번거로운 일이다.

즉, 제네릭의 목적성이 다형성의 측면이 강한 것에 비해 덩치가 커져버리는 구현이라는 것이다. 단순하게 서브타입에 의한 다형성이나 단순 구현으로도 해결이 가능하다.

이를 구현하려면 List<Student>List<Person>의 서브타입으로 만들어야 한다. 이것이 가능하기 위해서는 조건들이 붙는다.

1
2
3
4
5
6
7
8
9
10
abstract class List1<T>
{
    T get(int index);
}

abstract class List2<T>
{
    T get(int index);
    void add(T t);
}

이런 두 가지 추상클래스가 존재하고 Student클래스가 Person클래스를 상속받은 서브타입의 관계일 때, List1의 경우에는 List1가 List1의 서브타입이 된다. 반면 List2의 경우에는 List2가 List2의 서브타입이 되지 않는다.

실제 동작을 예로 List2<Person> people = students;의 관계에서 people에 add메서드를 통해 person을 추가한다면 이후 get을 통해 값을 꺼낼 때 학생인지 사람인지 알 수 없다. 실행중에 오류발생

이를 정리하면 “List가 원소 읽기만 허용하면 그래도 되고, 원소 추가도 허용하면 그렇지 않다.”라고 답할 수 있지만 좀 더 용어적인 설명이 필요하기에 가변성이 등장한다. “어떤 제네릭 타입은 타입 인자의 서브타입 관계를 보존하지만, 어떤 제네릭 타입은 인자 사이의 관계를 분류할 수 있다.” 여기서 이 분류가 가변성이다.

다시 말해 가변성이란 제네릭 타입과 타입 인자 사이의 관계를 뜻한다. 다음이 가변성의 종류이다.

  • 공변성(convariance): List1이 여기에 해당하며 B가 A의 서브타입일 때 List1가 List1의 서브타입이다.
  • 불변(invariance): List2가 여기에 해당하며 B가 A의 서브타입일 때 List2가 List2의 서브타입이 아니다.
  • 반변(contra-variance): 함수 타입이 여기에 해당하며 제네릭 타입이 타입 인자의 서브타입 관계를 뒤집는 것이다. 결과 타입을 C로 고정할 때 B가 A의 서브타입이면 B => C는 A => C의 슈퍼타입이다.

앞서 다룬 가변성을 좀 더 단순하고 일반화하여 설명한다면 제네릭 타입의 이름을 G, 타입 매개변수의 이름을 T라고 한다.

G가 T를 출력에만 사용하면 공변, 입력에만 사용하면 반변, 출력과 입력에 모두 사용하면 불변이다.

G에 해당하는 타입 T를 출력에 사용 T를 입력에 사용 가변성
List1 O X 공변
List2 O O 불변
int => T O X 공변
T => int X O 반변

지금까지의 내용은 개발자가 가변성을 판단하는 방법이지 타입 검사기가 가변성을 판단한느 것과는 별개의 문제이다. 직관적으로는 “타입 매개변수를 사용한 곳에 따라 정해진다”라고 알면 되지만 실제로 코드를 작성하라면 타입 검사기가 가변성을 판단하는 방법을 알아야 한다.

타입 검사기의 서브타입 판단 방법이 두 가지인 것처럼 가변성 판단 방법 역시 두 가지이다. 하나는 제네릭 타입을 정의할 때 가변성을 지정하도록 한 뒤 그에 따르는 것이고, 다른 하나는 사용할 때 가변성을 지정하도록 한 뒤 그에 따르는 것이다. 언어마다 사용하는 방법이 다르다.

정의할 때 가변성 지정하기

가변성은 각 제네릭 타입의 고유한 속성이다. 따라서 제네릭 타입을 정의할 때 가변성을 지정하는 것이 가장 직관적이다. 이를 C#에서는 out 키워드로 지정한다. 참고로 C#의 제네릭은 기본적으로 불변이다.

1
2
3
4
abstract class List3<out T>
{
    T get(int index);
}

List3<Any> == List3<Person> == List3<Student>이다.

따라서 out키워드를 붙이고 내부에서 값을 수정하는(제네릭 타입 매개변수를 지닌) 메서드를 사용한다면 타입 검사기가 코드를 거부한다.

타입 매개변수를 반변으로 만들고 싶다면 in 키워드를 사용한다. 이는 그 타입 매개변수를 입력에만 사용한다는 뜻이다.

1
2
3
4
5
abstract class Map<in K, V>
{
    V get(K key);
    void add(K key, V value);
}

K의 경우 입력에서만 사용되니 반변이 가능하고 V의 경우에는 불변이다. 따라서 Map<A, C>가 Map<B, C>의 서브타입이 가능하다.

공변과 반변은 일종의 트레이드 오프로 타입을 공변으로 만든다면 타입 매개변수를 입력에 사용하는 절반을 모두 포기해야 하고, 반볍으로 만든다면 나머지 절반을 포기해야 한다. 그러니 공변이나 반변으로 만든 클래스는 반쪽짜리 클래스를 만들 수밖에 없다.

사용할 때 가변성 지정하기

제네릭 타입을 사용할 때 가변성을 지정하는 경우, 제네릭 타입을 정의할 때는 가변성을 지정할 수 없다. 모든 제네릭 타입은 불변으로 정의되며 타입 매개변수를 아무 데서나 사용할 수 있다.

따라서 List가 불변이므로 B가 A의 서브타입이라면 List가 List의 서브타입이 아니다. 따라서 out이나 in을 타입 인자 앞에 붙여서 사용한다. List이나 List와 같이 사용한다. *이런 타입들은 제네릭 타입을 공변이나 반변으로 만드는 대신 기존 제네릭 타입보다 적은 기능을 제공한다.*

out을 붙인 경우 실제 클래스에 존재하는 메서드중 출력 기능만 사용이 가능한 객체가 주어진다. in의 경우 입력 기능만 사용이 가능한 객체가 주어진다.

정리

개인적으로 C#의 IEnumerable에 대해서 공부할 때 해당 내용을 정리한 경험이 있어서 잘 이해가 된 것 같다. 당시에도 어렵게 이해한 내용이 많았고, C# 키워드에서도 in, out키워드가 존재해서 좀 더 쉽게 이해한 것 같다.

논의사항

저는 가변성 부분에서 IEnumerable이나 C#의 제네릭 델리게이트 타입인 Func이 생각이 났습니다. 아마 제가 자주 사용해서 그런 것 같은데 다른 언어에도 이렇게 사전에 정의되어 있는 제네릭 델리게이트가 있는지 궁금합니다. 책에서는 함수 타입이라고 하는 것 같습니다.

1
public delegate TResult Func<in T,out TResult>(T arg);
  • Func의 경우 반환 값이 있기 때문에 out을 사용했기에 TResult는 공변이고, T는 반변
  • 비슷하게 Action의 경우엔 in만 존재

오버로딩에 의한 다형성

오버로딩

함수 오버로딩은 한 함수가 여러 타입의 인자를 받아야 할 때 이거나 타입보다 훨씬 간단하고 직관적인 해결책을 제공하는 기능이다. 함수 오버로딩이란 같은 이름의 함수를 여러 개 정의하는 것이다. overloading은 너무 많음이라는 뜻을 가지고 있기에 원래 한 이름의 함수를 하나만 정의해야 하지만, 편의상 같은 이름의 함수를 너무 많이 정의할 수 있도록 하는 기능이다.

책에서 보여주는 함수 내부에서 타입 검사(사용자 if)를 할 필요 없이 오버로딩을 통해 단순하게 다형성을 추가할 수 있다. 직관적이기는 하지만 중복성은 피하기 힘들다. 실제로 이를 줄이기 위해 또 내부의 동작을 함수로 묶고 나누는 과정이 많아지면 결국 배보다 배꼽이 커지는 경우가 많다. 결국은 또 트레이드 오프

1
2
write(c1, "hello");
write(c2, 42);

위와 같이 사용하는 오버로딩 함수에서 어떤 것을 호출할지는 언어 수준에서 자동으로 결정된다. 이렇게 함수가 오버로딩되어 있을 때 호출할 함수를 자동으로 고르는 것을 함수 선택이라 부른다.

  • 함수 선택 규칙
    • 인자의 타입에 맞는 함수를 고른다.
    • 인자의 타입에 가장 특화된 함수를 고른다.
    • 함수를 고를 때는 인자의 정적 타입만 고려한다.

어떻게 보면 오버로딩은 설계자, 제작자보다 사용자의 입장에서 조금 더 편리하게 사용할 수 있도록 하는 기능이라고 볼 수 있다.

오버로딩은 다형성을 만들어 내는 세 번째 방법이다. (첫 번째는 서브타입, 두 번째는 제네릭) 그 이유는 write라는 이름은 (cell, string) => void 타입이면서 (cell, int) => void 타입인 것으로 이해할 수 있다. write라는 하나의 대상이 여러 타입을 가지는 것이다.

하나의 대상이 여러 타입을 가진다는 것이 다형성의 가장 기본적인 근본이라고 생각이 된다. 오버로딩도 다형성이라고 알고는 있었지만 타입 검사 입장에서 보니 기본적인, 통일된 개념을 잘 이해할 수 있었다.

그러므로 이 역시 다형성이다. 이렇게 같은 이름을 여러 번 정의함으로써 만들어지는 다형성을 오버로딩에 의한 다형성이라고 부른다.

오버로딩이란 함수 오버로딩, 메서드 오버로딩, 연산자 오버로딩을 모두 통틀어 일컫는 용어다.

가장 특화된 함수

앞서 다훈 함수 선택 규칙의 두 번째 항목인 인자의 타입에 가장 특화된 함수를 고른다 (더 구체화 된)은 개발자 입장에서 상당히 합리적인 규칙이다. 당연히 정의한 이유가 더 특화, 추가적인 기능이 있기 때문에 새로운 클래스를 담은 오버로딩 함수를 만들었기 때문이다.

동적 타입과 정적 타입은 실제로 다형성에서 가장 많이 다루는 개념이다. 코드상으로 보이지않는 동적 흐름을 자연스럽게 설계해야 유연한 코드가 된다.

대부분의 언어가 함수 선택 시 정적 타입만을 고려하기 때문에 이 정적 선택을 잘 이해해야 한다. 실제 내 의도와 다르게 동작하기 때문이다. 가장 간단한 방법은 서브타입이 존재할 때 가급적 같은 이름으로 함수를 추가 정의하는 일을 피하는 것이다.

메서드 오버로딩

메서드 오버로딩은 함수에서 메서드로 바뀌었을 뿐 개념은 같다.

메서드 오버라이딩

메서드 오버라이딩은 특화된 동작을 정의하는 가장 좋은 방법이다. 메서드 오버라이딩은 클래스를 상속해서 자식 클래스에 메서드를 새로 정의할 때 메서드의 이름과 매개변수 타입을 부모 클래스에 정의되어 있는 메서드와 똑같게 정의하는 것을 말한다.

가장 쉽게 다형성을 효과적으로 볼 수 있는 기초적인 방법

overriding이라는 단어의 사전적인 뜻은 ‘자동으로 진행되는 동작을 사람이 개입하여 중단시킨 뒤 스스로 조작하는 것’이다. 이를 바탕으로 메서드 오버라이딩을 이해하자면 vector에 있던 length 메서드가 상속을 통해 자동으로 SparseVector에도 정의되는 것을 개발자가 개입하여 막은 뒤 기존 length 메서드와 다른 동작을 수행하는 새로운 length 메서드를 정의하는 것이다.

함수 오버로딩 즉, 메서드 오버로딩과 다른 점은 정적 타입이 vector라도 sparseVector의 메서드를 호출하여 실행한다는 점이다.

1
2
Vector v = new SparseVector();
v.length();

이 코드는 동적 타입으로 동작한다. 이처럼 메서드 오버라이딩을 사용하면 서브타입을 위해 더 특화된 동작을 정의하고, 정적 타입에 상관없이 언제나 그 특화된 동작이 사용되도록 만들 수 있다. 앞서 개발자에게 더 인지적인 두 가지 동작 모두 충족

책에서는 타입 검사기의 측면에서 다루지만 C++을 조금 공부한다면 실제 메모리 구조 즉, 객체의 타입이 어떻게 담기고 운용되는지를 알면 이는 간단한 문제이다.

  • 메서드 선택 규칙
    • 인자의 타입에 맞는 함수를 고른다.
    • 인자의 타입에 가장 특화된 함수를 고른다.
    • 메서드를 고를 때는 인자의 정적 타입만 고려한다.
    • 메서드를 고를 때는 수신자의 동적 타입도 고려한다.

메서드 선택의 한계

앞서 다룬 수신자의 동적 타입을 고려한다하는 것만으로 모든 문제가 해결되지 않는다. 문제는 수신자의 동적 타입만 고려하고 인자의 동적 타입은 고려하지 않는 데서 온다.

책에서 나오는 예제와 같이 단순한 경우에는 문제가 없지만 덧셈과 같이 타입이 다르게 나뉘는 경우(반환자와 매개변수의 차이)에서 수신자(반환자)는 정적타입이지만 메서드 오버라이딩이기에 동적 타입을 읽고, 매개변수는 그대로 정적 타입으로 해석하기 때문에 비효율적인 문제가 생길 수 있다.

이는 결국 처음에 말한 개발자의 기대와 다른 동작이 발생하는 것이다. 이를 해결하기 위해서 매개변수의 실제 타입을 뽑아낼 수 있는 this키워드를 사용하여 해결이 가능하긴 하다.

메서드 오버라이딩 결과 타입

지금까지 다룬 매개변수에서만 오버로딩을 다뤘는데 결과타입도 마찬가지로 다형성이 가능하다. 반면 메서드 오버라이딩을 할 때는 결과 타입을 아무렇게나 해서는 안 된다. 자식 클래스에 정의한 메서드의 결과 타입이 부모 클래스에 원래 있는 메서드의 결과 타입의 서브타입이어야 한다. 모든 타입은 자기 자신의 서브타입이니 이 조건은 두 메서드의 결과 타입이 같은 경우도 포함한다.

C#의 경우 오버라이딩할 때 결과 타입이 달라지는 것을 허용하지 않는다.

또한, 동적 선택으로 인해 실행 전에 타입 검사기가 참고하는 메서드와 실제 실행 중에 호출되는 메서드가 다를 수 있다. 타입 검사기는 정적 타입밖에 모르니 수신자의 정적 타입을 바탕으로 참고할 메서드를 정하는 데 반해, 실행 중에는 수신자의 동적 타입이 호출되는 메서드를 결정하기 때문이다.

이로 인한 문제를 막기 위해선 타입 검사기가 참고한 메서드와 다른 메서드가 호출되더라도 참고한 메서드의 결과 타입이 지켜지도록 해야 한다. 따라서 자식 클래스가 있는 메서드의 결과 타입이 부모 클래스에 있는 메서드의 결과 타입의 서브타입이어야 한다는 조건이 꼭 필요하다.

타입 클래스

이번에는 오버로딩에 의한 다형성을 매개변수에 의한 다형성과 함께 사용하는 방법을 알아볼 차례다. C#에는 존재하지 않는 기능이지만 다른 언에에선 유용하게 사용한다.

기존 정렬 .gt()메서드를 사용하여 정렬을 하는 부분에서 메서드로 두지 않고 함수를 오버로딩하여 구현하는 방법도 있다. 다만 T*(제네릭)은 아무 타입이나 될 수 있기에 타입 검사를 통과하지 못한다. 이에 상한 타입을 지정하여 이를 해결할 수 있었다.

결국 이 개념은 타입이 (T, T)인 gt함수가 있어야 한다는 조건이다. 즉, 특정 타입을 위한 함수가 존재한다는 사실을 표현하는 개념인 타입 클래스가 등장한다. 클래스라는 용어가 들어갔다고 클래스를 뜻하는 것은 아니다. 다만 타입이 만족해야 조건을 표현한다는 점에서 추상 클래스와 비슷한 역할을 한다.

1
2
3
typeclass Comparable<T> {
  bool gt(T x, T y);
}

이 코드의 뜻은 “어떤 타입 T가 Comparable타입 클래스에 속하려면 매개변수 타입이 (T, T)이고 결과 타입이 Boolean인 함수 gt가 있어야 한다.” 라는 뜻이다.

특정 타입을 어떤 타입 클래스에 속하게 만들고 싶다면 타입클래스 인스턴스를 정의해야 한다. 타입클래스 인스턴스를 정의할 때는 해당 타입과 타입클래스의 이름을 명시한 뒤 타입클래스가 요구하는 함수를 모두 정의하면 된다.

1
2
3
4
5
instance Comparable<int> {
  bool gt(int x, int y) {
    return x > y;
  }
}

마찬가지로 다른 타입도 생성하여 이 타입클래스에 속하는 인스턴스를 생성할 수 있다. 오버로딩 함수와 같이 사용가능하다. 그렇다면 오버로딩을 사용하면 되는거 아닌가? 라는 생각이 들지만 타입 클래스는 오버로딩이 가지지 못하는 장점을 가지고 있다.

우선 함수는 아무 때나 정의할 수 있지만 메서드는 클래스를 정의할 때만 정의할 수 있다. 대부분 개발자는 라이브러리에 있는 코드를 사용하는데 그 코드들은 수정할 수 없다. 따라서 클래스를 정의할 때만 메서드를 정의할 수 있다는 점이 치명적이다.

타입 클래스의 장점은 두 가지로 기존 클래스의 실질적 확장 없이도 영향을 주지 않고 확장 가능하다. 두 번째는 제네릭 타입을 다룰 때 나오는 한 번에 여러 타입을 특정 타입 클래스에 속하게 만들 수 있다. 이 장점들은 C#에서 처음엔 쉽게 대체 가능하다고 생각했지만 아니다. c#의 인터페이스로 이를 해결하려 해도 역시 기존 클래스에 영향을 주거나 서브타입을 만들어야 하고(or 어댑터 패턴), 반대로 확장 메서드의 경우도 기존 상속 구조에 영향을 주지 않는 편법이기 때문에 타입 클래스의 장점을 모두 가지지 못한다.

또한, 추상 클래스(인터페이스)를 사용할 때 일부만 비교 가능한 선택지는 존재하지 않는다. Comparable을 상속함으로 모든 List를 비교할 수 있도록 만들거나 Comparable을 상속하지 않음으로 모든 List를 비교할 수 없도록 만들어야 한다.

타입 클래스는 Comparable에 속하는 각각의 T마다 List를 Comparable에 속하게 만든느 타입클래스 인스턴스를 다음과 같이 한 번에 정의할 수 있다.

1
2
3
4
5
instance <T> Comparable<List<T>> requires Comparable<T> {
  Boolean gt(List<T> v1, List<T> v2) {
    gt(v1[i], v2[i]) ...
  }
}

이 코드는 ‘각각의 타입 T마다 List가 Comparable에 속한다. 단 T가 Comparable에 속할 때만'이라는 뜻이다. 따라서 List<List<List>>와 같은 사용도 가능해진다.

카인드

카인드란, 제네릭이라는 어떤 타입도 가능하다라는 명목에서 ArrayList와 같은 타입의 값이 존재하지 않기에 이런 타입 분류를 설명하기 위해 도입된 개념이 카인드이다. *ArrayList가 Int를 받아 ArrayList라는 타입을 만든다.*

  • 카인드의 종류
    • * : 모든 타입을 받을 수 있는 카인드
      • int, string의 카인드
    • * => * : 타입을 받아 타입을 반환하는 카인드
      • ArrayList의 카인드
    • (,) => * : 두 개의 타입을 받아 타입을 반환하는 카인드
      • map

정리

카인드와 타입클래스는 C계열에 속하지않는 하스칼, 러스트등에 존재하는 개념들이라 어색했지만 차이점과 장점에 대해서 알게 되서 다른 언어에 대한 흥미도 생긴 것 같다. 오버로딩 또한 단순하게 사용만 하다보니 실제 타입 검사기의 규칙에 대해서 생각해보지 않았다. 그 이유는 명확하고 C#의 설계 이념도 알아볼 수 있어서 좋았다.

논의사항

타입 클래스의 장점에 대해서 접하다 보니 C#의 인터페이스나 추상 클래스의 경우 미리 설계해야 한다는 점 그리고 나아가 객체지향적으로 설계하여 지속적인 확장과 수정이 가능하도록 하는 것과 비교하여 타입 클래스는 좀 더 단순하게 사후에도 확장이 가능하다는 것이 매력적으로 느껴집니다.

물론 객체지향적으로 작성한 코드도 충분히 확장 가능하지만, 타입 클래스에 비해 더 많은 고민과 설계가 필요하기에 (실제 개발에서 자주 발생하기에) 반대로 타입 클래스의 단점에 대해서 논의해보면 좋을 것 같습니다.

제 생각에는 타입 클래스를 너무 남발하게 되면 다른 설계에서 말하는 오용하거나 불필요한 확장, 최적화가 되지 않을까? 생각도 들고 반대로 다양한 타입을 대처하기 위한 인스턴스 남발로 인한 중복성도 걱정됩니다 (수정시 모든 정의 수정해야함)

댓글남기기