프로그래밍 언어 시리즈
- 9가지 프로그래밍 언어로 배우는 개념: 1편 - 타입 이론
- 9가지 프로그래밍 언어로 배우는 개념: 2편 - 다형성
- 9가지 프로그래밍 언어로 배우는 개념: 3편 - 메타프로그래밍
- 9가지 프로그래밍 언어로 배우는 개념: 4편 - 하이 레벨 언어와 동적 타입 언어
- 9가지 프로그래밍 언어로 배우는 개념: 5편 - 동시성 프로그래밍
서론
안녕하세요, 킹덤 서버 소프트웨어 엔지니어 지민규입니다. 프로그래밍에 있어 추상화만큼이나 중요한 것은 다형성입니다. 다형성으로 인해 다양한 데이터 타입과 오브젝트의 재사용이 가능해지며 필요한 코드의 양을 획기적으로 줄입니다. 다형성을 이룩하는 방법에는 여러가지가 있으며 흔히 객체 지향 프로그래밍(Object-oriented Programming)의 특징으로 다형성이 꼽히곤 하지만 사실 절차적 프로그래밍(Procedural Programming) 언어이든 함수형 프로그래밍(Functional Programming) 언어이든 다형성을 어떠한 방식으로든 지원하는 경우가 많습니다. 이번 글에서는 언어 기능으로서의 다형성들을 나열하고 비교합니다.
오버로딩
**오버로딩(Overloading)**은 같은 함수명에 다른 입출력 타입들을 가진 함수를 정의하여 하나의 함수 이름으로 묶을 수 있는 기 능입니다. 프로그래밍에서는 이름이 추상화의 기초 요소라는 점을 생각해보면 오버로딩은 다형성 기법이라기보다 추상화 기법에 가깝다고도 생각할 수 있습니다. 두 숫자를 받아서 합을 출력하는 함수를 int
와 float
버전으로 구현해봅시다. C의 경우 오버로딩이 없기 때문에 서로 다른 함수 이름으로 구현해야 합니다.
// C 코드
int addInt(int a, int b) {
return a + b;
}
float addFloat(float a, float b) {
return a + b;
}
C++에서는 오버로딩이 추가되었기 때문에 다음과 같이 처리가 가능합니다.
// C++ 코드
// A함수: int끼리 add 하는 함수
int add(int a, int b) {
return a + b;
}
// B함수: float끼리 add 하는 함수
float add(float a, float b) {
return a + b;
}
int a = add(1, 1); // 여기서는 int끼리 add 하는 A함수를 쓴다.
float b = add(1.0f, 1.0f); // 여기서는 float끼리 add 하는 B함수를 쓴다.
하지만 오버로딩은 남용될 가능성이 있습니다. 함수 간의 동작 차이가 클 경우 함수가 어떤 일을 할지 예측하기 어려워지며 직관성을 떨어트리고 실수로 잘못된 함수를 부를 가능성이 있습니다.
// C/C++ 코드
int getGrade(int score) {
return a * 2 / 100;
}
int getGrade(string gradeString) {
return toInt(trim(a)); // 앞뒤 공백 삭제 후 int로 파싱
}
int b = getGrade(getScore()); // 과연 어느 함수가 호출될까?
Go와 Rust는 오버로딩이 가져오는 이점보다 위험성이 더 높다고 판단하여 오버로딩을 지원하지 않습니다.
오버로딩과 유사한 기능으로는 인수 기본 값 기능입니다. 인수 기본 값이란 함수를 호출할 때 인수의 값을 지정하지 않았을 경우, 대신 기본 값을 넣어주는 기능입니다. 인수 기본 값은 하나의 함수 블록을 사용하므로 오버로딩에 비해 상대적으로 안전하나, 여전히 잘못 사용하면 버그를 가져옵니다.
// C++ 코드
// score의 기본값은 100으로 설정한다
int getGrade(int score = 100) {
return a * 2 / 100;
}
int score = getScore();
int grade = getGrade(); // 실수로 score 변수를 사용하지 않았다!
인터페이스
**인터페이스(Interface)**는 대부분의 프로그래밍 언어가 어떠한 형태로든 지원하는 다형성 기법입니다. 하지만 언어마다 걸린 제약과 기능이 조금씩 다릅니다. 과거 Java, C#의 인터페이스에는 추상 메소드만을 포함할 수 있었으나, 최근에는 인터페이스 메소드에 구현을 포함할 수 있게 되었습니다.
// Java 코드
public interface Food {
int getCalories();
int getWeight();
default float getCaloriesPerWeight() {
return getCalories() / getWeight();
}
}
인터페이스를 이용하면 다음과 같은 방식으로 다형성을 이룰 수 있습니다.
// Java 코드
public interface Food {
int getCalories();
}
public class Pizza implements Food {
int sliceCount; // 피자 조각 개수
@Override public int getCalories() {
return sliceCount * 300;
}
}
public class Burger implements Food {
public enum BurgerType { CHEESE, BACON_CHEESE }
BurgerType burgerType; // 버거의 종류
@Override public int getCalories() {
switch (burgerType) {
case CHEESE:
return 700;
case BACON_CHEESE:
return 900;
}
}
}
Food pizza = cookPizza();
Food burger = cookBurger();
// 하나의 Food 타입으로 두 가지 다른 동작을 한다.
int pizzaCalories = pizza.getCalories();
int burgerCalories = burger.getCalories();
이러한 합 타입 데이터에 대한 인터페이스는 타입 이론 포스트에서도 이야기했듯 완전 패턴 매칭으로 대체할 수 있습니다. 완전 패턴 매칭을 이용하면 더 직관적이고 문맥에 맞는 코드를 구현할 수 있습니다.
// Scala 코드
sealed trait BurgerType
case object Cheese extends BurgerType
case object BaconCheese extends BurgerType
sealed trait Food
case class Pizza(pieceCount: Int) extends Food
case class Burger(burgerType: BurgerType) extends Food
def getCalories(food: Food): Int = food match {
case Pizza(pieceCount) =>
pieceCount * 300
case Burger(burgerType) =>
burgerType match {
case Cheese => 700
case BaconCheese => 800
}
}
val food: Food = getPizza()
val calories: Int = getCalories(food)
// Rust 코드
pub enum BurgerType {
Cheese,
BaconCheese,
}
pub enum Food {
Pizza { piece_count: i32 },
Burger{ burger_type: BurgerType },
}
pub fn get_calories(food: &Food) -> i32 {
match food {
Food::Pizza { piece_count } => piece_count * 300,
Food::Burger { burger_type } =>
match burger_type {
BurgerType::Cheese => 700,
BurgerType::BaconCheese => 800,
},
}
}
let food: Food = get_pizza();
let calories: i32 = get_calories(&food);
제네릭
**제네릭(Generics)**은 함수나 타입을 선언할 때 고정된 타입 뿐만 아니라 가변적인 타입을 사용할 수 있게 해줍니다. 어떤 타입이 사용되는지는 함수를 호출하거나 타입의 인스턴스를 생성할 때 결정됩니다. 제네릭 타입은 특히 자료 구조 타입에서 자주 쓰입니다.
// Java 코드
public class ArrayList<T> {
private T[] elementData;
private int size;
public boolean add(T e) {
/* ... */
elementData[size++] = e;
return true;
}
}
ArrayList<Integer> intList = new ArrayList<Integer>();
ArrayList<String> stringList = new ArrayList<String>();
// ArrayList<Integer>의 add는 Integer를 받는다
intList.add(5);
intList.add(10);
intList.add(15);
// ArrayList<String>의 add는 String를 받는다
stringList.add("one");
stringList.add("two");
stringList.add("three");
그러나 제네릭 함수는 Java나 C#과 같은 상속 기반의 객체 지향 프로그래밍 언어에서는 상대적으로 적게 쓰입니다. 상속을 위시로 한 다형성은 데이터 타입을 그대로 인수로 전달하는 경우보다 인터페이스나 추상 클래스를 사용하는 경우가 많습니다. 다만 입출력 타입에 제네릭을 사용하고 있다면 제네릭 함수는 필수적입니다.
// Java 코드
// value를 size 만큼 채워서 ArrayList를 출력하는 함수
public <T> ArrayList<T> fill(T value, int size) {
ArrayList<T> ret = new ArrayList<T>();
for (int i = 0; i < size; i++) {
ret.add(value);
}
return ret;
}
// a와 b를 비교해서 더 높은 것을 출력하는 함수
// T는 Comparable<T> 인터페이스를 구현하고 있어야 한다
public <T extends Comparable<T>> T max(T a, T b) {
if (a.compareTo(b) >= 0) {
return a;
} else {
return b;
}
}
C++의 제네릭은 **템플릿(Template)**이라고 부릅니다. 템플릿은 다른 언어의 제네릭과 비교하면 특이한 점이 많은데 타입 인수에 들어간 타입이 마치 틀로 찍어낸 듯이 대입되어 함수가 구현된 것과 같습니다. 임의의 타입 2개를 받아서 더하는 add
함수를 구현할 때 템플릿을 이용하면 인터페이스 없이, 하나의 함수로 구현할 수 있습니다.
// C++ 코드
template<typename T>
T add(T a, T b) {
return a + b;
}
int a = add<int>(10, 5); // 여기서는 T가 int이다.
float b = add<float>(7.5f, 2.0f); // 여기서는 T가 float이다.
// T 위치에 지정된 타입을 치환하여 따로 구현한 것과 동일하다.
// int add(int a, int b) {
// return a + b;
// }
//
// float add(float a, float b) {
// return a + b;
// }
C++의 템플릿은 필드 접근도 일반화시킬 수 있습니다. 심지어 필드가 서로 다른 타입이어도 상관없습니다.
// C++ 코드
template<typename T>
void printValue(T a) {
cout << a.value << endl;
}
struct A {
int value;
};
struct B {
float value;
};
A a = { 5 };
B b = { 10.5f };
printValue(a);
printValue(b);
이처럼 C++의 템플릿은 C/C++의 매크로와 필적할 정도로의 강력함을 가지고 있습니다. 그러나 단점도 많은데 그중 하나가 Variance가 잘 적용되지 않는다는 점입니다.
Variance
Variance는 제네릭 타입간의 자연스러운 타입 캐스팅을 지원해주는 기능입니다. 제네릭 타입을 사용할 때 간혹 내부 T
타입을 바꿔줘야 원활한 경우가 있는데 구현에 따라 캐스팅이 안전한 타입이 있고 캐스팅이 안전하지 않은 타입들이 있습니다. Variance가 있으면 이러한 타입 캐스팅이 유효한지 컴파일 타임에 검증해줄 수 있습니다.
Variance는 특이하게도 각각의 프로그래밍 언어들이 서로 다른 방식으로 구현하고 있습니다. C++에서는 템플릿 특성상 Variance를 지원하지 않으며, C#, Kotlin은 인터페이스에서만 Variance를 허용하고 있고 Typescript에서는 모든 제네릭 타입에 Variance를 활용할 수 있습니다. Rust의 경우 로우 레벨 언어 특성상 Variance를 적용할 때의 세세한 규칙들이 있습니다.
// C# 코드
// 인터페이스에서만 활용 가능하다
IEnumerable<string> stringList = new List<string>();
IEnumerable<object> objectList = stringList;
// Kotlin 코드
// 인터페이스에서 활용 가능하다
val intList: List<Integer> = listof()
val objectList: List<Object> = intList
// Scala 코드
// 자유롭게 가능하다
val intList: List[Int] = List()
val anyList: List[Any] = intList
// Typescript 코드
let intArray: Array<number> = []
let anyArray: Array<any> = intArray
// Rust 코드
let i32Box: Box<i32> = Box::new(4);
let anyBox: Box<dyn Any> = i32Box;
위 예시에서는 Integer
가 Object
가 되는 예시를 들었는데 반대로 Object
가 Integer
가 되는 것만 허용되는 경우도 있습니다. C#의 IComparer<T>
가 그 예시입니다.
// C# 코드
// C#의 IComparer<T>의 대략적인 구현
public interface IComparer<in T> {
int Compare(T x, T y);
}
IComparer<object> objectComparer;
// stringComparer에 objectComparer를 대입할 수 있다.
IComparer<string> stringComparer = objectComparer;
Variance는 기본적으로 **업캐스팅(Upcasting)**을 기반으로 하고 있습니다. 업캐스팅이란 타입의 계층 구조에서 하위 계층에 존재하는 타입은 상위 계층으로 아무 위험성 없이 캐스팅 될 수 있다는 것을 가리킵니다. C#에서 string
은 object
를 상속 받고 있기 때문에 object
에 대입할 수 있습니다
// C# 코드
string str = "Hi";
object obj = str;
이는 곧 String
를 출력하는 함수는 곧 Object
를 출력하는 함수로 취급해도 괜찮다는 것을 의미합니다.
// C# 코드
// int를 string으로 변환시키는 익명 함수
Func<int, string> toString = x => x.ToString();
// 출력 타입을 object로 취급해도 괜찮다
Func<int, object> func = toString;
// 어차피 둘 다 출력을 object 타입에 대입시킬 수 있다
object toStringOut = square(5);
object funcOut = func(5);
이것을 인터페이스의 맥락으로 가져오면 다음과 같습니다.
// C# 코드
public interface IGenerator<out T> {
T Generate();
}
// string를 생성하는 인터페이스
public class StringGenerator : IGenerator<string> {
public string Generate() {
return "Hi";
}
}
// int generate() 메소드는 Object generate()로 변환이 가능하다
IGenerator<string> stringGenerator = new StringGenerator();
IGenerator<object> objectGenerator = stringGenerator;
이렇게 하위 타입 T
가 상위 타입으로 변환될 수 있는 경우를 Covariant이라고 부릅니다.
그렇다면 반대의 경우는 어떨까요?
// C# 코드
// object를 string으로 변환시키는 익명 함수
Func<object, string> toString = x => x.ToString();
// 입력 타입을 string으로 취급해도 괜찮다
Func<string, string> func = toString;
// 어차피 둘 다 입력으로 string를 받을 수 있다
string toStringOut = toString("hello");
string funcOut = func("hi");
// 인터페이스에서는...
public interface IConsumer<in T> {
void consume(T v);
}
// object를 입력받는 인터페이스
public class ObjectConsumer : IConsumer<object> {
public void consume(object v) {
Console.WriteLine(v.ToString());
}
}
// void consume(object value) 메소드는
// void consume(string value)로 변환이 가능하다.
IConsumer<object> objectConsumer = new ObjectConsumer();
IConsumer<string> stringConsumer = objectConsumer;
상위 타입T
가 하위 타입으로 변환될 수 있는 경우를 Contravariant이라고 부릅니다.
Variance의 방향은 제네릭을 어떤 방식으로 활용하고 있는지에 따라 갈립니다. T
를 출력하는 메소드가 있다면 Covariant, 입력으로 T
를 받는 메소드가 있다면 Contravariant가 되고 타입의 필드에서 T
를 어떤 방식으로 활용하고 있는지도 중요합니다.
그렇다면 Java의 ArrayList<T>
같이 T
를 입력 받기도 하고 T
를 출력하기도 하는 타입은 어느 것일까요? 정답은 “둘 다 아니다” 입니다. ArrayList<T>
의 T
는 바꿀 수 없고 이 경우를 Invariant 이라고 부릅니다.
Scala의 합 타입은 상속을 이용하여 구현되어있기 때문에 합 타입의 하위 타입과 상위 타입간의 업캐스팅이 가능합니다.
// Scala 코드
sealed trait Fruit
case class Apple(weight: Int) extends Fruit
case class Banana(length: Int) extends Fruit
val appleList: List[Apple] = List()
val fruitList: List[Fruit] = appleList
인터페이스의 한계
제네릭과 Variance에 대해서 충분히 이해했다면 상속이 있는 언어들에서 인터페이스에 담긴 언어적 한계를 파악할 수 있습니다. 인터페이스에는 총 두 가지의 문제점이 있습니다.
- 자기 자신의 타입을 사용하는 메소드를 선언할 수 없다.
- 정적 메소드를 추상 메소드로 선언할 수 없다.
먼저 첫 번째 문제를 파헤쳐봅시다, 자기 자신의 타입을 사용하는 메소드를 선언할 수 없다는 것이 무슨 의미일까요? 가령 Java에서 숫자 타입의 덧셈을 인터페이스로 구현해봅시다. int
의 덧셈 메소드는 int
인수를 받고 덧셈을 하여 int
를 출력 해야합니다. double
의 덧셈 메소드는 double
을 받고 덧셈을 하여 double
을 출력 해야 합니다. 이러한 덧셈을 하나의 인터페이스로 일관화 시킬 수 있을까요? Java는 int
에 메소드를 추가할 수 없으므로 int
를 감싸는 CustomInt
타입으로 인터페이스를 충족시켜봅시다.
// Java 코드
public interface Addable {
Addable add(Addable value); // CustomInt를 사용할 수 없다
}
public class CustomInt implements Addable {
public int value;
public CustomInt(int value) {
value = value;
}
@Override public Addable add(Addable rhs) {
return new CustomInt(value + ((CustomInt) rhs).value); // 캐스팅 해야 함
}
}
Addable a = new CustomInt(10);
Addable b = a.add(new CustomInt(5)); // Addable 타입이 출력된다
Addable
인터페이스에서 메소드에 CustomInt
를 명시할 수 없으므로 어쩔 수 없이 Addable
에 제네릭 T
를 추가하고 CustomInt
가 Addable<CustomInt>
를 상속받도록 해야 합니다.
// Java 코드
public interface Addable<T> {
T add(T value);
}
public class CustomInt implements Addable<CustomInt> {
public int value;
public CustomInt(int value) {
value = value;
}
@Override public CustomInt add(CustomInt rhs) {
return new CustomInt(value + rhs.value);
}
}
Addable<CustomInt> a = new CustomInt(10);
CustomInt b = a.add(new CustomInt(5)); // CustomInt 타입이 출력된다
이런 방식으로 구현하면 어느정도 해결되는 것 처럼 보입니다. 하지만 CustomInt
를 상속받기 시작하면 문제가 나타납니다.
// Java 코드
public class CustomComplex extends CustomInt {
public int imaginaryValue;
public CustomComplex(int value, int imaginaryValue) {
super(value); // 부모의 생성자 활용
imaginaryValue = imaginaryValue;
}
// 나머지 함수는 그대로 상속받는다
}
CustomComplex a = new CustomComplex(1, 2);
CustomInt b = a.add(new CustomComplex(3, 4)); // CustomInt가 출력된다!!!
Addable<T>
인터페이스의 메소드를 곰곰히 생각해보면 T
를 입력으로 받고 T
를 출력하기 때문에 Invariant입니다. 그러므로 하위 타입인 CustomComplex
가 Addable<CustomInt>
의 메소드를 사용할 수 있는 것은 옳지 않습니다. 그러나 이를 무시하고 추가한 결과 CustomComplex
의 add
메소드는 CustomInt
타입을 출력하고 있습 니다. 상속과 Variance를 섞어서 활용할 경우 이러한 모순적인 코드 나타납니다.
두번째 문제로 넘어갑시다. 9개의 언어 중 Rust를 제외하고 정적 메소드를 추상 메소드로 구현 할 수 있는 언어는 없습니다. 정적 추상 함수는 일반적으로는 의미가 없으나 제네릭과 함께 사용할 때 매우 유용합니다. 예를 들어 인스턴스 생성 함수를 인터페이스 메소드로 추가할 수 있습니다.
// Java 코드, 주의: 컴파일할 수 없는 코드
public interface Entity {
Entity parseFromString(String string);
}
public class Human implements Entity {
private String name;
@Override public Entity parseFromString(String string) {
Human human = new Human();
human.name = string;
return human;
}
}
public class Sword implements Entity {
private int attackPoint;
@Override public Entity parseFromString(String string) {
Sword sword = new Sword();
sword.attackPoint = Integer.parseInt(string);
return sword;
}
}
// T는 Entity를 상속받음
public <T extends Entity> Entity parseEntityFromString(String string) {
return T.parseFromString(string);
}
Entity human = parseEntityFromString<Human>("mango");
Entity sword = parseEntityFromString<Sword>("120");
이 문제 때문에 일반적으로 객체 지향 프로그래밍 언어에서는 **팩토리 패턴(Factory Pattern)**으로 우회해서 구현합니다. 하지만 이는 코드의 불필요한 복잡도 상승과 오브젝트 간의 강한 결속을 야기합니다.
Java, C#, Kotlin 등의 객체 지향 프로그래밍 언어에서 제네릭 함수의 활용성이 떨어지는 것은 이러한 인터페이스의 한계점과 연관이 있습니다. 그렇다면 Scala나 Rust에서는 어떠한 방식으로 해결되었을까요?
타입클래스
타입클래스는 인터페이스와 비슷하게, 타입간에 공통된 추상 함수를 지정할 수 있습니다. 다만 인터페이스는 타입의 메소드로 구현하지만 타입클래스는 타입과 분리된 영역에서 함수들을 구현합니다. 그렇기 때문에 상속 문제에서 자유로우며 모든 함수가 자연스럽게 정적이므로 정적 메소드 문제는 피할 수 있습니다.
Scala는 숫자 타입의 연산 타입클래스를 다음과 같이 일반화하여 제공하고 있습니다.
// Scala 코드
// "숫자"를 위한 타입클래스
// 인터페이스와 제네릭을 섞어서 타입클래스를 만든다
trait Numeric[T] {
def plus(lhs: T, rhs: T): T
/* ... */
}
// Numeric를 상속받지 않는다
case class CustomInt(value: Int)
// Numeric[CustomInt]의 정의
implicit val numeric: Numeric[CustomInt] =
new Numeric[CustomInt] {
def plus(lhs: CustomInt, rhs: CustomInt): CustomInt =
CustomInt(lhs.value + rhs.value)
/* ... */
}
// T는 Numeric 타입클래스를 가지고 있어야 한다
def addThree[T: Numeric](first: T, second: T, third: T) =
first + second + third
// addThree 함수를 자유롭게 사용할 수 있다
val intAdds: Int =
addThree(5, 10, 20)
val customIntAdds: CustomInt =
addThree(CustomInt(5), CustomInt(10), CustomInt(20))
val floatAdds: Float =
addThree(0.5, 1.2, 500.0)
Rust의 인터페이스에 해당하는 trait
는 자기 자신 타입을 지정할 수 있습니다. 이것이 가능한 이유는 Rust는 상속 자체가 없어 부모로부터 잘못된 메소드를 상속받을 일이 없기 때문입니다. 또한 Rust는 인터페이스에서 정적 메소드도 선언할 수 있습니다. 이러한 점들을 살펴보면 Rust의 trait
는 타입클래스와 많이 닮아 있다는 것을 알 수 있습니다.
// Rust 코드
// 덧셈을 위한 trait, Self는 자기 자신 타입이다
pub trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
// Rust에는 상속이 없다
#[derive(Clone)]
struct CustomInt(i32);
// Add trait 정의
impl Add for CustomInt {
type Output = CustomInt;
fn add(self, other: Self) -> Self {
CustomInt(self.0 + other.0)
}
}
// T는 Add<Output = T>를 구현하고 있어야한다
pub fn add_three<T: Add<Output = T>>(first: T, second: T, third: T) -> T {
first + second + third
}
// Add 함수를 자유롭게 사용할 수 있다.
let int_adds: i32 =
add_three(5, 10, 20);
let custom_int_adds: CustomInt =
add_three(CustomInt(5), CustomInt(10), CustomInt(20));
let float_adds: f32 =
add_three(0.5, 1.2, 500.0);
이렇게 주요 프로그래밍 언어들의 다형성 기능들을 대부분 살펴보았는데요, 이렇듯 언어들이 한 가지 다형성만이 아닌 여러가지 다형성을 제공하는 이유는 다형성마다 장단점이 있고 적절한 장소에 적당한 다형성 기능을 사용할 수 있게 해주기 때문입니다.
하지만 다형성을 사용한다고 해서 언제나 소프트웨어 아키텍처를 확장할 때 코드의 양이 필요한 만큼만 작성되는 것이 아니며 때로는 위 다형성 기능들로 해결이 안되는 반복적인 **보일러플레이트 코드(Bolierplate code)**가 작성되어야하기도 합니다. 다음 편에서는 이러한 보일러플레이트 코드가 어떻게 **메타 프로그래밍(Metaprogramming)**으로 해결되는지 다루고자 합니다.
부록
프로그래밍 언어 기능별 지원표, 2022년 5월 기준
언어 자체 기능 혹은 정규 라이브러리만 포함하고 있습니다.
C | C++ | C# | Java | Kotlin | Scala | Rust | Typescript | Go | |
---|---|---|---|---|---|---|---|---|---|
Nullable Type | X | X | O | O | O | O | O | O | X |
합 타입 | X | X | X | O | O | O | O | O | O |
합 타입 완전 패턴 매칭 | X | X | X | O | O | O | O | 부분적 | X |
오버로딩 | X | O | O | O | O | O | O | O | X |
상속 | X | O | O | O | O | O | X | O | X |
오버라이딩 | X | O | O | O | O | O | O | O | X |
타입 클래스 | X | X | X | X | X | O | O | X | X |
제네릭 | X | O | O | O | O | O | O | O | O |
타입 선언부 Variance | X | X | 인터페이스만 | X | 인터페이스만 | O | O | O | X |
아쉽게도 다루지 못한 다른 다형성 기능들
- Wildcard (Java의 개념), Type Projection (Kotlin의 개념)
- Associated Type
- Kind (Higher-kinded types)
- 덕 타이핑(Duck typing)
- Constraint (C++의 개념)
힙스터라면 공부해볼 만한 다른 언어들
- Assembly
- 바로 그 어셈블리
- C보다도 로우 레벨
- 스택, 함수 호출, 다양한 최적화의 원리를 이해해보자
- Carbon
- 정적 타입
- 로우 레벨
- Better C++ 계열
- 2022년 7월 공개된 언어
- D
- 정적 타입
- 로우 레벨
- Better C++ 계열
- Erlang, Elixir
- 동적 타입
- 함수형
- 모든 것은 액터 모델
- Fault-tolerant
- Haskell
- 정적 타입
- 순수 함수형
- Jai
- 정적 타입
- 로우 레벨
- 메타 프로그래밍 특화
- Julia
- 동적 타입
- 고성능
- 계산 수학
- OCaml
- 정적 타입
- 함수형 패러다임 입문용으로 나쁘지 않음
- Zig
- 정적 타입
- 로우 레벨
- Better C 계열