Optimium 탐구(6) — 정적 타입 언어와 타입 추론

ENERZAi
15 min readMay 22, 2024

--

안녕하세요? ENERZAi에서 Nadya를 개발하고 있는 은승욱이라고 합니다. 지난 주 글에서 고성능 컴퓨팅을 위한 프로그래밍 언어 Nadya에 대해서 설명드렸는데요. 이번 주에는 Nadya를 설계할 때 고심을 많이 했던 “프로그래밍 언어에서의 타입 시스템”에 대해 알아보고자 합니다. 프로그래밍 언어가 타입 시스템에 따라 어떻게 나뉘고 각각이 어떤 장단점이 있는지 작성하였으니 많은 관심 부탁 드립니다. Let’s Go!

정적 타입 언어란?

정적 타입 언어(Statically typed language)란, 컴파일 시점에 해당 언어의 모든 값(Value)에 타입(Type)이란 태그가 붙어, 안정성을 검사하는 언어를 일컫습니다. 그에 반해 동적 타입 언어(Dynamically typed language)란, 컴파일 시점에 타입을 검사하지 않고, 실행 시간에 값의 타입이 결정되는 언어를 말합니다. 정적 타입 언어의 대표적인 예시로 C/C++, C#, Java, Go 등이 있고, 동적 타입 언어로는 Python, Javascript, Ruby가 대표적입니다.

정적 타입 언어

C코드를 예시로 작성해보겠습니다.

int main() {
int integer = 0;
const char *str = "String";
integer = str;
return 0;
}

위와 같은 코드를 작성했다고 한다면 integer = str 코드 부분에서 컴파일러가 불만을 내뱉으면서, 컴파일이 진행되지 않습니다.

test.c:4:10: error: incompatible pointer to integer conversion assigning to 'int' from 'const char *' [-Wint-conversion]
4 | integer = str;
| ^ ~~~
1 error generated.

이는 C 의 규칙에서 const char * 타입의 값을 int 타입의 변수로 대입하는 것을 허용하고 있지 않기 때문입니다. 컴파일 시점에 타입을 검사하여 위와 같이 타입 에러가 발생하는 경우, 사용자가 작성한 코드를 다음 단계로 넘기지 않고, 컴파일을 종료합니다. 이것이 정적 타입 언어의 대표적인 특징입니다.

동적 타입 언어

Python 코드를 예시로 작성해보겠습니다.

a = 10
a = "string"
a = { "key" : "value", "another key" : 10 }
print(a)

위 코드는 에러 없이 정상적으로 실행됩니다.

$ python test.py
{'key': 'value', 'another key': 10}

a 변수에 int 타입의 값이 할당되었지만, str 타입 또는 dict 타입의 값이 대입되어도 아무런 문제가 없습니다. 이는 Python이라는 언어의 규칙이 이러한 코드를 제한하고 있지 않기 때문입니다. 한편, 다음과 같은 코드를 작성하면 어떻게 될까요?

a = 10
a = "string"
a = { "key" : "value", "another key" : 10 }
print("run")
print(a.split('t'))

split 은 Python str 타입이 가지고 있는 메서드입니다. 만약 a"string" 이었다면, ['s', 'ring'] 을 출력하겠지만, a 는 마지막에 dict 타입 값이 대입 되었으므로 아래와 같은 출력을 확인할 수 있습니다.

$ python test.py
run
Traceback (most recent call last):
File "/home/test/test.py", line 5, in <module>
print(a.split('t'))
^^^^^^^
AttributeError: 'dict' object has no attribute 'split'

위 코드의 경우 dict 타입에는 split 속성이 없으므로, 에러를 발생시킵니다. 반면에, run 출력은 그대로 나온 것을 확인할 수 있는데요. 이는, dict 타입에 split 속성이 없다는 사실을 프로그램 실행 전까지 확인할 수 없다는 것을 의미합니다. 이처럼, 문법이 맞는 프로그램에 한하여, 실행 직전까지 타입을 확인하지 못하는 것이 동적 타입 언어의 대표적인 특징입니다.

정적 타입 언어의 장단점, 그리고 왜?

한 눈에 보아도, Python은 유연하고, 프로그램을 작성하는데 훨씬 자유롭습니다. 반면에, C의 경우 타입으로 인해 유연한 프로그램 작성이 어려워 일부 동적 타입 언어를 주로 사용하는 프로그래머들 중에는 거부감을 느끼시는 분들도 있는데요.

