흐름 타이핑, 빌림 검사, 계약 프로그래밍처럼 특히 마음에 드는 프로그래밍 언어 기능을 살펴봅니다.
게시일: 2026-05-10
작성자: Pranoy Dutta
제가 좋아하는 프로그래밍 언어 기능 몇 가지만 소개합니다.
제가 Crystal에서 처음 접한 훌륭한 아이디어는 _흐름 타이핑_이라는 개념입니다.
Crystal은 Ruby 프로그래밍 언어와 문법이 매우 유사하며 정적 타입 검사를 수행하는 컴파일 언어입니다. Crystal이 동적 타입 언어처럼 느껴지게 하는 요소 중 하나는, 제가 사용해 본 대부분의 정적 타입 언어와 달리 변수의 생명주기 동안 여러 타입이 할당될 수 있다는 점입니다. 예를 들어 보겠습니다.
my_var = 5
# my_var's type here is Int32
assert(my_var.is_a?(Int32))
if some_complex_condition()
my_var = "hello!"
# my_var's type here is String
assert(my_var.is_a?(String))
end
# What is my_var's type here?
#
# It's Int32 | String
assert(my_var.is_a?(Int32 | String))
여기서 가장 흥미로운 점은 my_var가 단지 Int32인 시점이 있고, 확실히 String임이 보장되는 영역이 있으며, 그다음에는 컴파일러가 둘 중 어느 하나라고 실제로 보장할 수 없는 영역이 있다는 것입니다. 따라서 이 변수의 타입은 가능한 모든 경우의 유니온이 됩니다. 이제 my_var에서 String 메서드를 실행하려 하면 실패합니다. 이 변수는 String이 아니라 Int32 | String이기 때문이며, 컴파일러는 if my_var.is_a?(String) 같은 검사를 추가하도록 강제합니다. 이 검사는 가능한 타입을 String 하나로 좁힙니다.
이는 정교한 타입 추론을 사용해 컴파일 언어가 큰 런타임 비용 없이 동적으로 느껴지게 만드는 훌륭한 사례입니다.
Typescript에도 흐름 타이핑과 타입 좁히기가 있습니다!
Rust는 가비지 컬렉터 없이 메모리 안전성을 보장하는 시스템 프로그래밍 언어입니다.
동시성 프로그램에서 발생하는 메모리 안전성 버그의 큰 부류 중 하나는 끔찍한 데이터 레이스입니다. 이는 여러 스레드가 동기화 없이 동시에 같은 메모리 위치를 읽고 쓸 때 발생합니다.
빌림 검사기는 Rust가 컴파일 시점에 데이터 레이스를 정적으로 방지할 수 있게 하는 방법입니다. 다음 규칙을 강제합니다.
&mut T)&T)이는 여러 독자 또는 단일 작성자를 허용하는 잠금인 읽기-쓰기 잠금을 떠올리게 할 수 있습니다. 데이터 레이스를 방지하려면 쓰기에 대해 읽기를 동기화하기만 하면 되기 때문입니다. 동시성 동기화의 목적은 특정 메모리 위치에 대한 쓰기를 직렬화하는 것입니다.
저는 빌림 검사기를 좋아합니다. 데이터 레이스 문제에 대한 매우 우아한 해결책이며, 컴파일 시점 검사와 늘어난 복잡성이라는 비용만 드는 제로 비용 추상화이기 때문입니다. 늘어난 복잡성은 실제 절충안이지만, 동시성 프로그램을 작성한다면 이 복잡성은 주제 자체에 내재되어 있습니다.
프로그래밍을 어느 정도 해 보았다면, 주어진 불변 조건이 지켜지지 않을 때 오류를 발생시키거나 보고하는 소박한 assert 문을 접해 보았을 것입니다. 모든 프로그램에는 지켜야 할 불변 조건이 있습니다. 예를 들어 "이 날짜는 항상 저 날짜보다 뒤다" 또는 "이 트리는 항상 균형을 이룬다" 같은 것들입니다.
D 프로그래밍 언어는 (정말 과소평가된 언어입니다) 계약 프로그래밍을 지원하며, 함수 수준이나 객체 수준에서 더 복잡한 불변 조건을 기술할 수 있도록 문법적 지원을 제공합니다.
D에는 표준 assert 문이 있습니다.
double always_positive = 100;
assert(always_positive > 0);
하지만 D에는 의미론적 차이를 표시하는 데 사용되는 enforce도 있습니다. assert는 프로그램 불변 조건 위반에 사용됩니다. assert가 실행되었다면 이는 프로그램의 정확성 버그를 나타내야 합니다. 반면 enforce는 외부 문제 때문에 예외를 던집니다. 예를 들어 범위를 벗어난 사용자 입력이나 환경 문제 같은 것입니다.
enforce(length >= 7, "Must be at least 7.");
또한 D는 함수의 사전 조건과 사후 조건을 문법적으로 지원합니다. 다음은 Programming in D에서 가져온 예시입니다.
int daysInFebruary(int year)
out (result) {
assert((result == 28) || (result == 29));
} do {
return isLeapYear(year) ? 29 : 28;
}
여기서 daysInFebruary 함수에는 사후 조건이 있습니다. 이 함수는 반드시 28 또는 29만 반환해야 하며, 그 밖의 모든 값은 확실히 함수의 논리적 오류입니다.
마지막으로 D에는 객체 데이터가 항상 일관된 상태임을 보장하는 데 사용되는 클래스 수준 불변 조건이 있습니다. 다음은 이 모든 것을 함께 보여 주는 조금 더 복잡한 예시입니다.
class BankAccount {
private double balance;
invariant() {
balance >= 0; // object-level invariant, always checked
}
this(double initialBalance)
in (initialBalance >= 0, "Initial balance cannot be negative")
{
balance = initialBalance;
}
void deposit(double amount)
in (amount > 0, "Deposit amount must be positive")
out (; balance == balance + amount) // checked after method returns
{
balance += amount;
}
void withdraw(double amount)
in (amount > 0, "Withdrawal amount must be positive")
in (amount <= balance, "Insufficient funds")
out (; balance >= 0, "Balance must remain non-negative")
{
balance -= amount;
}
double getBalance()
out (result; result >= 0, "Balance returned must be non-negative")
{
return balance;
}
}
이 invariant() 블록은 모든 클래스 메서드의 시작과 끝에서 일관성 검사 함수를 실행하는 것보다 훨씬 깔끔하고 유지보수하기 쉬우며, 관용적인 의미를 지닙니다.
-- Pranoy