일반적으로 정적 타입 언어는 아래와 같은 장단점을 보유하고 있습니다. 다만, 아래의 장단점이 모든 언어, 모든 상황에 해당되는 것은 아니라는 점 참고 부탁 드립니다.

  • 장점
  1. 타입을 미리 검사하므로, 실행 시간에 발생할 에러를 줄일 수 있다.
  2. 프로그램의 속도가 빠르다.
  3. 타입 파악이 용이하므로, 프로그램 문맥 파악이 쉽다.
  4. IDE(Integrated Development Environment)에서 활용할 수 있는 정보가 많다. (자동 완성 등)
  • 단점
  1. 유연한 코드를 만들기 어렵다.
  2. 타입으로 인해, 간단한 로직도 장황하게 구현하게 된다.
  3. 러닝 커브가 동적 타입 언어에 비해 크다.

동적 타입 언어는 정적 타입 언어와 정확히 반대되는 장단점을 가진다고 볼 수 있습니다.

위 항목에서 다른 부분은 직관적이지만, 2. 프로그램의 속도가 빠르다. 는 어째서일까요? 왜 정적 타입 언어는 빠르고, 동적 타입 언어는 느릴까요?

컴퓨터와 메모리

이를 이해하기 위해서 아주 간단하게, 컴퓨터에 대해 이해하는 것이 필요합니다. 컴퓨터가 연산을 수행하는 대략적인 순서는 아래와 같습니다.

  1. 메모리를 조회하여, 레지스터에 전달한다.
  2. 레지스터끼리 연산기를 통해 연산을 진행하여 레지스터에 전달한다.
  3. 레지스터의 값을 메모리에 전달한다.

위 프로세스에 생략되어 있는 부분까지 구체적으로 나열하면 아래와 같습니다.

  1. 주소 A에 있는 메모리를 조회하여, N byte 자료를 담을 수 있는 레지스터에 전달한다.
  2. N byte와 M byte 레지스터의 데이터를 P 연산기를 통해 연산하여 K byte 레지스터에 전달한다.
  3. K byte 레지스터의 값을 주소 B에 저장한다.

모든 연산에 정확한 숫자가 정해져야만 위의 과정에 따라 컴퓨터가 동작할 수 있습니다. 그리고, 이런 동작을 문자로서 표현한 것이 어셈블리어(Assembly language)라고 할 수 있습니다.

정적 타입 언어는 모든 값의 타입이 정해져 있고, 각 타입마다 얼마만큼의 메모리를 차지하는지 추적할 수 있다면, 이 정보를 직접 기계어로 전달할 수 있습니다. 아래와 같은 C 프로그램을 예로 들어봅시다.

struct Struct {
int first; // 4 byte
const char *second; // 8 byte
long double third; // 8 byte
};

int main() {
struct Struct myStruct = {
.first = 10,
.second = "Second",
.third = 0.01L,
};
const char *str = myStruct.second;
return 0;
}

위에서 Struct 는 총 20 byte를 메모리에서 차지하는 자료형이며, 각각의 멤버가 Struct 에서 어느 자리에 위치하는지도 알 수 있습니다. Struct 값의 주소를 X 라고 하면, first 멤버의 시작 주소가 X , second의 시작 주소가 X + 4, third 멤버의 시작 주소가 X + 12 이 되는 것이죠. 각 타입도 알고 있으므로, 조회할 때 몇 byte의 자료를 어디서 조회해야하는지, 모든 정보를 다 알 수 있게 됩니다.

따라서, myStruct.first 호출의 기계어는 myStruct 의 메모리에 second 멤버의 주소 오프셋 4 를 더하고, 해당 메모리를 조회하는 코드로 구성됩니다.

한편, 동적 타입 언어의 경우 이러한 정보를 활용할 수 없습니다. 어떤 값의 타입을 정적으로 알 수 없으므로, 어떤 주소에서 얼마만큼의 데이터를 조회해야하는지 실행시점에 알아야합니다. 예컨대, 아래와 같은 간단한 덧셈 코드도 쉽게 실행할 수 없는 것이죠.

def add(a, b):
return a + b

따라서 Python 인터프리터는, 위와 같은 코드를 아래와 같은 의사코드로 실행합니다.

def int_add(left: int, right: int):
...

def string_add(left: str, right: str):
...

def float_add(left: float, right: float):
...

...

def add(a_value, a_attr, b_value, b_attr):
if has_attribute('add', a_attr, b_attr):
function = get_attribute('add', a_attr) ## int_add, string_add ... 중 하나로 매핑
function_call(function, a_value, b_value)
else:
raise RuntimeError(...)

정적 타입 언어는 단 몇 줄의 기계어만 생성되지만, 동적 타입 언어는 타입의 속성값을 값과 함께 가지고 다니며, 분기문을 통해, 정적인 기계어로 수행가능한 코드로 분배하는 과정을 거치게 됩니다. 추가적인 과정을 필요로 하기 때문에, 기계어에 비해 더욱 많은 시간이 소요될 수 밖에 없는 것이죠. 이로 인해, 컴파일 언어에 비해 적게는 수십배, 많게는 수천, 수만배 더 느리게 작동하게 됩니다.

또 한편, 위와 같이 동적으로 작동할 수 있도록 하는 기계어 코드를 만드는 작업 역시 난이도가 높고, 그만큼 효용이 크지 않기 때문에, Python, Ruby를 비롯한 동적 타입 언어는 대부분 인터프리터 또는 가상머신을 사용하는 구조를 채택하고 있습니다.

타입 추론

정적 타입 언어는 기계어로 번역하기 전, 표현식의 타입이 언어에서 규정한 Semantics(구문 규칙)을 만족하는지 확인하기 위해 모든 표현식의 타입을 알고 있어야합니다. 이상적으로는 아래와 같이 사용자가 모든 타입을 작성해주는 것이 좋습니다.

int main() {
int integer1 = 1 : int;
int integer2 = 2 : int;
return (integer1 : int + integer2 : int) : int;
}

하지만, 모든 프로그램을 이런 방식으로 작성해야한다면, 프로그래머는 불필요한 타입들을 작성하는데에만 많은 시간을 낭비하게 됩니다. 언어 설계자들은 사용자 편의성을 위해 불필요한 타입 작성을 최소화하는 방향으로 언어를 설계하며, 컴파일 과정에서 타입이 작성되지 않은 표현식에 타입을 채워넣는 과정을 타입 추론(Type inference)라고 합니다.

정적 타입 언어의 특성상, 이러한 타입 작성을 완벽하게 생략할 수는 없지만, 타입 작성을 최소한으로 줄이고, 코드를 보다 간결하게 만들기 위한 노력은 이미 많은 언어에서 관찰되고 있습니다. 아래에서 몇 가지 사례만 간단하게 살펴보겠습니다.

선언/반환 타입 생략

C++의 auto나 Rust의 let 선언문은 우변 표현식의 타입에 따라 변수의 타입을 결정합니다. 그 밖에도, Lambda 식이나, 함수의 리턴 타입을 생략할 수 있도록 하기도 합니다.

/// C++ 17

auto /* int */ add(int x, int y) {
return x + y;
}

int main() {
auto lambda = []() /* -> int */ {
return add(1, 2);
};
auto /* int */ result = lambda();
return result;
}
/// Rust 
fn main() {
let f = |a : i32, b : i32| a + b; // (i32, i32) -> i32
println!("{}", f(1, 2)); // 3
}

제네릭 타입

한편, 정적 타입 언어에서 값들은 하나의 타입을 가지므로, 타입은 다르지만 수행하는 코드의 동작이 같은 경우, 여러 개의 코드를 타입만 다르게 하여 작성해야하는 경우가 있습니다. 이러한 경우를 최대한 유연하게 처리하기 위하여 제네릭 타입(Generic type)을 도입하는 경우가 많습니다. 아래는 동등한 두 C++ 코드를 나타냅니다.

#include <string>

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

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

int main() {
auto intAdd = add(1, 2);
auto stringAdd = add("hello ", "world");
return 0;
}
#include <string>

template <typename T>
T add(T a, T b) {
return a + b;
}

/* instantiated functions

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

std::string add_string(std::string a, std::string b) {
return a + b;
}

*/

int main() {
auto intAdd = add<int>(1, 2); /* add_int(1, 2) */
auto stringAdd = add<std::string>("hello ", "world"); /* add_string("hello ", "world") */
}

하나의 제네릭 코드가, 사용자의 호출에 따라 여러 개의 함수로 재정의 됩니다. 제네릭 코드의 컴파일은 언어마다 다르지만, 일반적으로 제네릭 코드는 C++과 같이, 호출 시에 구문 트리를 재정의하며, 제네릭 타입이 일반적인 타입으로 채워지는 순간 타입 검사를 다시 진행합니다. 따라서, 어떤 경우에는 전혀 문제 없던 제네릭 코드가, 호출 하는 순간 타입 에러가 발생하기도 합니다.

#include <string>

template <typename T>
T add(T a, T b) {
return a + b;
}

struct myStruct {
int member;
};

int main() {
auto intAdd = add<int>(1, 2);
auto stringAdd = add<std::string>("hello ", "world");
auto myStructAdd = add<myStruct>(myStruct{1}, myStruct{2}); /// Type error!
}

제네릭 타입 추론

C++, Rust를 비롯한 일부 언어에서는 제네릭 타입을 유저가 명시하지 않더라도, 이를 추론하여 적절한 함수, 구조체에 접근하도록 기능을 제공하고 있습니다. 제네릭 코드를 아래와 같이 작성하더라도 문제 없이 작동합니다.

#include <string>

template <typename T>
T add(T a, T b) {
return a + b;
}

struct myStruct {
int member;
};

int main() {
auto intAdd = add(1, 2); // <int>
auto floatAdd = add(1.0f, 2.0f); // <float>
auto longdoubleAdd = add(1.0L, 2.0L); // <long double>
}

호출 시점에 인자들의 타입과 함수의 이름을 토대로 제네릭 타입을 추론하여 함수를 재정의합니다.

함수형 언어와 타입 추론

함수형 언어는 기본적으로 강력한 타입 추론 알고리즘을 통해 정적 타입을 구현하는 경우가 많습니다. 그런데 정적 타입 언어임에도 불구하고, 아래의 예시처럼 직접 사용해보면 타입을 작성해야만 하는 경우가 비교적 적습니다.

add a b = a + b

intAdd = f 1 2
floatAdd = f 1.0 2.0

main = do
print intAdd

함수형 언어의 경우 동적 타입 언어로 착각할 만큼 타입에 대한 작성을 제한할 수 있습니다. 심지어 제네릭 타입도 사용 가능한 것을 볼 수 있죠. 이는 함수형 언어가 사용하는 타입 이론이 다른 언어들과는 다른 부분이 많기 때문입니다.

일반적으로 함수형 언어는 힌들리 밀너 타입계(Hindley-Milner type system)를 주로 사용하여, 강력한 타입 추론 기능을 제공합니다. 이상적으로는 변수의 타입부터 함수의 선언 타입, 함수의 반환 타입 모두 생략이 가능하며, 특별한 선언 없이도 제네릭 타입 사용이 가능합니다. 타입 추론이 진행되면 위 코드는 아래와 같은 의사 코드로 표시됩니다.

Num => Num => Num f (a: Num) (b: Num) = (a: Num + b: Num) : Num

Int intAdd = (f: Int => Int => Int) (1: Int) (2: Int)
Float floatAdd = (f: Float => Float => Float) (1.0: Float) (2.0: Float)

main = do
print (intAdd: Int)

눈썰미 좋은 분들은 확인하셨겠지만, f 변수의 타입이 사용할 때마다 변하는 것을 볼 수 있습니다. 선언은 Num => Num => Num 이지만, Int 사용 시에는 Int => Int => Int , Float 사용 시에는 Float => Float => Float 로 바뀌었습니다. 이렇게 선언 이후 사용할 때마다 타입이 변화하는 것을 Let polymorphism 이라고 합니다. 힌들리 밀너 타입계에서 가장 대표적으로 언급되는 타입 추론 알고리즘인 알고리즘 W(Algorithm W) 역시 Let polymorphism 을 기반으로 하고 있습니다. 저희가 개발한 프로그래밍 언어인 Nadya 역시 고성능을 위하여 정적 타입 언어로 설계되었으며, 함수형 패러다임을 위해 알고리즘 W를 토대로 타입 추론을 진행하고 있습니다. 자세한 알고리즘의 내용은 추후 게시물에서 다룰 예정입니다.

마무리

이번 게시물에서는 정적 타입 언어의 특성과 이를 위한 다양한 타입 추론 기법들에 대해 간략하게 알아보았습니다. 정적 타입 언어는 여러분들의 컴퓨터를 작동시키는 근본이라고도 볼 수 있는데, 이 짧은 글을 통해 조금이나마 더 이해하게 되었으면 좋겠습니다. 말씀드렸듯이 지난 게시물에서 소개드렸던 Nadya 역시 정적 타입 언어에 해당하는데요. 앞으로도 Nadya 구현에 활용된 알고리즘 및 최적화 기법에 대한 게시물들을 업로드할 예정이니, 많은 관심 부탁드립니다.

--

--

ENERZAi

Our vision is delivering the best AI experience on everything for everyone