FunLang 튜토리얼
어서오세요
이 튜토리얼에 오신 것을 환영합니다.
FunLang는 함수형 프로그래밍의 핵심 아이디어들을 작고 명확한 언어로 담아낸 ML 계열 언어입니다. F#의 들여쓰기 기반 문법, OCaml의 타입 시스템과 연산자 분류 체계, Haskell의 순수 함수적 사고방식에서 영감을 받아 설계되었습니다. 거대한 표준 라이브러리나 복잡한 도구 체인 없이도 함수형 언어의 본질적인 아름다움을 경험할 수 있도록 만들어졌습니다.
이 튜토리얼은 파이썬, 자바스크립트, Java 등의 언어를 어느 정도 다뤄봤고, 함수형 프로그래밍에 진지하게 입문하고 싶은 분들을 위해 썼습니다. OCaml이나 F#을 이미 알고 있다면 1~4장은 빠르게 훑고 지나갈 수 있을 것이고, Haskell 배경이 있다면 타입 시스템 부분에서 친숙한 개념을 많이 만나게 될 것입니다.
완전한 초보자분도 환영합니다. 다만 “재귀가 무엇인가“부터 시작하지는 않습니다. 그 기초는 독자분이 이미 가지고 있다고 가정합니다.
FunLang는 어떤 언어인가
한 문장으로 요약하자면: F# 스타일의 들여쓰기 기반 문법, ADT/GADT/Records 타입 시스템, Haskell 스타일 타입 클래스, 모듈, 예외 처리, 파이프 연산자, 문자열 내장 함수를 갖춘 ML 계열 함수형 프로그래밍 언어입니다.
좀 더 풀어서 이야기하면, FunLang는 다음 세 가지를 잘 합니다.
첫째, 타입으로 생각하기입니다. FunLang의 타입 시스템은 단순한 오류 방지 도구가 아닙니다. 대수적 데이터 타입(ADT)과 GADT를 통해 여러분이 다루는 문제의 구조를 코드로 직접 표현할 수 있습니다. “이 함수는 실패할 수 있다“는 사실을 Option이나 Result 타입으로 명시하고, 컴파일러가 모든 경우를 처리했는지 확인해줍니다.
둘째, 파이프라인으로 생각하기입니다. |> 연산자와 함수 합성은 데이터 변환 과정을 왼쪽에서 오른쪽으로 읽히는 자연스러운 흐름으로 표현합니다. 중첩된 함수 호출 대신 단계별 변환의 연쇄로 프로그램을 구성하는 방식을 익히게 됩니다.
셋째, 패턴으로 생각하기입니다. 패턴 매칭은 단순한 switch문의 대체품이 아닙니다. 복잡한 데이터 구조를 분해하고, 타입에 따라 분기하고, 불가능한 케이스를 컴파일 시점에 제거하는 강력한 도구입니다.
튜토리얼의 구성
이 튜토리얼은 네 단계로 나뉩니다. 각 단계는 이전 단계 위에 쌓아올려집니다.
시작하기
1장: 시작하기부터 시작하세요.
목차
기초
- 시작하기 — 실행 방법, 기본 값, 연산자
- 함수 — lambda, let, let rec, 고차 함수
- 리스트와 튜플 — 리스트, 튜플, cons
- 패턴 매칭 — 패턴 종류, when guard
기초 단계에서는 FunLang가 어떻게 동작하는지 손으로 느껴봅니다. 특히 2장의 함수와 4장의 패턴 매칭은 이후 모든 장의 토대가 됩니다. 서두르지 말고 코드를 직접 실행해보며 감을 익히세요.
타입 시스템
- 대수적 데이터 타입 — ADT, 파라메트릭, 재귀 타입
- 레코드 — 레코드, mutable fields
타입 시스템 단계는 FunLang의 심장입니다. 5장에서 ADT를 이해하는 순간 함수형 프로그래밍의 핵심 설계 방식이 보이기 시작합니다. “데이터의 모양을 타입으로 표현한다“는 발상은 처음에는 낯설 수 있지만, 한번 익숙해지면 다른 방식으로 돌아가기 어렵습니다.
실용 프로그래밍
- 문자열과 출력 — string 함수, printf
- 파이프와 합성 — |>, >>, <<
- Prelude 표준 라이브러리 — Option, Result, 리스트 함수
- 모듈과 네임스페이스 — module, open, qualified access
실용 프로그래밍 단계에서는 실제로 유용한 프로그램을 작성하는 방법을 익힙니다. 8장의 파이프 연산자는 FunLang 코드를 “FunLang답게” 만드는 핵심입니다. 9장의 Prelude는 매일 쓰게 될 도구들의 모음입니다.
에러 처리
에러 처리 단계에서는 실패를 어떻게 다룰 것인가라는 질문을 깊이 파고듭니다. 단순히 “예외를 던지고 잡는 방법“을 넘어서, 언제 예외를 쓰고 언제 Option을 쓰고 언제 Result를 써야 하는지 설계적 판단력을 기릅니다.
심화 주제
- 사용자 정의 연산자 — 연산자 정의, 우선순위
- GADT — 타입 정제, 어노테이션
- 타입 클래스 — typeclass, instance, 제약 추론
- 알고리즘과 자료구조 — 정렬, 트리, 수론
심화 주제 단계는 FunLang의 더 강력한 도구들을 다룹니다. 13장의 사용자 정의 연산자는 코드를 도메인 언어처럼 읽히게 만드는 기술이고, 14장의 GADT는 타입 시스템으로 표현할 수 있는 것들의 한계를 크게 넓혀줍니다. 23장의 타입 클래스는 Haskell에서 영감을 받은 다형성 메커니즘으로, 타입에 따라 동작이 달라지는 함수를 타입 안전하게 정의할 수 있게 합니다. 15장은 앞서 배운 모든 것을 실제 알고리즘 문제에 적용하는 종합 실습입니다.
부록
- CLI 참조 — 모든 CLI 모드
튜토리얼을 따라가는 방법
코드를 눈으로만 읽지 마세요. 모든 예제를 직접 실행해보고, 값을 바꿔서 어떻게 되는지 실험해보세요. “이렇게 하면 어떻게 될까?” 하는 질문이 생기면 바로 시도해보세요. 컴파일러의 에러 메시지는 적이 아닙니다. 무엇이 잘못되었는지 알려주는 가장 정직한 선생님입니다.
각 장의 예제들은 독립적으로 실행할 수 있습니다. 특정 개념이 궁금하면 해당 장으로 바로 건너뛰어도 됩니다. 다만 이전 장에서 소개된 개념은 이후 장에서 설명 없이 사용되므로, 처음 읽을 때는 순서대로 따라오는 것을 권장합니다.
막히는 부분이 있다면 그 장을 처음부터 다시 읽어보세요. 두 번째 읽을 때 처음에 놓쳤던 것이 보이는 경우가 많습니다. 프로그래밍 언어의 새로운 개념들은 종종 한번에 이해되지 않고, 여러 번 마주치면서 점점 깊어지는 방식으로 익혀집니다.
자, 시작해봅시다.
1장: 시작하기 (Getting Started)
FunLang는 F# 스타일의 들여쓰기 구문을 사용하는 정적 타입(statically-typed) 함수형 언어로, 대수적 데이터 타입(algebraic data types, GADTs 포함), 결정 트리 컴파일 방식의 패턴 매칭(pattern matching), 그리고 모듈 시스템을 지원합니다.
처음 FunLang를 접하면 “왜 또 다른 언어인가?“라는 생각이 들 수 있습니다. FunLang의 설계 철학은 간단합니다: F#과 OCaml의 표현력 있는 타입 시스템을 가져오되, 문법적인 군더더기를 최소화하는 것입니다. Haskell처럼 순수 함수형이지만 훨씬 접근하기 쉬운 문법을 목표로 합니다. 이 튜토리얼에서는 언어의 기초부터 차근차근 살펴봅니다.
FunLang 실행하기
FunLang는 세 가지 방식으로 실행할 수 있습니다. 각 방식은 서로 다른 용도에 최적화되어 있으므로 상황에 맞게 선택하세요.
REPL (대화형 모드) 는 인자 없이 실행하면 대화형 세션을 시작합니다:
$ fn
FunLang REPL v14.0
Type :help for commands, #quit or Ctrl+D to exit.
fn> 1 + 2
- : int = 3
fn> let x = 42
val x : int = 42
프롬프트에서 표현식을 입력하면 타입과 값을 즉시 확인할 수 있습니다. let 바인딩은 다음 줄에서도 유지되므로 점진적으로 코드를 구축할 수 있습니다. :type 명령으로 표현식의 타입만 확인하거나, :load로 파일을 로드할 수도 있습니다.
REPL은 언어를 탐험하기에 가장 좋은 방법입니다. Python의 >>> 프롬프트처럼, 아이디어를 빠르게 테스트하고 타입과 동작을 확인할 수 있습니다. 새로운 함수를 작성하기 전에 REPL에서 먼저 논리를 검증하는 습관을 들이면 개발 속도가 크게 향상됩니다.
표현식 모드(Expression mode) 는 커맨드 라인에서 단일 표현식을 평가합니다:
$ fn --expr '1 + 2'
3
쉘 스크립트에서 FunLang를 활용하거나, CI 파이프라인에서 빠른 계산이 필요할 때 유용합니다.
파일 모드(File mode) 는 프로그램 파일을 평가합니다. 마지막 바인딩의 값이 출력됩니다:
$ cat hello.l3
let greeting = "hello"
let result = greeting + " world"
$ fn hello.l3
"hello world"
“마지막 바인딩의 값이 출력된다“는 동작 방식은 처음에는 낯설게 느껴질 수 있습니다. Python처럼 print 없이도 결과가 나온다는 점이 편리하지만, 이는 FunLang가 모든 것을 표현식으로 바라본다는 철학의 반영입니다. 의도적으로 마지막 let result = ...를 프로그램의 “반환값“으로 삼는 코딩 스타일을 자연스럽게 유도합니다.
진단 모드(Diagnostic modes) 는 평가 없이 컴파일 결과를 검사합니다:
$ fn --emit-ast --expr '1 + 2'
Add (Number 1, Number 2)
$ fn --emit-type --expr '1 + 2'
int
--emit-ast는 언어를 학습하거나 컴파일러 동작을 이해할 때 굉장히 유용합니다. 1 + 2가 Add (Number 1, Number 2)라는 트리 구조로 파싱된다는 것을 직접 눈으로 확인할 수 있습니다. --emit-type은 타입 추론 결과를 확인할 때 씁니다 – 복잡한 표현식의 타입이 무엇인지 헷갈릴 때 바로 물어볼 수 있는 도구입니다.
정수와 산술 연산
숫자 계산부터 시작하겠습니다. FunLang의 기본 숫자 타입은 정수(int)이며, 대부분의 언어와 동일한 연산자를 지원합니다.
표준 산술 연산자를 지원하며 일반적인 우선순위를 따릅니다. 나눗셈은 정수 나눗셈(integer division)입니다.
fn> 1 + 2 * 3
7
fn> 10 - 3
7
fn> 10 / 3
3
fn> -5
-5
fn> 10 % 3
1
fn> 7 % 2
1
%는 나머지(모듈로) 연산자입니다.
10 / 3 = 3이라는 결과에 주목하세요. Python 3의 // 연산자와 동일하게 소수점을 버리는 정수 나눗셈입니다. 부동소수점 계산이 필요하다면 이 점을 염두에 두어야 합니다. 수치 계산보다는 알고리즘과 자료구조에 집중하는 언어의 특성상 이 선택은 자연스럽습니다.
불리언 (Booleans)
논리 값과 논리 연산자입니다. 이 부분은 어느 언어나 비슷하지만, FunLang의 단락 평가 동작은 알아두면 도움이 됩니다.
true와 false를 지원하며, 단락 평가(short-circuit) 방식의 &&와 ||를 사용합니다.
fn> true && false
false
fn> true || false
true
단락 평가(short-circuit evaluation)란 불필요한 경우 오른쪽 피연산자를 평가하지 않는 것을 의미합니다:
fn> false && (1/0 = 0)
false
fn> true || (1/0 = 0)
true
1/0은 런타임 에러를 발생시키는 표현식이지만, 위 예제에서는 에러가 발생하지 않습니다. false && ...에서 앞이 false이면 결과는 무조건 false이므로 오른쪽을 평가할 필요가 없습니다. 이는 성능 최적화이기도 하지만, 실용적으로는 “왼쪽 조건이 참이어야만 오른쪽을 안전하게 평가할 수 있는” 가드 패턴에 자주 활용됩니다.
not 함수로 불리언을 부정할 수 있습니다:
fn> not true
false
not이 연산자(!)가 아닌 함수라는 점이 흥미롭습니다. FunLang에서는 not을 다른 고차 함수에 직접 넘길 수 있습니다. 예를 들어 filter not [true; false; true]처럼요 – 이것이 함수형 언어에서 연산자 대신 함수를 선호하는 이유 중 하나입니다.
문자열 (Strings)
문자열 리터럴은 큰따옴표를 사용하며 표준 이스케이프 시퀀스(\n, \t, \\, \")를 지원합니다.
문자열 연결(concatenation)에는 + 연산자를 사용합니다.
fn> "hello" + " world"
"hello world"
fn> "line1\nline2"
"line1
line2"
문자열 연결에 +를 쓰는 것은 Python과 같은 관례입니다. Haskell이나 OCaml에서는 ^나 ^ 같은 별도 연산자를 쓰는 것과 비교하면, FunLang는 직관성을 우선시합니다.
내장 문자열 함수:
fn> String.length "hello"
5
fn> String.substring "hello" 1 3
"ell"
fn> to_string 42
"42"
String.substring "hello" 1 3은 인덱스 1부터 3개의 문자를 추출합니다. to_string은 숫자를 문자열로 바꿀 때 유용합니다 – 특히 숫자와 문자열을 연결하고 싶을 때 "값: " + to_string 42처럼 자주 씁니다. 문자열 함수의 전체 목록과 printf 포맷 출력은 7장: 문자열과 출력에서 다룹니다.
비교 연산자
다른 언어와 비교하면 FunLang의 비교 연산자에서 눈에 띄는 차이점이 있습니다. 처음에 실수하기 쉬운 부분이므로 주의를 기울이세요.
등호는 =입니다 (==가 아닙니다). 부등호는 <>입니다.
fn> 1 = 1
true
fn> 1 <> 2
true
fn> 3 < 5
true
fn> 3 >= 3
true
=는 ML 계열 언어(F#, OCaml, SML)의 전통적인 관례입니다. C, Java, Python에서 온 개발자라면 처음에 ==를 습관적으로 입력할 수 있습니다. 마찬가지로 “같지 않음“을 !=가 아닌 <>로 표현하는 것도 ML 전통입니다. 며칠 쓰다 보면 금방 익숙해집니다.
조건문 (Conditionals)
FunLang에서 if는 문(statement)이 아니라 표현식(expression)입니다. 이 차이가 언어 전체를 관통하는 중요한 설계 원칙입니다.
fn> if 1 < 2 then "yes" else "no"
"yes"
두 분기(branch)는 동일한 타입이어야 합니다. else 분기는 필수입니다.
if가 표현식이라는 의미는, 변수에 직접 담을 수 있다는 뜻입니다: let label = if score > 90 then "A" else "B". C나 Java에서는 삼항 연산자(? :)를 별도로 두는 반면, FunLang에서는 if-then-else 자체가 그 역할을 합니다.
else가 필수인 이유도 여기서 비롯됩니다. “값을 돌려주는 표현식“이 else 없이 존재한다면 조건이 거짓일 때 어떤 값을 돌려줄지 알 수 없기 때문입니다. 타입 시스템이 이를 강제하여 잠재적인 버그를 컴파일 타임에 잡아냅니다.
주석 (Comments)
//로 줄 주석을, (* ... *)로 블록 주석을 작성합니다:
fn> 1 + 2 // this is ignored
3
fn> (* block comment *) 1 + 2
3
줄 주석 //는 C/Java/Python 스타일로 익숙할 것입니다. 블록 주석 (* ... *)는 OCaml과 동일한 형식입니다. 블록 주석은 코드 섹션을 잠시 비활성화하거나, 함수 위에 여러 줄 설명을 붙일 때 쓰면 좋습니다.
유닛 타입 (Unit Type)
유닛 타입은 처음 접하면 “이게 왜 필요하지?“라는 의문이 들 수 있는 개념입니다. 하지만 함수형 언어에서는 없어서는 안 될 중요한 역할을 합니다.
유닛 타입 ()는 “의미 있는 값이 없음“을 나타내며, 부수 효과(side effects)에 사용됩니다:
fn> ()
()
fn> println "hello"
hello
()
println "hello"는 화면에 텍스트를 출력하고 ()를 반환합니다. “화면에 출력하는 행위” 자체가 목적이고, 반환값은 중요하지 않을 때 ()를 씁니다. println 외에 print, printf, sprintf 등의 출력 함수는 7장: 문자열과 출력에서 자세히 다룹니다.
왜 void 대신 ()를 쓸까요? ()는 실제 값입니다 – 타입 시스템 안에서 다룰 수 있습니다. 반면 void는 “값이 없다“는 특수 케이스로 취급해 타입 시스템을 복잡하게 만듭니다. ()를 쓰면 “부수 효과를 수행하는 함수“도 일관된 방식으로 표현할 수 있습니다. Haskell의 IO (), F#의 unit도 같은 개념입니다.
구문 참고 사항
이 챕터를 마치기 전에, FunLang 문법 전반에 걸쳐 알아두어야 할 핵심 규칙들을 정리합니다. 이 규칙들은 처음에는 제약처럼 느껴질 수 있지만, 실제로는 코드를 더 읽기 쉽게 만들어줍니다.
- 들여쓰기 기반 – 블록에 세미콜론이나 중괄호가 필요 없습니다 (F# 스타일)
- 파일 모드: 최상위 레벨에서
let바인딩을 사용하며in이 필요 없습니다; 마지막 바인딩의 값이 출력됩니다 - REPL:
let x = 42로 영속적 바인딩 생성; 표현식도 바로 평가 가능 - 표현식 모드 (
--expr): 지역 바인딩에let x = ... in body를 사용합니다 match파이프는match키워드의 열(column)에 맞춰 정렬해야 합니다
들여쓰기 기반 문법은 Python을 써본 분들에게 친숙할 것입니다. 중괄호나 세미콜론 없이 들여쓰기만으로 블록 구조를 표현합니다. 한 가지 주의할 점은 탭과 스페이스를 섞어 쓰면 예상치 못한 파싱 오류가 생길 수 있다는 것입니다 – 일관되게 스페이스를 사용하는 것을 권장합니다.
2장: 함수 (Functions)
함수형 언어에서 함수는 단순한 코드 묶음이 아닙니다. 함수는 값입니다 – 변수에 담을 수 있고, 다른 함수에 인자로 넘길 수 있으며, 함수에서 함수를 반환할 수도 있습니다. 이 장에서는 FunLang가 함수를 어떻게 다루는지 살펴보면서, 함수형 프로그래밍의 핵심 아이디어들을 하나씩 짚어봅니다.
익명 함수 (Anonymous Functions)
모든 함수의 기반이 되는 개념부터 시작합니다. 이름 없는 함수, 즉 람다(lambda)입니다.
람다 구문은 fun을 사용합니다:
fn> (fun x -> x + 1) 10
11
fun x -> x + 1은 “x를 받아서 x + 1을 반환하는 함수“입니다. Python의 lambda x: x + 1, JavaScript의 x => x + 1과 같은 개념입니다. 이 함수에 10을 바로 적용한 결과가 11입니다.
타입 어노테이션을 포함하는 경우:
fn> (fun (x: int) -> x + 1) 10
11
FunLang는 타입을 자동으로 추론하기 때문에 보통은 타입 어노테이션을 쓸 필요가 없습니다. 하지만 타입 오류를 디버깅할 때나, 코드를 읽는 사람에게 의도를 명확히 전달하고 싶을 때 유용합니다. 타입 어노테이션은 컴파일러에게도, 코드를 읽는 사람에게도 일종의 문서 역할을 합니다.
튜플 파라미터를 직접 구조 분해할 수 있습니다:
fn> (fun (x, y) -> x + y) (1, 2)
3
fn> (fun (a, b, c) -> a + b + c) (1, 2, 3)
6
함수 정의 자체에서 튜플을 분해하는 이 문법은 코드를 훨씬 간결하게 만들어줍니다. fun pair -> fst pair + snd pair처럼 쓰는 대신 패턴 매칭을 인자 단계에서 바로 수행할 수 있습니다. F#과 Haskell에서도 이런 스타일을 많이 씁니다.
Let 바인딩 (REPL / 표현식 모드)
매번 함수를 쓸 때마다 이름 없는 람다를 쓸 수는 없습니다. let으로 값과 함수에 이름을 붙입니다.
REPL에서는 let ... in으로 값을 바인딩합니다:
fn> let x = 5 in x + 1
6
let x = 5 in x + 1을 읽는 방법: “x를 5로 정의하고, 그 문맥에서 x + 1을 계산하라.” in 뒤의 표현식이 전체의 결과값이 됩니다. 수학의 “… where x = 5“와 같은 개념입니다.
바인딩을 연쇄적으로 사용할 수 있습니다:
fn> let x = 5 in let y = x + 1 in y * 2
12
이처럼 let ... in let ... in ... 형태로 바인딩을 쌓아갈 수 있습니다. 중간 계산 결과에 이름을 붙여가며 복잡한 표현식을 단계적으로 구성할 수 있습니다. 익숙해지면 매우 자연스러운 스타일입니다.
Let 바인딩 (파일 모드)
파일에서 코드를 작성할 때는 REPL과 약간 다른 문법을 씁니다. in 키워드 없이 최상위에서 바인딩을 나열합니다.
파일 모드에서 let 바인딩은 최상위 선언(top-level declarations)이며 in이 필요 없습니다.
마지막 바인딩의 값이 출력됩니다:
$ cat add.l3
let a = 10
let b = 20
let result = a + b
$ fn add.l3
30
파일 모드의 let은 Python의 모듈 레벨 변수 선언과 비슷합니다. 위에서 아래로 순서대로 평가되며, 각 바인딩은 그 이후의 바인딩에서 사용할 수 있습니다. result가 마지막 바인딩이므로 그 값인 30이 출력됩니다.
다중 매개변수 함수 (Multi-Parameter Functions)
여러 인자를 받는 함수를 어떻게 정의할까요? 여기서 함수형 언어의 핵심 개념인 커링(currying)이 등장합니다.
다중 매개변수 함수는 중첩된 람다로 변환됩니다 (커링, currying). 파일 모드(모듈 레벨)에서 다음과 같이 작동합니다:
$ cat multi.l3
let add x y = x + y
let result = add 3 4
$ fn multi.l3
7
위 코드는 다음과 동일합니다:
$ cat multi2.l3
let add = fun x -> fun y -> x + y
let result = add 3 4
$ fn multi2.l3
7
두 코드가 완전히 동일하다는 점이 커링의 핵심입니다. add x y = x + y는 문법적 편의를 위한 축약 표현일 뿐, 실제로는 fun x -> fun y -> x + y입니다. 다중 파라미터 람다 fun x y -> x + y로도 쓸 수 있으며, 이 역시 중첩된 람다로 디슈거됩니다. add 3 4를 평가하면 먼저 add 3이 fun y -> 3 + y라는 새로운 함수를 반환하고, 거기에 4를 적용해 7을 얻습니다.
이 동작이 단순한 구현 세부사항처럼 보일 수 있지만, 이것이 바로 부분 적용(partial application)을 가능하게 하는 기반입니다. 뒤에 나오는 커링 섹션에서 이것이 얼마나 유용한지 볼 수 있습니다.
재귀 함수 (Recursive Functions)
함수형 언어에서는 반복을 표현하는 주된 방법이 재귀입니다. for 루프나 while 루프 대신, 함수가 자기 자신을 호출합니다.
재귀에는 let rec를 사용합니다. 표현식 레벨(in과 함께)과 모듈 레벨(파일 최상위) 모두에서 사용할 수 있습니다.
fn> let rec fact n = if n <= 1 then 1 else n * fact (n - 1) in fact 5
120
왜 let rec가 필요한 걸까요? 일반 let에서는 정의하는 시점에 자기 자신의 이름이 아직 스코프에 없습니다. let rec는 “이 함수의 이름을 함수 본문 안에서도 참조할 수 있다“고 컴파일러에게 알려주는 키워드입니다. OCaml과 F#도 같은 방식을 씁니다.
제한 사항: let rec는 단일 매개변수만 지원합니다. 다중 매개변수
재귀 함수의 경우, 단일 튜플을 받거나 본문 내부에서 중첩 람다를 사용하세요:
$ cat len.l3
let rec len xs =
match xs with
| [] -> 0
| _ :: rest -> 1 + len rest
let result = len [1; 2; 3]
let _ = println (to_string result)
$ fn len.l3
3
여기서 [1; 2; 3]은 리스트이고 match ... with는 패턴 매칭입니다. 리스트는 3장에서, 패턴 매칭은 4장에서 자세히 다룹니다. 지금은 “빈 리스트이면 0, 아니면 1을 더하고 나머지에 재귀“라는 흐름만 이해하면 충분합니다.
파일 모드에서는 최상위 let 내부에 let rec을 포함시킵니다. 들여쓰기가 스코프를 결정하므로 명시적 in은 필요 없습니다:
$ cat factorial.l3
let result =
let rec fact n = if n <= 1 then 1 else n * fact (n - 1)
fact 10
$ fn factorial.l3
3628800
재귀 함수를 외부에 노출하지 않고 지역 구현 세부사항으로 감추고 싶을 때 유용합니다.
모듈 레벨 let rec
let rec을 모듈 레벨(파일 최상위)에서 in 없이 직접 선언할 수 있습니다:
$ cat fact_module.l3
let rec fact n = if n <= 1 then 1 else n * fact (n - 1)
let result = fact 10
$ fn fact_module.l3
3628800
이 형태는 파일 모드에서만 동작합니다. REPL에서는 여전히 let rec ... in ...을 사용하세요.
모듈 레벨 let rec는 재귀 함수를 여러 곳에서 사용해야 할 때 훨씬 편리합니다. 중첩 let rec ... in 패턴은 함수를 한 번만 쓸 때 적합하고, 모듈 레벨 선언은 여러 바인딩에서 공유해야 할 때 적합합니다.
상호 재귀 (Mutual Recursion)
때로는 두 함수가 서로를 호출해야 할 경우가 있습니다. 하나를 먼저 정의하면 다른 하나가 아직 존재하지 않는 문제가 생깁니다. 이를 해결하는 것이 and 키워드입니다.
and 키워드로 서로를 호출하는 함수들을 동시에 선언할 수 있습니다:
$ cat even_odd.l3
let rec even n = if n = 0 then true else odd (n - 1)
and odd n = if n = 0 then false else even (n - 1)
let result = (even 10, odd 7)
$ fn even_odd.l3
(true, true)
even과 odd는 서로를 호출합니다. and로 연결된 함수들은 동시에 환경에 등록되어
서로의 존재를 알 수 있습니다.
짝수/홀수 판별은 교과서적인 예제지만, 실제로 상호 재귀는 상태 머신(state machine)을 구현하거나 문법 파서를 작성할 때 매우 유용합니다. 예를 들어 “문자열 내부를 파싱하는 상태“와 “문자열 외부를 파싱하는 상태“가 서로를 전환하는 패턴이 전형적인 상호 재귀입니다.
상호 재귀는 모듈 레벨에서만 동작합니다. 각 함수는 단일 파라미터를 받으며, 다중 파라미터는 클로저로 처리합니다:
$ cat mutrec_multi.l3
let rec isEven n = if n = 0 then true else isOdd (n - 1)
and isOdd n = if n = 0 then false else isEven (n - 1)
let r1 = isEven 100
let r2 = isOdd 99
let result = (r1, r2)
$ fn mutrec_multi.l3
(true, true)
꼬리 호출 최적화 (Tail Call Optimization)
재귀를 쓰면 자연스럽게 드는 걱정이 있습니다: “깊이 재귀하면 스택이 넘치지 않을까?” FunLang는 꼬리 호출 최적화(TCO)로 이 문제를 해결합니다.
FunLang는 꼬리 위치(tail position)의 함수 호출을 자동으로 최적화합니다. 이를 통해 깊은 재귀도 스택 오버플로우 없이 실행됩니다.
꼬리 호출이란? 함수의 마지막 동작이 다른 함수를 호출하는 것입니다:
$ cat tco_loop.l3
let rec loop n = if n = 0 then 0 else loop (n - 1)
let result = loop 1000000
$ fn tco_loop.l3
0
100만 번의 재귀가 스택 오버플로우 없이 동작합니다.
loop (n - 1)이 함수의 마지막 동작입니다. 이 경우 컴파일러는 재귀 호출을 실제로 새 스택 프레임을 만드는 대신, 현재 프레임을 재사용하는 루프로 변환합니다. 결과적으로 while n != 0: n -= 1과 동일한 기계어 코드가 됩니다. 메모리 사용량이 일정하게 유지됩니다.
누적 변수 패턴: 꼬리 재귀로 바꾸려면 결과를 누적 변수에 전달하세요:
-- 꼬리 재귀가 아닌 버전 (n * fact(n-1)에서 곱셈이 남음):
fn> let rec fact n = if n <= 1 then 1 else n * fact (n - 1) in fact 10
3628800
-- 꼬리 재귀 버전 (acc에 결과 누적):
$ cat tco_fact.l3
let rec factTail n = fun acc -> if n <= 1 then acc else factTail (n - 1) (acc * n)
let result = factTail 10 1
$ fn tco_fact.l3
3628800
n * fact (n - 1)에서는 fact (n - 1)이 반환된 후 곱셈을 해야 하므로 꼬리 위치가 아닙니다. 스택에 “나중에 곱해야 할 n“들이 쌓입니다. 반면 factTail (n - 1) (acc * n)은 마지막 동작이 순수한 함수 호출이므로 꼬리 위치입니다. 누적값 acc를 파라미터로 전달함으로써 스택 대신 파라미터에 상태를 저장합니다.
꼬리 위치 규칙:
if양쪽 브랜치: 꼬리 위치 ✓match절 본문: 꼬리 위치 ✓let ... in body: body가 꼬리 위치 ✓try ... with: try 본문은 꼬리 위치 ✗ (예외 핸들러 때문)- 산술 연산의 피연산자: 꼬리 위치 ✗ (연산이 남아있음)
이 규칙을 외울 필요는 없습니다. 핵심만 기억하세요: “재귀 호출 결과를 그대로 반환하면 꼬리 위치, 그 결과로 무언가를 더 해야 하면 꼬리 위치가 아니다.”
고차 함수 (Higher-Order Functions)
함수가 값이라면, 함수를 인자로 받거나 반환하는 함수도 당연히 가능합니다. 이것이 고차 함수(higher-order functions)입니다. 처음에는 추상적으로 들리지만, 실제로는 매우 실용적인 패턴입니다.
함수는 일급 값(first-class values)입니다. 함수를 인자로 전달할 수 있습니다:
$ cat hof.l3
let apply f x = f x
let result = apply (fun x -> x + 1) 10
$ fn hof.l3
11
apply는 함수 f와 값 x를 받아 f x를 계산합니다. 단순해 보이지만, 이 패턴을 확장하면 map, filter, fold 같은 강력한 추상화가 나옵니다. 어떤 연산을 수행할지 외부에서 주입받는 것이 고차 함수의 핵심입니다.
함수에서 함수를 반환할 수도 있습니다:
$ cat hof2.l3
let make_adder n = fun x -> x + n
let add10 = make_adder 10
let result = add10 5
$ fn hof2.l3
15
make_adder는 함수를 만드는 함수, 즉 팩토리(factory)입니다. make_adder 10은 “10을 더하는 함수“를 반환합니다. add10은 그 결과인 함수를 담고 있고, add10 5는 15를 줍니다. 이런 패턴은 설정값을 캡처한 함수를 만들어야 할 때 매우 유용합니다.
클로저 (Closures)
make_adder가 동작하는 이유는 클로저(closure) 덕분입니다. 함수는 자신이 정의된 스코프의 변수를 “기억“합니다.
함수는 자신을 둘러싼 스코프(scope)의 변수를 캡처합니다:
$ cat closure.l3
let x = 10
let add_x y = x + y
let result = add_x 5
$ fn closure.l3
15
add_x는 정의될 때 x = 10이라는 환경을 캡처합니다. 나중에 add_x 5를 호출할 때 x가 여전히 10이라는 것을 알고 있습니다. 이것이 클로저입니다 – 함수와 그 함수가 캡처한 환경의 조합입니다.
클로저는 함수형 프로그래밍의 가장 강력한 도구 중 하나입니다. 상태를 객체 대신 함수와 클로저로 표현할 수 있습니다. make_adder가 좋은 예입니다 – 각 호출마다 다른 n을 캡처한 별개의 클로저를 만들어냅니다.
커링과 부분 적용 (Currying and Partial Application)
앞서 다중 매개변수 함수가 중첩된 람다라는 것을 배웠습니다. 이 구조가 단순히 구현의 편의가 아니라, 함수형 프로그래밍에서 가장 강력한 도구 중 하나인 부분 적용(partial application)을 가능하게 한다는 것을 이 절에서 살펴봅니다.
기본 개념
다중 매개변수 함수는 자동으로 커링(currying)됩니다:
$ cat curry.l3
let add x y = x + y
let add5 = add 5
let result = add5 3
$ fn curry.l3
8
add 5는 add에 첫 번째 인자만 적용한 결과입니다. y는 아직 제공하지 않았으므로 fun y -> 5 + y라는 함수가 됩니다. 이것이 부분 적용입니다 — 함수에 인자를 모두 주지 않고 일부만 주면, 나머지 인자를 기다리는 새로운 함수가 만들어집니다.
명시적으로 풀어쓰면 이런 일이 일어나고 있습니다:
let add x y = x + y -- 사실은 let add = fun x -> fun y -> x + y
let add5 = add 5 -- fun y -> 5 + y 라는 클로저가 만들어짐
let result = add5 3 -- (fun y -> 5 + y) 3 = 8
3단계 커링도 자연스럽게 동작합니다:
$ cat curry3.l3
let f x = fun y -> fun z -> x + y + z
let g = f 1
let h = g 2
let result = h 3
$ fn curry3.l3
6
f 1은 fun y -> fun z -> 1 + y + z, g 2는 fun z -> 1 + 2 + z, h 3은 1 + 2 + 3 = 6입니다. 각 단계마다 인자 하나가 고정되고, 나머지를 기다리는 함수가 반환됩니다.
왜 부분 적용이 중요한가
부분 적용의 핵심 가치는 기존 함수에서 새로운 전문화된 함수를 만드는 것입니다. 일반적인 함수 하나를 정의해두면, 부분 적용으로 다양한 변형을 0줄의 추가 코드로 만들 수 있습니다.
$ cat specialization.l3
let mul x y = x * y
let double = mul 2
let triple = mul 3
let result = (double 5, triple 5)
$ fn specialization.l3
(10, 15)
double과 triple은 mul이라는 범용 함수에서 특수화된 함수입니다. 매번 fun x -> x * 2를 새로 쓸 필요가 없습니다. 함수 하나가 여러 함수의 “공장” 역할을 합니다.
이 패턴은 설정(configuration)에서도 빛을 발합니다:
$ cat config_pattern.l3
let greet greeting = fun name -> greeting ^^ " " ^^ name
let hello = greet "Hello"
let hi = greet "Hi"
let result = (hello "Alice", hi "Bob")
$ fn config_pattern.l3
("Hello Alice", "Hi Bob")
greet는 인사말 형식을 정의하는 “설정 가능한 함수“이고, hello와 hi는 각각 다른 설정이 적용된 구체적인 함수입니다. 객체지향에서 생성자 매개변수로 하는 일을 부분 적용이 대신합니다.
고차 함수와의 조합
부분 적용이 진짜 빛을 발하는 것은 map, filter, fold 같은 고차 함수와 함께 쓸 때입니다. 이 함수들은 Prelude 표준 라이브러리에서 제공되며, 9장: Prelude에서 전체 목록을 다룹니다. 여기서는 부분 적용과의 조합에 집중합니다.
$ cat partial_hof.l3
let add x y = x + y
let gt n = fun x -> x > n
// 부분 적용으로 간결하게
let r1 = map (add 10) [1; 2; 3]
// 람다로 쓰면 더 길어진다
// let r1 = map (fun x -> add 10 x) [1; 2; 3]
let r2 = filter (gt 3) [1; 2; 3; 4; 5; 6]
let result = (r1, r2)
$ fn partial_hof.l3
([11; 12; 13], [4; 5; 6])
map (add 10)은 “모든 원소에 10을 더한다“는 의도를 코드가 그대로 말해줍니다. map (fun x -> add 10 x)보다 짧을 뿐 아니라, 더 직접적으로 의도를 전달합니다.
fold를 부분 적용하면 새로운 집계 함수를 만들 수 있습니다:
$ cat partial_fold.l3
let sum = fold (fun acc -> fun x -> acc + x) 0
let product = fold (fun acc -> fun x -> acc * x) 1
let r1 = sum [1; 2; 3; 4; 5]
let r2 = product [1; 2; 3; 4; 5]
let result = (r1, r2)
$ fn partial_fold.l3
(15, 120)
fold에 연산과 초기값을 주고, 리스트는 나중에 받도록 남겨둔 것입니다. sum과 product는 “리스트를 기다리는 집계 함수“가 됩니다.
파이프라인에서의 부분 적용
파이프 연산자 |>와 부분 적용은 최고의 궁합입니다. |>는 8장: 파이프와 합성에서 자세히 다루지만, 부분 적용과의 시너지가 너무 중요하므로 여기서 먼저 맛보기를 보여드립니다. 파이프라인의 각 단계가 부분 적용된 함수가 되면, 데이터 처리 흐름을 선언적으로 기술할 수 있습니다.
$ cat partial_pipeline.l3
let gt n = fun x -> x > n
let mul x y = x * y
let result =
[1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
|> filter (gt 3)
|> map (mul 10)
|> fold (fun acc -> fun x -> acc + x) 0
$ fn partial_pipeline.l3
490
“1부터 10까지 중 3보다 큰 것만 골라서, 각각 10을 곱하고, 전부 더한다.” 코드가 이 설명을 거의 그대로 표현합니다. filter (gt 3)는 “3보다 큰 것만 남기는 함수”, map (mul 10)은 “각각 10을 곱하는 함수“입니다.
만약 부분 적용 없이 같은 코드를 쓴다면:
|> filter (fun x -> x > 3)
|> map (fun x -> x * 10)
|> fold (fun acc -> fun x -> acc + x) 0
동작은 같지만, 부분 적용 버전이 더 간결하고 의도가 명확합니다. gt 3은 “3보다 크다“를, mul 10은 “10을 곱한다“를 함수 이름 자체로 말해줍니다.
내장 함수의 부분 적용
FunLang의 내장 함수도 커링되어 있어서 부분 적용이 가능합니다:
$ cat partial_builtin.l3
let greet = fun s -> "Hello " ^^ s
let r1 = greet "World"
let r2 = map (fun s -> "item: " ^^ s) ["apple"; "banana"; "cherry"]
let result = (r1, r2)
$ fn partial_builtin.l3
("Hello World", ["item: apple"; "item: banana"; "item: cherry"])
^^ 연산자로 문자열을 연결합니다. 람다와 함께 사용하면 접두사를 붙이는 함수를 간결하게 만들 수 있고, 이것을 map에 넘기면 리스트의 모든 문자열에 접두사를 붙일 수 있습니다.
let rec과 부분 적용
재귀 함수도 부분 적용이 가능합니다. 이것은 재귀적 연산의 “설정“을 분리하는 데 유용합니다:
$ cat partial_rec.l3
let rec power base = fun exp ->
if exp = 0 then 1
else base * power base (exp - 1)
let pow2 = power 2
let pow3 = power 3
let r1 = map pow2 [0; 1; 2; 3; 4; 5]
let r2 = map pow3 [0; 1; 2; 3]
let result = (r1, r2)
$ fn partial_rec.l3
([1; 2; 4; 8; 16; 32], [1; 3; 9; 27])
power 2는 “2의 거듭제곱 함수”, power 3은 “3의 거듭제곱 함수“입니다. 밑(base)을 고정하고 지수(exp)만 나중에 받는 구조입니다. map pow2 [0..5]로 2의 0승부터 5승까지를 한 줄에 구할 수 있습니다.
함수를 값으로 다루기
부분 적용의 궁극적인 의미는 “함수를 값처럼 자유롭게 만들고, 전달하고, 조합할 수 있다“는 것입니다. 함수를 리스트에 넣을 수도 있습니다:
$ cat partial_list.l3
let add x y = x + y
let mul x y = x * y
let transforms = [add 1; add 10; mul 2; mul 100]
let apply_all fs = fun x -> map (fun f -> f x) fs
let result = apply_all transforms 5
$ fn partial_list.l3
[6; 15; 10; 500]
[add 1; add 10; mul 2; mul 100]은 부분 적용된 함수 4개를 담은 리스트입니다. apply_all transforms 5는 5에 네 가지 변환을 모두 적용합니다. 함수가 정수나 문자열과 마찬가지로 리스트에 넣고, 꺼내고, 적용할 수 있는 일급 값(first-class value)이라는 것을 실감할 수 있는 예입니다.
부분 적용을 위한 인자 순서
부분 적용을 효과적으로 쓰려면, 함수를 설계할 때 나중에 바뀔 인자를 마지막에 두는 것이 좋습니다.
// 좋은 설계: "설정"이 먼저, "데이터"가 마지막
let gt threshold = fun value -> value > threshold
filter (gt 3) myList // 깔끔
// 덜 좋은 설계: "데이터"가 먼저
let gt2 value = fun threshold -> value > threshold
filter (fun x -> gt2 x 3) myList // 람다가 필요
Prelude의 map, filter, fold가 모두 함수를 먼저 받고 리스트를 마지막에 받는 이유가 여기에 있습니다. map f는 “f를 적용하는 변환“이라는 완성된 의미를 가지며, 어떤 리스트에든 적용할 수 있습니다. 이것은 우연이 아니라 의도된 설계입니다.
파이프와 합성 연산자 (Pipe and Composition Operators)
여러 변환을 연달아 적용할 때, 중첩 함수 호출은 안에서 밖으로 읽어야 해서 불편합니다. 파이프 연산자와 합성 연산자가 이 문제를 해결합니다. 여기서는 기본적인 사용법만 소개하며, 더 다양한 활용과 우선순위 규칙은 8장: 파이프와 합성에서 깊이 다룹니다.
파이프 연산자(pipe operator) |>는 값을 마지막 인자로 전달합니다:
fn> 5 |> (fun x -> x + 1)
6
5 |> (fun x -> x + 1)은 (fun x -> x + 1) 5와 동일합니다. 차이는 읽는 방향입니다. “5에서 시작해서 1을 더한다“고 왼쪽에서 오른쪽으로 읽을 수 있습니다. F#과 Elixir에서 온 개발자라면 이 연산자가 무척 익숙할 것입니다.
합성 연산자(composition operators) >>(왼쪽에서 오른쪽)와 <<(오른쪽에서 왼쪽):
fn> let f = (fun x -> x + 1) >> (fun x -> x * 2) in f 3
8
여기서 f 3은 (3 + 1) * 2 = 8을 계산합니다.
>> 는 두 함수를 하나로 합칩니다. f >> g는 “먼저 f를 적용하고 그 결과에 g를 적용하는 새 함수“입니다. g(f(x))를 (f >> g)(x)로 쓸 수 있습니다. 수학의 함수 합성 g ∘ f와 같지만 적용 순서가 왼쪽에서 오른쪽이라 읽기 더 자연스럽습니다. 여러 변환 단계를 파이프라인으로 조합할 때, |>는 데이터에 초점을, >>는 함수 조합 자체에 초점을 맞춥니다.
3장: 리스트와 튜플 (Lists and Tuples)
데이터를 다루다 보면 여러 값을 함께 묶어야 할 순간이 반드시 옵니다. FunLang는 두 가지 기본 컬렉션을 제공합니다: 리스트(list)와 튜플(tuple). 얼핏 비슷해 보이지만 설계 철학이 다릅니다. 리스트는 “같은 종류의 값들을 가변 개수로”, 튜플은 “다른 종류의 값들을 고정 개수로” 묶는 데 쓰입니다. 이 장에서는 두 컬렉션의 특성과 활용 방법을 살펴봅니다.
리스트 기초
리스트는 함수형 언어의 가장 기본적인 자료구조입니다. Haskell, OCaml, F# 모두 리스트를 핵심 자료구조로 삼고 있으며, FunLang도 마찬가지입니다.
리스트는 순서가 있는 동질적(homogeneous) 컬렉션입니다:
fn> [1; 2; 3]
[1; 2; 3]
fn> []
[]
“동질적(homogeneous)“이라는 말은 리스트 안의 모든 원소가 같은 타입이어야 한다는 뜻입니다. [1; "hello"; true]처럼 타입이 섞인 리스트는 컴파일 에러가 됩니다. 처음에는 제약처럼 느껴지지만, 이 덕분에 리스트를 처리하는 함수를 안전하게 작성할 수 있습니다 – 원소 타입을 미리 알 수 있으니까요.
cons 연산자 ::는 요소를 앞에 추가합니다:
fn> 1 :: [2; 3]
[1; 2; 3]
fn> 1 :: 2 :: 3 :: []
[1; 2; 3]
:: 연산자는 함수형 언어 리스트의 핵심입니다. OCaml, Haskell에서도 같은 역할을 하며, “cons“라고 읽습니다. 1 :: [2; 3]은 “1과 [2; 3]을 연결(cons)한다“는 뜻입니다. 1 :: 2 :: 3 :: []처럼 오른쪽에서 왼쪽으로 연결해 나가는 방식으로 리스트를 구성할 수 있습니다. 패턴 매칭에서 x :: rest 형태로 리스트를 분해할 때도 이 연산자가 활용됩니다.
중요한 점은 :: 가 새 리스트를 만드는 것이지 기존 리스트를 변경하지 않는다는 점입니다. FunLang의 리스트는 불변(immutable)입니다. 원소를 앞에 추가하는 것은 O(1) 연산이고, 리스트를 뒤에서 추가하는 것은 O(n)입니다 – 이 특성이 재귀적 리스트 처리 패턴의 기반이 됩니다.
리스트 범위 (List Ranges)
연속된 숫자 리스트를 만들어야 할 때마다 [1; 2; 3; 4; 5; ...]처럼 일일이 쓰는 것은 번거롭습니다. 범위 구문이 이를 해결합니다.
[start..stop] 구문으로 정수 리스트를 생성할 수 있습니다:
fn> [1..5]
[1; 2; 3; 4; 5]
fn> [1..10]
[1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
스텝(증가값)을 지정할 수도 있습니다. [start..step..stop] 형태입니다:
fn> [1..2..10]
[1; 3; 5; 7; 9]
fn> [0..5..20]
[0; 5; 10; 15; 20]
F#의 [1..2..10] 문법과 동일합니다. Python의 range(1, 11, 2)와 비교하면 더 자연스럽게 읽힙니다. 스텝을 생략하면 기본값은 1입니다.
stop이 start보다 작으면 빈 리스트를 반환합니다 (F# 동작과 동일):
fn> [5..1]
[]
단일 원소 범위:
fn> [3..3]
[3]
[5..1]이 에러가 아닌 빈 리스트를 반환한다는 점을 기억하세요. 이 동작이 유용할 때도 있고 예상치 못한 빈 리스트로 버그가 생길 수도 있습니다. 변수로 범위를 만들 때는 start <= stop인지 확인하는 습관을 들이세요.
범위는 파이프 연산자와 함께 유용합니다:
$ cat range_sum.l3
let result =
let rec fold f = fun acc -> fun xs ->
match xs with
| [] -> acc
| h :: t -> fold f (f acc h) t
fold (fun acc -> fun x -> acc + x) 0 [1..100]
$ fn range_sum.l3
5050
1부터 100까지의 합을 구하는 고전적인 예제입니다. [1..100]으로 100개 원소의 리스트를 만들고, fold로 누적 합산합니다. Gauss가 초등학생 때 암산으로 계산했다는 바로 그 5050입니다.
참고: 범위는 정수(int)만 지원합니다. 스텝이 0이면 런타임 에러가 발생합니다.
튜플 (Tuples)
리스트가 “여러 개의 같은 것“을 담는다면, 튜플은 “고정 개수의 다른 것“을 담습니다. 이름, 나이, 활성화 여부처럼 서로 다른 타입의 값들을 하나로 묶을 때 튜플을 씁니다.
튜플은 고정 크기의 이질적(heterogeneous) 컬렉션입니다:
fn> (1, "hello")
(1, "hello")
fn> (1, "hello", true)
(1, "hello", true)
(1, "hello")는 int * string 타입, (1, "hello", true)는 int * string * bool 타입입니다. 타입 표기에서 *를 쓰는 것은 수학의 곱집합(Cartesian product)에서 왔습니다 – int * string은 정수와 문자열의 가능한 모든 조합의 집합입니다.
패턴 바인딩으로 튜플을 분해할 수 있습니다:
fn> let (x, y) = (1, 2) in x + y
3
fn> let (a, b, c) = (1, 2, 3) in a + b + c
6
튜플 분해(destructuring)는 매우 자주 쓰이는 패턴입니다. fst, snd 같은 접근 함수 없이 바로 이름을 붙여 사용할 수 있어 코드가 훨씬 명확해집니다. 함수에서 여러 값을 반환해야 할 때 튜플을 쓰고, 받는 쪽에서 즉시 분해하는 패턴이 FunLang에서 아주 흔합니다.
함수가 여러 값을 반환하는 것처럼 보이게 하는 관용적인 방법이 바로 튜플입니다. 실제로는 하나의 튜플 값을 반환하지만, 호출자에서 분해하면 여러 값을 받는 것처럼 느껴집니다.
유닛 (Unit)
1장에서 유닛 타입을 소개했지만, 튜플의 맥락에서 다시 보면 더 명확합니다. 유닛은 사실 원소가 0개인 튜플입니다.
유닛 타입 ()는 “값 없음“을 나타냅니다:
fn> ()
()
()를 빈 튜플로 생각하면 이해가 쉽습니다. (1, 2)는 원소 두 개짜리 튜플, (1,)은 원소 하나짜리 튜플, ()는 원소 없는 튜플. 이 시각으로 보면 유닛이 타입 시스템 안에서 일관되게 취급되는 이유가 납득됩니다.
리스트와 튜플 조합
리스트와 튜플은 자유롭게 조합할 수 있습니다. 실제 프로그램에서는 이런 조합이 자주 등장합니다.
튜플의 리스트:
fn> [(1, "a"); (2, "b")]
[(1, "a"); (2, "b")]
이 패턴은 키-값 쌍의 목록, 좌표 목록, 레코드 목록 등 다양한 용도로 쓰입니다. 데이터베이스의 행(row)을 튜플로, 테이블 전체를 리스트로 표현하는 방식입니다. 4장에서 배우는 패턴 매칭과 결합하면 이런 구조를 우아하게 처리할 수 있습니다.
리스트 함수
리스트를 다루는 핵심 도구들입니다. Prelude에서 제공하는 함수들을 먼저 살펴보고, 필요할 때 직접 작성하는 방법도 알아봅니다.
Prelude 함수 사용하기
map, filter, fold, length, reverse, append 등 자주 사용하는 리스트 함수는
Prelude 표준 라이브러리에서 제공됩니다. 별도의 정의 없이 바로 사용할 수 있습니다. 각 함수의 상세한 설명과 Option/Result 타입은 9장: Prelude 표준 라이브러리에서 다룹니다. 여기서는 리스트 처리에 필요한 핵심만 소개합니다:
fn> map (fun x -> x * 2) [1; 2; 3]
[2; 4; 6]
fn> filter (fun x -> x > 2) [1; 2; 3; 4; 5]
[3; 4; 5]
fn> fold (fun acc -> fun x -> acc + x) 0 [1..5]
15
fn> length [1; 2; 3; 4]
4
fn> append [1; 2] [3; 4]
[1; 2; 3; 4]
이 세 함수 – map, filter, fold – 는 함수형 프로그래밍의 삼위일체라고 할 수 있습니다. Haskell, F#, Python의 functools, JavaScript의 Array 메서드 모두 이 패턴을 따릅니다.
map은 변환: 리스트의 모든 원소에 함수를 적용해 새 리스트를 만듭니다. filter는 선택: 조건을 만족하는 원소만 남깁니다. fold는 축약: 리스트 전체를 하나의 값으로 압축합니다. 이 세 함수를 잘 조합하면 대부분의 리스트 처리 문제를 해결할 수 있습니다.
fold의 시그니처가 낯설 수 있습니다. fun acc -> fun x -> acc + x처럼 누적값과 현재 원소를 각각 인자로 받는 커링된 함수를 넘기는 것이 FunLang 스타일입니다. 초기값(0)과 리스트를 마지막에 넘깁니다.
++ 연산자는 append의 별칭입니다. 더 자연스러운 중위 표기를 제공합니다:
fn> [1; 2] ++ [3; 4]
[1; 2; 3; 4]
fn> [1..3] ++ [10..12]
[1; 2; 3; 10; 11; 12]
(++)를 고차 함수로도 사용할 수 있습니다:
fn> fold (++) [] [[1; 2]; [3]; [4; 5]]
[1; 2; 3; 4; 5]
fold (++) [] [[1;2]; [3]; [4;5]]는 리스트의 리스트를 하나로 평탄화(flatten)합니다. (++)를 괄호로 감싸면 중위 연산자를 일반 함수처럼 쓸 수 있는 것이 포인트입니다. 이런 방식으로 연산자를 fold에 직접 넘길 수 있습니다.
append(혹은 ++)는 O(n) 연산임을 기억하세요. 왼쪽 리스트의 모든 원소를 복사해야 합니다. 리스트 앞에 추가하는 :: 는 O(1)입니다. 리스트를 쌓아나가는 알고리즘을 작성할 때 ++ 대신 :: 로 앞에 추가하고 마지막에 reverse하는 것이 더 효율적인 이유입니다.
직접 재귀 함수 작성하기
Prelude에 없는 동작이 필요하면 let rec으로 직접 재귀 함수를 작성할 수 있습니다.
let rec는 단일 매개변수만 지원하므로, 추가 상태를 전달하려면
클로저(closure)를 사용하세요.
합계:
$ cat sum.l3
let rec sum xs =
match xs with
| [] -> 0
| x :: rest -> x + sum rest
let result = sum [1; 2; 3; 4; 5]
let _ = println (to_string result)
$ fn sum.l3
15
각 요소를 10배로:
$ cat map10.l3
let rec go xs =
match xs with
| [] -> []
| x :: rest -> x * 10 :: go rest
let result = go [1; 2; 3]
let _ = println (to_string result)
$ fn map10.l3
[10; 20; 30]
이 두 예제는 리스트 재귀의 기본 패턴을 잘 보여줍니다. 빈 리스트([])가 기저 사례(base case)이고, x :: rest로 분해해 머리(head) x를 처리한 뒤 꼬리(tail) rest에 재귀 호출하는 것이 귀납 사례(inductive case)입니다. Haskell을 비롯한 모든 함수형 언어에서 리스트 재귀는 이 구조를 따릅니다.
sum은 fold (fun acc -> fun x -> acc + x) 0으로, go는 map (fun x -> x * 10)으로 표현할 수 있습니다. 그렇다면 언제 직접 재귀를 써야 할까요? Prelude에 없는 복잡한 변환이 필요하거나, 여러 상태를 동시에 추적해야 하거나, 특수한 종료 조건이 있을 때입니다. 먼저 map/filter/fold로 해결할 수 있는지 확인하고, 안 되면 직접 작성하는 것이 좋은 순서입니다.
리스트 컴프리헨션 (List Comprehensions)
리스트를 변환하거나 생성할 때, map을 호출하는 대신 컴프리헨션 구문으로 더 직관적으로 표현할 수 있습니다. Python의 리스트 컴프리헨션과 비슷한 개념입니다.
컬렉션에서 생성
[for x in collection -> expr] 구문으로 컬렉션의 각 원소를 변환한 새 리스트를 만듭니다:
$ cat comp_basic.l3
let doubled = [for x in [1;2;3] -> x * 2]
let _ = println (to_string doubled)
let strs = [for s in ["a";"b";"c"] -> s ^^ "!"]
let _ = println (to_string strs)
$ fn comp_basic.l3
[2; 4; 6]
["a!"; "b!"; "c!"]
()
[for x in [1;2;3] -> x * 2]는 map (fun x -> x * 2) [1;2;3]와 같은 결과를 반환합니다. 어떤 것을 쓸지는 취향이지만, 변환 표현식이 복잡할 때는 컴프리헨션이 더 읽기 쉬운 경우가 많습니다.
범위에서 생성
[for i in start..stop -> expr] 구문으로 정수 범위를 순회하며 리스트를 생성합니다:
$ cat comp_range.l3
let squares = [for i in 0..4 -> i * i]
let _ = println (to_string squares)
let tens = [for i in 1..3 -> i * 10]
let _ = println (to_string tens)
$ fn comp_range.l3
[0; 1; 4; 9; 16]
[10; 20; 30]
()
0..4는 0, 1, 2, 3, 4를 순서대로 생성합니다. 리스트 범위 [0..4]와 동일한 범위이지만, 컴프리헨션에서는 각 값에 표현식을 적용하여 새 리스트를 만듭니다.
엣지 케이스
빈 컬렉션이나 단일 원소 컬렉션도 정상 동작합니다:
$ cat comp_edge.l3
let empty = [for x in [] -> x * 2]
let _ = println (to_string empty)
let single = [for x in [42] -> x + 1]
let _ = println (to_string single)
let nested = [for x in [1;2;3] -> to_string (x * x)]
let _ = println (to_string nested)
$ fn comp_edge.l3
[]
[43]
["1"; "4"; "9"]
()
화살표 -> 오른쪽에는 임의의 표현식을 쓸 수 있으므로, to_string이나 다른 함수를 호출하는 것도 자연스럽습니다.
리스트 패턴 매칭 (Pattern Matching on Lists)
4장 미리보기 – 리스트 패턴을 사용한 match:
fn> match [1; 2; 3] with | [] -> "empty" | x :: _ -> to_string x
"1"
중첩 구조 분해(nested destructuring):
fn> match [1; 2; 3] with | a :: b :: _ -> a + b | _ -> 0
3
패턴 매칭은 FunLang에서 리스트를 다루는 가장 강력한 방법입니다. x :: _는 “하나 이상의 원소가 있고 첫 번째는 x“를 의미합니다. a :: b :: _는 “두 개 이상의 원소가 있고 처음 두 개는 a와 b“를 의미합니다. 와일드카드 _는 “관심 없는 나머지 부분“을 나타냅니다.
직접 재귀 함수를 작성할 때 match xs with | [] -> ... | x :: rest -> ... 패턴이 거의 항상 등장하는 이유가 여기 있습니다. 리스트의 구조 자체를 코드로 표현하는 가장 자연스러운 방법이기 때문입니다. 패턴 매칭의 전체 기능은 4장에서 자세히 다룹니다.
4장: 패턴 매칭 (Pattern Matching)
C나 Java의 switch문을 써본 적이 있다면, 패턴 매칭은 그것의 대폭 강화된 버전이라고 생각할 수 있습니다. 하지만 실제로 써보면 차원이 다릅니다. switch는 값을 비교하는 것이 전부지만, 패턴 매칭은 데이터의 구조를 분해하면서 동시에 변수를 바인딩하고, 조건에 따라 분기합니다. 이 세 가지가 하나의 구문에서 일어납니다.
패턴 매칭이 함수형 프로그래밍의 핵심이라고 불리는 데는 이유가 있습니다. ADT(5장)를 정의하면 자연스럽게 패턴 매칭으로 처리하게 되고, 리스트를 순회할 때도, 에러를 처리할 때도, 복잡한 데이터를 파싱할 때도 패턴 매칭이 등장합니다. FunLang에서 가장 많이 쓰게 될 기능이니 이 장을 천천히 읽어보세요.
FunLang의 컴파일러는 패턴의 완전성(exhaustiveness)을 검사합니다. 빠뜨린 케이스가 있으면 경고해주고, 중복된 패턴도 알려줍니다. 그리고 내부적으로 패턴을 효율적인 결정 트리(decision tree)로 컴파일하여, 모든 케이스를 순차적으로 비교하는 것이 아니라 최소한의 비교만으로 올바른 분기에 도달합니다.
기본 Match 구문
Match 표현식은 match 키워드에 맞춰 정렬된 | 파이프를 사용합니다:
fn> match 2 with | 0 -> "zero" | 1 -> "one" | _ -> "other"
"other"
파일 모드에서 여러 줄의 match는 들여쓰기를 사용합니다:
$ cat classify.l3
let classify x =
match x with
| 0 -> "zero"
| 1 -> "one"
| _ -> "other"
let result = classify 1
$ fn classify.l3
"one"
파이프는 match 키워드의 열(column)에 맞춰야 하며, 그보다 들여쓰기하면 안 됩니다.
패턴 종류
상수 패턴 (Constant Patterns)
정수 및 불리언 리터럴:
fn> match true with | true -> "yes" | false -> "no"
"yes"
fn> match 3 with | 1 -> "one" | 2 -> "two" | 3 -> "three" | _ -> "other"
"three"
음수 정수도 패턴으로 사용할 수 있습니다:
fn> match (0 - 1) with | -1 -> "neg one" | 0 -> "zero" | _ -> "other"
"neg one"
디스패치 테이블을 위한 다중 상수 사용:
$ cat daytype.l3
let dayType d =
match d with
| 1 -> "Monday"
| 2 -> "Tuesday"
| 3 -> "Wednesday"
| 4 -> "Thursday"
| 5 -> "Friday"
| 6 -> "Saturday"
| 7 -> "Sunday"
| _ -> "invalid"
let result = dayType 3
$ fn daytype.l3
"Wednesday"
문자열 패턴 (String Patterns)
문자열 리터럴도 패턴으로 사용할 수 있습니다:
$ cat string_match.l3
let greet name =
match name with
| "Alice" -> "Hello, Alice!"
| "Bob" -> "Hi, Bob!"
| _ -> "Who are you, " + name + "?"
let result = greet "Alice"
$ fn string_match.l3
"Hello, Alice!"
커맨드 디스패치에 유용합니다:
$ cat cmd_dispatch.l3
let classify cmd =
match cmd with
| "quit" | "exit" | "q" -> "exit command"
| "help" | "?" -> "help command"
| _ -> "unknown: " + cmd
let result = classify "quit"
$ fn cmd_dispatch.l3
"exit command"
위 예제는 or-패턴과 문자열 패턴을 함께 사용합니다. or-패턴은 아래에서 설명합니다.
변수 및 와일드카드 패턴 (Variable and Wildcard Patterns)
변수 패턴은 매칭된 값을 이름에 바인딩합니다. _는 값을 버리는 와일드카드(wildcard)입니다:
fn> match 42 with | x -> x + 1
43
fn> match 42 with | _ -> 0
0
변수 패턴은 항상 매칭됩니다 – 모든 값을 잡는 기본 케이스(catch-all) 역할을 합니다:
$ cat sign.l3
let sign x =
match x with
| 0 -> 0
| n when n > 0 -> 1
| _ -> 0 - 1
let result = (sign 5, sign 0, sign (0 - 3))
$ fn sign.l3
(1, 0, -1)
섀도잉(Shadowing): 패턴 내의 변수는 같은 이름의 외부 바인딩을 가립니다:
fn> let x = 10 in match 5 with | x -> x
5
내부 x는 10이 아닌 5에 바인딩됩니다.
튜플 패턴 (Tuple Patterns)
튜플을 제자리에서 분해합니다:
fn> match (1, 2) with | (a, b) -> a + b
3
중첩 튜플 패턴:
fn> match ((1, 2), (3, 4)) with | ((a, b), (c, d)) -> a + b + c + d
10
튜플을 상수 및 와일드카드와 결합:
$ cat classify_pair.l3
let classify pair =
match pair with
| (true, 0) -> "zero-true"
| (true, x) -> "positive-true: " + to_string x
| (false, _) -> "false"
let result = classify (true, 42)
$ fn classify_pair.l3
"positive-true: 42"
리스트 패턴 (List Patterns)
빈 리스트, cons, 또는 특정 길이에 대해 매칭합니다:
fn> match [1; 2; 3] with | [] -> "empty" | x :: _ -> to_string x
"1"
fn> match [1; 2; 3] with | a :: b :: _ -> a + b | _ -> 0
3
정확한 길이 매칭:
$ cat list_describe.l3
let describe xs =
match xs with
| [] -> "empty"
| x :: [] -> "singleton: " + to_string x
| x :: y :: [] -> "pair: " + to_string x + "," + to_string y
| x :: y :: z :: _ -> "three+: " + to_string x + "," + to_string y + "," + to_string z
let r1 = describe []
let r2 = describe [42]
let r3 = describe [1; 2]
let r4 = describe [10; 20; 30; 40]
let result = r1 + " | " + r2 + " | " + r3 + " | " + r4
$ fn list_describe.l3
"empty | singleton: 42 | pair: 1,2 | three+: 10,20,30"
생성자 패턴 (Constructor Patterns)
대수적 데이터 타입(ADT)의 생성자를 매칭합니다. ADT는 5장: 대수적 데이터 타입에서 자세히 다룹니다. 여기서는 패턴 매칭에서 생성자를 어떻게 사용하는지에 집중합니다:
$ cat shape.l3
type Shape =
| Circle of int
| Rect of int * int
let area s =
match s with
| Circle r -> r * r * 3
| Rect (w, h) -> w * h
let result = area (Circle 5)
$ fn shape.l3
75
데이터가 없는 생성자 (nullary):
$ cat card.l3
type Card =
| Ace
| King
| Queen
| Jack
| Num of int
let value c =
match c with
| Ace -> 11
| King -> 10
| Queen -> 10
| Jack -> 10
| Num n -> n
let result = value Ace + value King + value (Num 5)
$ fn card.l3
26
생성자 내부에서 와일드카드 사용:
$ cat is_leaf.l3
type Tree =
| Leaf
| Node of Tree * int * Tree
let isLeaf t =
match t with
| Leaf -> true
| Node (_, _, _) -> false
let result = (isLeaf Leaf, isLeaf (Node (Leaf, 1, Leaf)))
$ fn is_leaf.l3
(true, false)
중첩 패턴 (Nested Patterns)
패턴은 자유롭게 합성할 수 있습니다 – 생성자 안의 생성자, 튜플 안의 리스트 등:
Option 안의 Option (Option 타입은 Prelude에서 제공되며, 9장에서 자세히 다룹니다. Some은 값이 있음, None은 없음을 나타냅니다):
$ cat deep_option.l3
let deepGet opt =
match opt with
| Some (Some (Some x)) -> to_string x
| Some (Some None) -> "inner none"
| Some None -> "mid none"
| None -> "outer none"
let r1 = deepGet (Some (Some (Some 42)))
let r2 = deepGet (Some (Some None))
let r3 = deepGet (Some None)
let r4 = deepGet None
let result = r1 + " | " + r2 + " | " + r3 + " | " + r4
$ fn deep_option.l3
"42 | inner none | mid none | outer none"
튜플의 리스트:
$ cat sum_first.l3
let rec sumFirst xs =
match xs with
| [] -> 0
| (a, _) :: rest -> a + sumFirst rest
let result = sumFirst [(1, "a"); (2, "b"); (3, "c")]
let _ = println (to_string result)
$ fn sum_first.l3
6
리스트 안의 튜플을 포함하는 생성자:
$ cat nested_complex.l3
type Opt 'a =
| None
| Some of 'a
let result =
match Some [1; 2; 3] with
| Some (x :: _) -> x
| Some [] -> 0
| None -> 0
$ fn nested_complex.l3
1
레코드 패턴 (Record Patterns)
레코드는 이름 붙은 필드를 가진 데이터 구조로, 6장: 레코드에서 자세히 다룹니다. 여기서는 match에서 레코드 필드를 구조 분해하는 방법만 살펴봅니다:
$ cat record_match.l3
type Point = { x: int; y: int }
let p = { x = 1; y = 2 }
let result =
match p with
| { x = a; y = b } -> a + b
$ fn record_match.l3
3
부분 레코드 패턴 – 일부 필드만 매칭:
$ cat record_partial.l3
type Person = { name: string; age: int; active: bool }
let greet p =
match p with
| { name = n; age = a } -> n + " is " + to_string a
let result = greet { name = "Alice"; age = 30; active = true }
$ fn record_partial.l3
"Alice is 30"
Or-패턴 (Or-Patterns)
여러 패턴이 같은 본문을 공유할 때 |로 결합합니다:
fn> match 2 with | 1 | 2 | 3 -> "small" | _ -> "big"
"small"
각 대안은 같은 결과 표현식으로 이어집니다. 파일 모드에서:
$ cat or_pattern.l3
let classify n =
match n with
| 0 -> "zero"
| 1 | 2 | 3 -> "small"
| 4 | 5 | 6 -> "medium"
| _ -> "large"
let result = classify 5
$ fn or_pattern.l3
"medium"
생성자와 Or-패턴
ADT 생성자에도 사용할 수 있습니다:
$ cat or_ctor.l3
type Direction =
| North
| South
| East
| West
let isVertical d =
match d with
| North | South -> true
| East | West -> false
let result = (isVertical North, isVertical East)
$ fn or_ctor.l3
(true, false)
문자열 Or-패턴
문자열 패턴과 조합하면 강력한 디스패치가 가능합니다:
$ cat or_string.l3
let respond input =
match input with
| "yes" | "y" | "ok" -> true
| "no" | "n" -> false
| _ -> false
let result = (respond "yes", respond "n", respond "maybe")
$ fn or_string.l3
(true, false, false)
소진 검사와 Or-패턴
Or-패턴은 소진 검사(exhaustiveness)에 올바르게 통합됩니다. 각 대안이 별도의 패턴으로 취급되어, or-패턴으로 모든 경우를 커버하면 경고가 나오지 않습니다:
$ cat or_exhaust.l3
type Color =
| Red
| Green
| Blue
let name c =
match c with
| Red -> "red"
| Green | Blue -> "cool"
let result = name Red
$ fn or_exhaust.l3
"red"
위 예제에서 Green | Blue가 나머지 모든 경우를 커버하므로 소진 경고가 없습니다.
제한 사항
- Or-패턴은 최상위 레벨에서만 지원됩니다. 중첩된 or-패턴 (
Some (1 | 2))은 아직 지원되지 않습니다. - Or-패턴 내에서 변수 바인딩은 허용되지 않습니다. 상수와 생성자 패턴만 사용하세요.
When 가드 (When Guards)
when을 사용하여 패턴에 불리언 조건을 추가합니다. 가드는
패턴이 매칭된 후에 평가됩니다. 가드가 실패하면 다음 절(clause)로
매칭이 계속됩니다.
$ cat guard.l3
let classify n =
match n with
| x when x > 0 -> "positive"
| 0 -> "zero"
| _ -> "negative"
let result = classify 5
$ fn guard.l3
"positive"
범위 분류를 위한 가드 (Guards for Range Classification)
여러 가드를 사용하여 범위 기반 디스패치를 만들 수 있습니다:
$ cat grade.l3
let grade score =
match score with
| s when s >= 90 -> "A"
| s when s >= 80 -> "B"
| s when s >= 70 -> "C"
| s when s >= 60 -> "D"
| _ -> "F"
let result = grade 85
$ fn grade.l3
"B"
생성자와 가드 결합
구조적 매칭과 값 조건을 결합합니다:
$ cat shape_guard.l3
type Shape =
| Circle of int
| Rect of int * int
let isLarge s =
match s with
| Circle r when r > 10 -> true
| Rect (w, h) when w * h > 100 -> true
| _ -> false
let r1 = isLarge (Circle 15)
let r2 = isLarge (Circle 5)
let r3 = isLarge (Rect (20, 10))
let result = (r1, r2, r3)
$ fn shape_guard.l3
(true, false, true)
가드 폴스루 (Guard Fallthrough)
가드가 실패하면 기본 케이스로 건너뛰는 것이 아니라 다음 절로 매칭이 계속됩니다. 이를 통해 계층적 조건을 구성할 수 있습니다:
$ cat fallthrough.l3
let classify x =
match x with
| n when n > 100 -> "large"
| n when n > 10 -> "medium"
| n when n > 0 -> "small"
| 0 -> "zero"
| _ -> "negative"
let result = classify 50
$ fn fallthrough.l3
"medium"
계산된 값에 대한 Match
변수뿐만 아니라 표현식의 결과에 대해서도 매칭할 수 있습니다:
$ cat match_expr.l3
let abs x = if x < 0 then 0 - x else x
let classify x =
match abs x with
| 0 -> "zero"
| n when n < 10 -> "small"
| n when n < 100 -> "medium"
| _ -> "large"
let result = classify (0 - 42)
$ fn match_expr.l3
"medium"
완전성 검사 (Exhaustiveness Checking)
컴파일러는 누락된 케이스에 대해 경고합니다 (W0001):
$ cat exhaustive.l3
type Color =
| Red
| Green
| Blue
let result =
match Red with
| Red -> 1
| Green -> 2
$ fn exhaustive.l3
Warning: warning[W0001]: Incomplete pattern match. Missing cases: Blue
--> :0:0-1:0
= hint: Add the missing cases or a wildcard pattern '_' to cover all values
1
중복 경고 (Redundancy Warnings)
컴파일러는 도달 불가능한 패턴에 대해서도 경고합니다 (W0002):
$ cat redundant.l3
let result =
match 1 with
| _ -> "catch all"
| 1 -> "one"
$ fn redundant.l3
Warning: warning[W0002]: Redundant pattern match clause. This pattern is never reached
--> :0:0-1:0
= hint: Remove this clause or reorder the patterns
"catch all"
와일드카드 _가 모든 값을 잡으므로, | 1 절은 도달 불가능합니다.
ADT 튜플의 완전성 검사
패턴 완전성 검사는 중첩된 구조에서도 작동합니다:
$ cat color_mix.l3
type Color =
| Red
| Green
| Blue
let mix a b =
match (a, b) with
| (Red, Blue) -> "purple"
| (Blue, Red) -> "purple"
| (Red, Green) -> "yellow"
| (Green, Red) -> "yellow"
| (Blue, Green) -> "cyan"
| (Green, Blue) -> "cyan"
| _ -> "same"
let result = mix Red Blue
$ fn color_mix.l3
"purple"
Let 패턴 구조 분해 (Let-Pattern Destructuring)
전체 match 없이 구조 분해할 수 있습니다:
fn> let (x, y) = (1, 2) in x + y
3
fn> let (a, b, c) = (1, 2, 3) in a + b + c
6
결정 트리 컴파일 (Decision Tree Compilation)
FunLang는 Jules Jacobs 알고리즘을 사용하여 패턴 매칭을 이진 결정 트리(binary decision tree)로 컴파일합니다. 이는 다음을 의미합니다:
- 중복 테스트 없음: 각 생성자는 실행 경로당 최대 한 번만 테스트됩니다
- 효율적인 디스패치: match당 O(depth)이며, O(clauses)가 아닙니다
- 절 공유: 공통 하위 패턴이 결정 노드를 공유합니다
정확성을 위해 이를 신경 쓸 필요는 없지만, 많은 절이 있는 복잡한 매칭도 효율적으로 처리된다는 것을 의미합니다.
실전 예제
재귀적 리스트 처리
리스트 재귀의 표준 패턴: 빈 리스트와 cons를 매칭하고, 꼬리(tail)에 대해 재귀합니다.
리스트 합계:
$ cat list_sum.l3
let rec sum xs =
match xs with
| [] -> 0
| x :: rest -> x + sum rest
let _ = println (to_string (sum [1; 2; 3; 4; 5]))
$ fn list_sum.l3
15
요소 개수 세기:
$ cat list_length.l3
let rec length xs =
match xs with
| [] -> 0
| _ :: rest -> 1 + length rest
let _ = println (to_string (length [10; 20; 30]))
$ fn list_length.l3
3
조건 함수로 필터링 (클로저 캡처):
$ cat list_filter.l3
let rec filter pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: filter pred t else filter pred t
let _ = println (to_string (filter (fun x -> x > 3) [1; 2; 3; 4; 5; 6]))
$ fn list_filter.l3
[4; 5; 6]
조건이 참인 동안 가져오기(take while):
$ cat take_while.l3
let result =
let rec takeWhile pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: takeWhile pred t else []
takeWhile (fun x -> x < 5) [1; 2; 3; 4; 5; 6; 7]
$ fn take_while.l3
[1; 2; 3; 4]
ADT 표현식 평가기 (ADT Expression Evaluator)
패턴 매칭은 재귀적 ADT 순회에서 특히 빛을 발합니다:
$ cat expr_eval.l3
type Expr =
| Num of int
| Add of Expr * Expr
| Mul of Expr * Expr
let result =
let rec eval e =
match e with
| Num n -> n
| Add (a, b) -> eval a + eval b
| Mul (a, b) -> eval a * eval b
eval (Add (Mul (Num 3, Num 4), Num 5))
$ fn expr_eval.l3
17
eval (Add (Mul (Num 3, Num 4), Num 5))는 (3 * 4) + 5 = 17을 계산합니다.
연관 리스트에서 조회 (Lookup in Association List)
튜플 리스트에 대한 패턴 매칭으로 키-값 조회를 수행합니다:
$ cat lookup.l3
let result =
let rec lookup key = fun xs ->
match xs with
| [] -> None
| (k, v) :: rest -> if k = key then Some v else lookup key rest
let env = [(1, "one"); (2, "two"); (3, "three")]
let r1 = lookup 2 env
let r2 = lookup 9 env
(r1, r2)
$ fn lookup.l3
(Some "two", None)
패턴 매칭을 이용한 트리 순회
모든 트리 연산은 자연스러운 패턴 매칭으로 표현됩니다:
$ cat tree_ops.l3
type Tree =
| Leaf
| Node of Tree * int * Tree
let result =
// 트리의 깊이: 왼쪽/오른쪽 중 더 깊은 쪽 + 1
let rec depth t =
match t with
| Leaf -> 0
| Node (l, _, r) -> 1 + max (depth l) (depth r)
// 트리의 노드 수
let rec size t =
match t with
| Leaf -> 0
| Node (l, _, r) -> 1 + size l + size r
// 트리의 모든 값의 합
let rec sumTree t =
match t with
| Leaf -> 0
| Node (l, v, r) -> sumTree l + v + sumTree r
let t = Node (Node (Leaf, 1, Leaf), 2, Node (Leaf, 3, Node (Leaf, 4, Leaf)))
(depth t, size t, sumTree t)
$ fn tree_ops.l3
(3, 4, 10)
패턴 매칭을 이용한 삽입 정렬 (Insertion Sort)
두 개의 재귀 함수를 연결합니다:
$ cat isort.l3
let sorted =
let rec insert x = fun xs ->
match xs with
| [] -> x :: []
| h :: t -> if x <= h then x :: h :: t else h :: insert x t
let rec sort xs =
match xs with
| [] -> []
| h :: t -> insert h (sort t)
sort [5; 3; 8; 1; 9; 2; 7; 4; 6]
let result = sorted
$ fn isort.l3
[1; 2; 3; 4; 5; 6; 7; 8; 9]
요약
| 패턴 | 구문 | 예제 |
|---|---|---|
| 상수 | 0, true | | 0 -> "zero" |
| 문자열 | "hello" | | "hello" -> "hi" |
| 변수 | x | | x -> x + 1 |
| 와일드카드 | _ | | _ -> "default" |
| 튜플 | (a, b) | | (x, y) -> x + y |
| 빈 리스트 | [] | | [] -> "empty" |
| 리스트 cons | h :: t | | x :: rest -> x |
| 생성자 | Some x | | Some v -> v |
| 레코드 | { x = a } | | { x = a } -> a |
| 중첩 | Some (x :: _) | | Some (h :: _) -> h |
| Or-패턴 | 1 | 2 | 3 | | 1 | 2 | 3 -> "small" |
| 가드 포함 | x when cond | | n when n > 0 -> "pos" |
5장: 대수적 데이터 타입 (Algebraic Data Types)
대수적 데이터 타입(ADT)은 함수형 프로그래밍의 핵심 도구입니다. “대수적“이라는 이름이 어렵게 들릴 수 있지만, 본질은 간단합니다 — 여러 가지 형태를 가질 수 있는 타입을 정의하는 방법입니다. Python의 클래스 계층 구조나 Java의 상속을 대체할 수 있는, 훨씬 간결하고 안전한 방식이라고 생각하면 됩니다.
ADT를 처음 접하는 분들은 “그냥 열거형(enum) 아닌가요?“라고 물을 수 있습니다. 맞습니다 — 단순한 경우에는 열거형입니다. 하지만 각 케이스가 서로 다른 종류의 데이터를 담을 수 있다는 점이 결정적으로 다릅니다. 이 차이가 ADT를 강력하게 만드는 이유입니다. ADT의 더 강력한 확장인 GADT(일반화된 대수적 데이터 타입)는 14장에서 다룹니다.
단순 열거형
가장 기본적인 형태부터 시작합니다. 이름 있는 생성자(constructor)를 가진 타입을 정의합니다:
$ cat color.l3
type Color =
| Red
| Green
| Blue
let result =
match Green with
| Red -> "red"
| Green -> "green"
| Blue -> "blue"
$ fn color.l3
"green"
Red, Green, Blue는 단순히 Color 타입의 값입니다. Python에서 class Color(Enum)으로 만드는 것과 비슷하지만, match 표현식이 함께 쓰일 때 진가가 드러납니다. 컴파일러가 모든 케이스를 다뤘는지 확인해 주기 때문입니다.
선행 파이프 구문
케이스가 많아지면 한 줄에 나열하는 것이 읽기 불편해집니다. 여러 줄의 타입 정의에서는 선행 파이프(leading pipe)를 사용할 수 있습니다:
$ cat direction.l3
type Direction =
| North
| South
| East
| West
let result =
match North with
| North -> "up"
| South -> "down"
| East -> "right"
| West -> "left"
$ fn direction.l3
"up"
선행 파이프는 단순히 스타일의 문제입니다 — 두 방식 모두 동일하게 동작합니다. 하지만 케이스가 4개 이상이라면 선행 파이프 방식이 훨씬 읽기 좋습니다. F#이나 OCaml 코드베이스에서도 이 관례를 자주 볼 수 있습니다.
데이터를 가진 생성자
ADT가 단순 열거형과 달라지는 지점입니다. 생성자는 of를 사용하여 값을 포함할 수 있습니다:
$ cat shape.l3
type Shape =
| Circle of int
| Rect of int * int
let area s =
match s with
| Circle r -> r * r * 3
| Rect (w, h) -> w * h
let result = area (Rect (3, 4))
$ fn shape.l3
12
Circle은 반지름 하나를 담고, Rect는 너비와 높이 두 개를 담습니다. 서로 다른 케이스가 서로 다른 구조를 가질 수 있다는 것이 ADT의 힘입니다. Python에서 이를 표현하려면 별도의 클래스를 만들고 공통 기반 클래스를 상속받아야 하지만, FunLang에서는 한 줄로 끝납니다.
패턴 매칭에서 Rect (w, h)처럼 쓰면 생성자에 담긴 데이터가 자동으로 구조 분해(destructuring)됩니다. 별도의 getter나 필드 접근이 필요 없습니다.
매개변수화된 타입
같은 구조를 다양한 타입에 재사용하고 싶을 때 타입 매개변수를 사용합니다. 타입 매개변수(type parameter)는 타입 이름 뒤에 위치합니다:
$ cat option.l3
type Option 'a =
| None
| Some of 'a
let x = Some 42
let result =
match x with
| Some v -> v
| None -> 0
$ fn option.l3
42
Option은 함수형 프로그래밍에서 가장 중요한 타입 중 하나입니다. “값이 있을 수도 있고 없을 수도 있는” 상황을 null 없이 표현합니다. 'a는 타입 변수 — 어떤 타입이든 담을 수 있다는 의미입니다. Option 'a라고 선언하면, 실제로 Some 42를 만들 때 컴파일러가 'a가 int임을 자동으로 추론합니다.
--emit-type으로 추론된 타입을 확인할 수 있습니다:
$ fn --emit-type option.l3
result : int
x : Option<int>
x의 타입이 Option<int>로 정확하게 추론된 것을 볼 수 있습니다. 이 타입 추론 덕분에 타입을 명시하지 않아도 컴파일러가 타입 안전성을 보장해 줍니다.
여러 개의 타입 매개변수도 사용할 수 있습니다:
$ cat either.l3
type Either 'a 'b =
| Left of 'a
| Right of 'b
let result =
match Left 42 with
| Left n -> n
| Right s -> String.length s
$ fn either.l3
42
Either는 두 가지 가능성을 표현합니다. Haskell에서는 오류 처리에 자주 쓰이는 패턴으로, Left는 보통 실패를, Right는 성공을 나타냅니다. 여기서는 Left에 int, Right에 string을 담을 수 있는 타입을 한 줄로 정의했습니다.
재귀 타입
ADT의 또 다른 강력한 특징은 자기 자신을 참조할 수 있다는 점입니다. 이를 통해 리스트, 트리, 그래프 같은 재귀적 자료 구조를 자연스럽게 표현할 수 있습니다:
$ cat intlist.l3
type IntList =
| Nil
| Cons of int * IntList
let xs = Cons (1, Cons (2, Cons (3, Nil)))
let result =
let rec sum xs =
match xs with
| Nil -> 0
| Cons (x, rest) -> x + sum rest
in
sum xs
$ fn intlist.l3
6
Cons (1, Cons (2, Cons (3, Nil)))는 [1; 2; 3] 리스트를 직접 구현한 것입니다. FunLang의 내장 리스트도 사실 이런 식으로 동작합니다. 재귀 타입은 재귀 함수와 자연스럽게 짝을 이룹니다 — 타입의 구조가 함수의 구조를 그대로 반영합니다.
깊이(depth) 함수를 가진 이진 트리:
$ cat tree.l3
type Tree =
| Leaf of int
| Branch of Tree * Tree
let t = Branch (Leaf 1, Branch (Leaf 2, Leaf 3))
let result =
// 트리의 깊이: 왼쪽/오른쪽 중 더 깊은 쪽 + 1
let rec depth t =
match t with
| Leaf _ -> 1
| Branch (l, r) -> 1 + max (depth l) (depth r)
in
depth t
$ fn tree.l3
3
이진 트리를 두 줄로 정의하고, 깊이 함수까지 자연스럽게 작성했습니다. 객체지향 언어에서 이와 동등한 코드를 작성하려면 추상 기반 클래스와 두 개의 서브클래스가 필요합니다. ADT는 이런 상황에서 코드를 극적으로 줄여줍니다.
참고: let rec은 표현식 수준(in과 함께)에서만 동작하며, 모듈 수준에서는 사용할 수 없습니다.
파일 모드에서 재귀 함수를 사용하려면 최상위 let 안에 let rec ... in을 포함시키세요.
상호 재귀 타입
때로는 두 타입이 서로를 참조해야 할 때가 있습니다. and를 사용하여 서로를 참조하는 타입을 정의할 수 있습니다:
$ cat mutual.l3
type Tree =
| Leaf of int
| Node of Forest
and Forest = Empty | Trees of Tree * Forest
let result = Node (Trees (Leaf 1, Trees (Leaf 2, Empty)))
$ fn mutual.l3
Node (Trees ((Leaf 1, Trees ((Leaf 2, Empty)))))
Tree는 Forest를 참조하고, Forest는 Tree를 참조합니다. 두 타입을 별도로 선언하면 컴파일러가 먼저 선언된 타입을 아직 모르는 상태에서 후자를 정의해야 하는 문제가 생깁니다. and 키워드는 “이 두 타입을 동시에 정의한다“는 의미로, 이 문제를 깔끔하게 해결합니다. F#과 OCaml에서도 동일한 and 키워드를 사용합니다.
완전성 검사
ADT와 패턴 매칭의 조합이 특히 빛을 발하는 순간이 바로 여기입니다. 컴파일러는 match 패턴이 불완전할 때 경고합니다:
$ cat exhaustive.l3
type Color =
| Red
| Green
| Blue
let result =
match Red with
| Red -> 1
| Green -> 2
$ fn exhaustive.l3
Warning: warning[W0001]: Incomplete pattern match. Missing cases: Blue
--> :0:0-1:0
= hint: Add the missing cases or a wildcard pattern '_' to cover all values
1
Blue 케이스를 빠뜨렸을 때 컴파일러가 정확히 어떤 케이스가 없는지 알려줍니다. 이 기능은 생각보다 훨씬 중요합니다. 나중에 타입에 케이스를 추가했을 때, 그 타입을 다루는 모든 match 표현식에서 경고가 발생합니다. 즉, 컴파일러가 “이 새로운 케이스를 처리하는 걸 잊지 마세요“라고 자동으로 알려주는 셈입니다. Python의 if/elif/else 체인에서는 절대 얻을 수 없는 안전성입니다.
프로그램은 여전히 실행되지만, 경고가 누락된 케이스를 알려줍니다.
와일드카드 _를 추가하거나 모든 생성자를 다루면 경고가 사라집니다.
실용 예제: 간단한 계산기
ADT의 실용적인 활용을 보여주는 고전적인 예제입니다. 산술 표현식을 데이터 구조로 표현하고, 그것을 평가하는 인터프리터를 만들 수 있습니다:
$ cat calc.l3
type Expr =
| Num of int
| Plus of Expr * Expr
| Mul of Expr * Expr
let e = Plus (Num 2, Mul (Num 3, Num 4))
let result =
let rec eval e =
match e with
| Num n -> n
| Plus (a, b) -> eval a + eval b
| Mul (a, b) -> eval a * eval b
in
eval e
$ fn calc.l3
14
Plus (Num 2, Mul (Num 3, Num 4))는 2 + (3 * 4)를 트리로 표현한 것입니다. eval 함수는 이 트리를 순회하며 실제 값을 계산합니다. 실제 프로그래밍 언어 인터프리터도 이와 같은 방식으로 동작합니다 — AST(Abstract Syntax Tree)를 ADT로 정의하고, 각 케이스를 패턴 매칭으로 처리합니다. 이 예제에서 ADT가 “왜” 유용한지가 가장 잘 드러납니다.
타입 별칭 (Type Aliases)
지금까지 본 ADT는 새로운 타입을 만들었습니다. 하지만 때로는 기존 타입에 의미 있는 이름을 붙이고 싶을 때가 있습니다. type Name = ExistingType으로 기존 타입에 별칭을 부여할 수 있습니다:
$ cat alias_basic.l3
type Name = string
type Age = int
let greet name age = name + " is " + to_string age
let result = greet "Alice" 30
$ fn alias_basic.l3
"Alice is 30"
Name과 Age는 코드를 읽는 사람에게 “이 문자열은 이름이고, 이 정수는 나이입니다“라는 의도를 전달합니다. 함수 시그니처에서 string -> int -> string 대신 Name -> Age -> string처럼 읽히면 훨씬 명확해집니다.
타입 별칭은 **투명(transparent)**합니다 — 별칭과 원본 타입은 완전히 동일합니다.
Name은 string과 같은 타입이므로, string 함수를 그대로 사용할 수 있습니다.
복합 타입 별칭
단순 타입뿐 아니라 튜플, 함수, 리스트 타입에도 별칭을 붙일 수 있습니다:
$ cat alias_complex.l3
type IntPair = int * int
type Transform = int -> int
type IntList = int list
let swap p =
match p with
| (a, b) -> (b, a)
let result = swap (1, 2)
$ fn alias_complex.l3
(2, 1)
Transform = int -> int처럼 함수 타입에 별칭을 붙이면 특히 유용합니다. 고차 함수를 많이 사용하는 코드에서 (int -> int) -> (int -> int) 같은 타입보다 Transform -> Transform이 훨씬 읽기 좋습니다.
타입 별칭 vs ADT
두 기능을 혼동하지 않도록 주의하세요. 핵심 차이는 “새로운 타입을 만드는가“입니다:
type Name = string— 별칭.Name은string과 동일type Color = Red | Green | Blue— ADT.Color는 새로운 타입
타입 별칭은 문서화와 가독성을 위한 도구이고, ADT는 새로운 데이터 구조를 정의하는 도구입니다. 별칭은 기존 함수를 그대로 사용할 수 있지만, ADT는 패턴 매칭을 통해서만 값에 접근할 수 있습니다.
--emit-type에서 별칭은 원본 타입으로 표시됩니다:
$ cat alias_emit.l3
type Name = string
let x = "hello"
$ fn --emit-type alias_emit.l3
x : string
Name이 아닌 string으로 표시됩니다. 컴파일러 입장에서 별칭은 완전히 투명하기 때문입니다. 이 점이 Haskell의 newtype과 다른 부분입니다 — newtype은 별도의 타입으로 취급되지만, FunLang의 타입 별칭은 단순히 다른 이름일 뿐입니다.
6장: 레코드 (Records)
앞 장에서 ADT를 배웠습니다. ADT가 “이 값은 A이거나, B이거나, C다“라는 선택(합 타입)을 표현한다면, 레코드는 “이 값은 A이고, B이고, C다“라는 묶음(곱 타입)을 표현합니다. 함께 속하는 데이터를 하나의 단위로 묶고, 각 부분에 이름을 붙이는 것이 레코드의 역할입니다.
Python의 dataclass, Rust의 struct, F#의 record와 본질적으로 같은 개념입니다. 다만 FunLang의 레코드는 기본적으로 불변(immutable)이라는 점이 다릅니다 — 명시적으로 mutable을 선언하지 않으면 값을 변경할 수 없습니다.
레코드 타입 선언
이름 있는 필드를 가진 레코드를 정의합니다:
$ cat point.l3
type Point = { px: int; py: int }
let p = { px = 3; py = 4 }
let result = p.px + p.py
$ fn point.l3
7
타입 선언에서는 :로 필드 이름과 타입을 구분하고, 생성 시에는 =로 필드 이름과 값을 연결합니다. 세미콜론 ;은 필드 구분자입니다. 처음에 : vs =가 헷갈릴 수 있으니 주의하세요 — 선언은 콜론, 생성은 등호입니다.
중요: 필드 이름은 모든 레코드 타입에 걸쳐 전역적으로 고유해야 합니다.
두 레코드 타입이 같은 필드 이름을 공유할 수 없습니다. 이는 컴파일러가 필드 이름만으로 어떤 레코드 타입인지 결정하기 때문입니다. 예를 들어 Point와 Vector 두 타입이 모두 x 필드를 가질 수는 없습니다. 이 제약을 피하기 위해 px, py처럼 타입 접두사를 붙이는 관례를 사용하거나, 10장: 모듈에서 다루는 모듈 시스템으로 이름 공간을 분리하는 방법이 있습니다.
필드 접근
점 표기법(dot notation)으로 필드에 접근합니다:
$ cat access.l3
type Person = { name: string; age: int }
let alice = { name = "Alice"; age = 30 }
let result = alice.name + " is " + to_string alice.age
$ fn access.l3
"Alice is 30"
alice.name처럼 점 표기법을 사용하는 것은 대부분의 언어에서 익숙한 방식입니다. 레코드를 변수에 담아두고 필요한 필드를 꺼내 쓰는 것이 일반적인 패턴입니다.
연쇄 필드 접근
레코드 안에 레코드가 중첩되어 있을 때, 점 표기법을 이어 붙여 깊은 곳의 값에 접근할 수 있습니다:
$ cat nested.l3
type Inner = { val: int }
type Outer = { inner: Inner }
let o = { inner = { val = 42 } }
let result = o.inner.val
$ fn nested.l3
42
o.inner.val처럼 연쇄 접근은 자연스럽게 읽힙니다. 복잡한 설정 값이나 계층적인 데이터 구조를 표현할 때 중첩 레코드가 유용합니다. 다만 중첩이 너무 깊어지면 업데이트가 번거로워질 수 있습니다 — 이 부분은 다음 섹션의 복사 후 갱신에서 더 자세히 다룹니다.
복사 후 갱신
함수형 프로그래밍에서는 기존 값을 변경하는 대신 수정된 새 값을 만드는 방식을 선호합니다. { record with field = value } 구문으로 수정된 복사본을 생성합니다:
$ cat update.l3
type Point = { px: int; py: int }
let p = { px = 1; py = 2 }
let moved = { p with px = 10 }
let result = moved
$ fn update.l3
{ px = 10; py = 2 }
{ p with px = 10 }은 “p의 모든 필드를 그대로 복사하되, px만 10으로 바꾼 새 레코드를 만들어라“는 의미입니다. py = 2는 자동으로 복사됩니다. 필드가 많은 레코드에서 하나만 바꾸고 싶을 때 특히 편리합니다.
여러 필드를 한 번에 갱신할 수 있습니다:
$ cat multi_update.l3
type Vec3 = { vx: int; vy: int; vz: int }
let v = { vx = 1; vy = 2; vz = 3 }
let result = { v with vx = 10; vy = 20 }
$ fn multi_update.l3
{ vx = 10; vy = 20; vz = 3 }
with 뒤에 세미콜론으로 구분하여 여러 필드를 동시에 지정할 수 있습니다. vz = 3은 원본 v에서 그대로 가져옵니다.
원본 레코드는 변경되지 않습니다 – 복사 후 갱신은 새로운 값을 생성합니다. 이 점이 중요합니다. moved를 만든 이후에도 원본 p는 { px = 1; py = 2 }로 그대로 남아 있습니다. 이런 불변성 덕분에 값을 공유하거나 히스토리를 추적하는 코드에서 예상치 못한 변경으로 인한 버그가 발생하지 않습니다.
레코드 패턴 매칭
필드 접근 외에도, match 표현식에서 레코드를 구조 분해할 수 있습니다:
$ cat record_match.l3
type Point = { px: int; py: int }
let p = { px = 3; py = 4 }
let result =
match p with
| { px = a; py = b } -> a + b
$ fn record_match.l3
7
패턴에서 { px = a; py = b }는 “레코드의 px 필드를 a에 바인딩하고, py 필드를 b에 바인딩하라“는 의미입니다. 이후 a와 b를 일반 변수처럼 사용할 수 있습니다. ADT와 레코드를 함께 사용할 때 이 패턴이 자연스럽게 쓰입니다 — 예를 들어 Some { px = x; py = y }처럼 중첩 구조를 한 번에 분해할 수 있습니다.
가변 필드
지금까지의 레코드는 모두 불변이었습니다. 하지만 상태를 추적해야 하는 경우도 있습니다. mutable로 필드를 선언하면 제자리 갱신(in-place update)이 가능합니다:
$ cat counter.l3
type Counter = { mutable count: int }
let c = { count = 0 }
let _ = c.count <- c.count + 1
let _ = c.count <- c.count + 1
let _ = c.count <- c.count + 1
let result = c.count
$ fn counter.l3
3
<- 연산자는 필드를 제자리에서 갱신하고 unit ()을 반환합니다.
모듈 수준에서 변이(mutation)를 순차적으로 실행하려면 let _ =을 사용하세요.
가변 필드는 강력하지만 신중하게 사용해야 합니다. 값이 언제 바뀔지 예측하기 어려워지면 버그를 찾기가 힘들어집니다. 일반적인 원칙은 진짜 상태(예: 캐시, 카운터, 외부 리소스)가 아니면 불변 레코드와 복사 후 갱신을 선호하는 것입니다.
매개변수화된 레코드
ADT처럼 레코드도 타입 매개변수를 가질 수 있습니다. 이를 통해 같은 구조를 여러 타입에 재사용할 수 있습니다:
$ cat pair.l3
type Pair 'a = { fst: 'a; snd: 'a }
let p = { fst = 1; snd = 2 }
let result = p.fst + p.snd
$ fn pair.l3
3
Pair 'a는 같은 타입의 두 값을 묶는 레코드입니다. { fst = 1; snd = 2 }를 만들면 컴파일러가 'a를 int로 추론합니다. 두 필드의 타입이 다른 쌍을 만들고 싶다면 type Pair 'a 'b = { fst: 'a; snd: 'b }처럼 타입 매개변수를 두 개로 늘리면 됩니다.
구조적 동치
레코드의 동등 비교는 내용 기반으로 이루어집니다. 같은 타입, 같은 필드 값이면 같은 레코드입니다:
$ cat equality.l3
type Point = { px: int; py: int }
let p1 = { px = 1; py = 2 }
let p2 = { px = 1; py = 2 }
let p3 = { px = 1; py = 3 }
let r1 = if p1 = p2 then "equal" else "not equal"
let result = if p1 = p3 then "equal" else "not equal"
$ fn equality.l3
"not equal"
p1과 p2는 별개의 값이지만 모든 필드가 같으므로 동등합니다. p1과 p3는 py가 다르므로 동등하지 않습니다. 이 구조적 동치(structural equality)는 참조 동등성(reference equality)을 사용하는 Java의 ==와 다릅니다 — Java에서는 두 new Point(1, 2)가 서로 다르다고 판단합니다. FunLang에서는 내용이 같으면 같습니다.
실용 예제: 가변 상태
가변 필드의 실용적인 사용 예입니다. 입금과 잔액 확인이 가능한 은행 계좌:
$ cat account.l3
type Account = { mutable balance: int }
let acct = { balance = 100 }
let _ = acct.balance <- acct.balance + 50
let _ = acct.balance <- acct.balance - 30
let result = acct.balance
$ fn account.l3
120
초기 잔액 100에서 50을 더하고 30을 빼면 120이 됩니다. 이처럼 시간이 지남에 따라 상태가 변해야 하는 경우에 가변 필드가 적합합니다. 다만 실제 금융 시스템이라면 불변 레코드와 트랜잭션 히스토리를 함께 유지하는 방식이 더 안전하겠지만, 여기서는 가변 필드의 동작 방식을 보여주는 데 집중합니다.
제한 사항
FunLang 레코드에는 현재 두 가지 제약이 있습니다. 이를 미리 알아두면 당황하지 않을 수 있습니다:
- 필드 단축 표기 불가:
{ px = px; py = py }의 축약형으로{ px; py }를 사용할 수 없습니다. JavaScript의{ px, py }단축 표기에 익숙하다면 아쉬울 수 있지만, 현재는 항상 명시적으로{ px = px; py = py }라고 써야 합니다. - 전역적으로 고유한 필드: 두 레코드 타입이 같은 필드 이름을 공유할 수 없습니다.
컴파일러가 필드 이름으로 레코드 타입을 결정하므로, 고유성이 필요합니다. 여러 레코드 타입을 정의할 때 필드 이름 앞에 타입 약어를 붙이는 관례(
px,py대신 단순히x,y를 쓰면 충돌 가능)를 따르면 이 제약을 자연스럽게 회피할 수 있습니다.
7장: 문자열과 출력 (Strings and Output)
문자열 처리는 어떤 언어에서든 실질적인 프로그래밍의 기반입니다. FunLang는 간결하지만 충분히 강력한 문자열 함수들을 제공하고, printf 계열 함수로 형식화된 출력도 지원합니다. 이 장에서는 문자열을 만들고 조작하는 방법부터, 화면에 출력하는 다양한 방법까지 살펴봅니다.
문자열 리터럴
문자열 리터럴은 큰따옴표와 표준 이스케이프 시퀀스를 사용합니다:
fn> "hello world"
"hello world"
fn> "tab\there"
"tab here"
\t, \n, \\ 같은 표준 이스케이프 시퀀스를 모두 사용할 수 있습니다. REPL에서 문자열을 입력하면 따옴표가 포함된 결과가 나타나는데, 이는 “이것이 문자열 값임“을 명시하는 것입니다. 파일 출력(println 등)에서는 따옴표 없이 내용만 출력됩니다.
문자열 연결
+ 연산자로 문자열을 연결합니다:
fn> "hello" + " " + "world"
"hello world"
FunLang에서 +는 정수 덧셈과 문자열 연결 모두에 사용됩니다. 타입 추론 덕분에 컴파일러가 문맥에서 어떤 +인지 판단합니다. 단, 정수와 문자열을 섞어 쓰면 타입 오류가 발생합니다 — "age: " + 30은 안 되고, "age: " + to_string 30이 필요합니다.
문자열 함수
FunLang는 자주 쓰이는 문자열 연산을 Prelude의 String 모듈과 ^^ 연산자로 제공합니다. 각 함수의 동작 방식과 언제 사용하면 좋은지 함께 살펴봅니다.
참고: 이 함수들은 내부적으로
string_length,string_sub,string_concat,string_contains등의 raw builtin 함수를 기반으로 합니다. raw builtin을 직접 호출할 수도 있지만, 일반적으로는String모듈 함수와^^연산자를 사용하는 것을 권장합니다.
String.length
문자 수를 반환합니다:
fn> String.length "hello"
5
fn> String.length ""
0
빈 문자열의 길이는 0입니다. 인덱스 범위 검사나 반복 횟수 계산에 자주 쓰입니다.
^^ 연산자 (문자열 연결)
^^ 연산자로 두 문자열을 연결합니다 (Core 모듈에서 제공):
fn> "hello" ^^ " world"
"hello world"
+ 연산자와 결과는 같지만, ^^는 문자열 전용 연결 연산자라는 점이 다릅니다. 람다와 함께 사용하면 접두사를 붙이는 함수를 만들 수 있습니다:
$ cat prefix.l3
let add_prefix = fun s -> "prefix:" ^^ s
let result = add_prefix "value"
$ fn prefix.l3
"prefix:value"
fun s -> "prefix:" ^^ s는 “앞에 ’prefix:’를 붙이는 함수“입니다. 이렇게 만든 add_prefix를 파이프라인에서 재사용할 수 있습니다. 예를 들어 리스트의 모든 항목에 접두사를 붙이거나, 고차 함수에 인자로 전달할 때 편리합니다.
String.substring
시작 인덱스와 길이로 부분 문자열을 추출합니다:
fn> String.substring "hello" 1 3
"ell"
String.substring s start len은 인덱스 start부터 len개의 문자를 반환합니다.
인덱스는 0부터 시작합니다.
Python의 슬라이싱 s[1:4]와 비슷하지만, 끝 인덱스가 아니라 길이를 지정한다는 점에 주의하세요. Python에서 s[1:4]는 인덱스 1부터 3까지 3개의 문자를 가져오지만, FunLang에서는 String.substring s 1 3으로 동일한 결과를 얻습니다. start + len이 문자열 길이를 초과하지 않도록 주의하세요.
String.contains
문자열이 부분 문자열을 포함하는지 확인합니다:
fn> String.contains "hello world" "world"
true
fn> String.contains "hello" "xyz"
false
검색 결과를 bool로 반환하므로 if 표현식이나 조건 분기에 바로 사용할 수 있습니다. 파일 경로 검증, 키워드 필터링 같은 간단한 텍스트 검색에 유용합니다.
to_string
모든 타입의 값을 문자열 표현으로 변환합니다:
fn> to_string 42
"42"
fn> to_string true
"true"
fn> to_string "already a string"
"already a string"
ADT, 리스트, 튜플, 레코드 등 모든 복합 타입도 지원합니다:
fn> to_string (Some 42)
"Some 42"
fn> to_string [1; 2; 3]
"[1; 2; 3]"
fn> to_string (1, true)
"(1, true)"
to_string은 FunLang에서 가장 유연한 함수 중 하나입니다. 디버깅할 때 복잡한 값을 출력해보고 싶을 때, 또는 로그 메시지를 만들 때 항상 to_string으로 시작하면 됩니다.
참고: 문자열은 그대로 반환됩니다 (따옴표 없음, F# string 함수와 동일).
복합 타입 내부의 문자열에는 따옴표가 포함됩니다: to_string (Some "hi") → Some "hi".
이 동작이 처음에는 약간 헷갈릴 수 있습니다. to_string "hello"는 "hello"(따옴표 없이 hello)를 반환하지만, to_string (Some "hello")는 "Some \"hello\"" 형태로 내부 문자열에 따옴표가 붙습니다. 컨테이너 안의 문자열이라는 맥락에서 따옴표가 필요하기 때문입니다.
string_to_int
문자열을 정수로 파싱합니다:
fn> string_to_int "123"
123
외부에서 입력받은 숫자 문자열을 계산에 사용하기 전에 정수로 변환할 때 필요합니다. 파싱이 실패하는 경우(숫자가 아닌 문자열)에 대한 처리는 현재 별도로 필요합니다.
출력 함수
FunLang는 네 가지 출력 함수를 제공합니다. 각각 줄바꿈과 형식화 여부가 다릅니다. 어떤 상황에 어떤 함수를 쓰면 좋은지 함께 알아봅니다.
줄바꿈 없이 문자열을 출력하고, unit을 반환합니다:
fn> print "hello"
hello()
대화형 세션에서는 부수 효과 텍스트가 () 결과 바로 앞에 나타납니다. 여러 값을 같은 줄에 이어 출력하거나, 진행 상황을 한 줄에 표시할 때 사용합니다.
println
줄바꿈을 포함하여 문자열을 출력하고, unit을 반환합니다:
fn> println "hello"
hello
()
가장 자주 쓰이는 출력 함수입니다. 한 줄씩 메시지를 출력할 때 기본 선택입니다. print와의 차이는 출력 후 자동으로 줄바꿈(\n)을 추가한다는 것입니다.
printf
지정자를 사용한 형식화 출력:
| 지정자 | 타입 | 예제 |
|---|---|---|
%d | int | printf "%d" 42 |
%s | string | printf "%s" "hi" |
%b | bool | printf "%b" true |
%% | 리터럴 % | printf "100%%" |
printf는 커링되어 있으며, 각 지정자가 하나의 인자를 소비합니다:
$ cat printf_demo.l3
let _ = printf "%s is %d years old\n" "Alice" 30
let result = 0
$ fn printf_demo.l3
Alice is 30 years old
0
C의 printf나 Python의 % 포맷팅에 익숙하다면 자연스럽게 느껴질 것입니다. 중요한 차이는 FunLang의 printf가 커링되어 있다는 점입니다 — printf "%s is %d" "Alice"는 아직 인자를 하나 더 받아야 하는 함수를 반환합니다. 이 덕분에 부분 적용이 가능합니다. 예를 들어 let log_int = printf "value: %d\n"으로 정수를 로깅하는 함수를 만들 수 있습니다.
복수 지정자:
$ cat printf_multi.l3
let _ = printf "name=%s, active=%b\n" "Bob" true
$ fn printf_multi.l3
name=Bob, active=true
0
형식 문자열의 지정자 순서와 인자의 순서가 일치해야 합니다. 지정자의 타입과 실제 인자의 타입이 다르면 타입 오류가 발생합니다 — 이것이 단순 문자열 연결보다 printf가 안전한 이유입니다.
printfn
printf와 동일하지만 자동으로 줄바꿈을 추가합니다:
$ cat printfn_demo.l3
let _ = printfn "name=%s, age=%d" "Alice" 30
let result = 0
$ fn printfn_demo.l3
name=Alice, age=30
0
printf "...\n"을 쓸 필요 없이 printfn "..."을 사용하세요. 줄바꿈을 형식 문자열 끝에 매번 붙이는 것을 잊기 쉽습니다. 한 줄씩 형식화된 출력을 할 때는 printfn이 실수를 줄여줍니다.
sprintf
형식화된 문자열을 반환합니다 (출력하지 않음):
fn> sprintf "%d + %d = %d" 1 2 3
"1 + 2 = 3"
fn> sprintf "name=%s" "Alice"
"name=Alice"
sprintf는 출력하지 않고 형식화된 문자열 값을 만들어 돌려줍니다. 바로 출력하는 대신 나중에 사용하거나 다른 함수에 전달할 형식화된 문자열이 필요할 때 유용합니다. Python의 str.format()나 f-string과 역할이 비슷합니다.
+와 to_string 대신 sprintf를 사용하면 더 간결합니다:
$ cat sprintf_vs.l3
// 이전: 수동 연결
let old = "result=" ^^ to_string 42
// 이후: sprintf
let new_ = sprintf "result=%d" 42
let result = (old, new_)
$ fn sprintf_vs.l3
("result=42", "result=42")
두 방법의 결과는 같지만, sprintf가 형식을 한눈에 파악하기 좋습니다. 특히 여러 값을 조합할 때 차이가 뚜렷합니다 — "name=" + to_string name + ", age=" + to_string age보다 sprintf "name=%s, age=%d" name age가 훨씬 읽기 좋습니다.
문자열 슬라이싱 (String Slicing)
String.substring보다 간결한 슬라이싱 구문을 제공합니다. s.[start..stop]은 인덱스 start부터 stop까지(양쪽 포함)의 부분 문자열을 반환합니다:
$ cat str_slice.l3
let s = "hello"
let _ = println (s.[1..3])
let _ = println (s.[0..0])
let t = "abcdef"
let _ = println (t.[0..2])
let _ = println (t.[3..5])
$ fn str_slice.l3
ell
h
abc
def
()
s.[start..] 형태로 끝 인덱스를 생략하면, start부터 문자열 끝까지를 반환합니다:
$ cat str_slice_open.l3
let s = "hello"
let _ = println (s.[2..])
let _ = println ("hello world".[6..])
$ fn str_slice_open.l3
llo
world
()
String.substring이 시작+길이를 사용하는 반면, 슬라이싱은 시작+끝 인덱스를 사용합니다. Python의 s[1:4]와 비슷하지만 끝 인덱스가 포함(inclusive)된다는 차이가 있습니다.
String 모듈 함수
Prelude의 String 모듈은 문자열 검사 및 변환 함수를 제공합니다:
$ cat str_module.l3
let _ = println (to_string (String.endsWith "hello.txt" ".txt"))
let _ = println (to_string (String.endsWith "hello.txt" ".csv"))
let _ = println (to_string (String.startsWith "hello" "he"))
let _ = println (to_string (String.startsWith "hello" "wo"))
let _ = println (String.trim " spaces ")
$ fn str_module.l3
true
false
true
false
spaces
()
| 함수 | 설명 |
|---|---|
String.endsWith s suffix | 문자열이 suffix로 끝나는지 확인 |
String.startsWith s prefix | 문자열이 prefix로 시작하는지 확인 |
String.trim s | 양쪽 공백 제거 |
String.length s | 문자열 길이 |
String.substring s start len | 부분 문자열 추출 |
String.contains s needle | 부분 문자열 포함 여부 |
String.concat sep lst | 구분자로 문자열 리스트를 연결 |
StringBuilder
문자열을 여러 번 +나 ^^로 연결하면 매번 새 문자열이 생성되어 비효율적입니다. StringBuilder는 문자열 조각들을 모아두었다가 한 번에 합치는 가변 버퍼입니다:
$ cat sb_basic.l3
let sb = StringBuilder.create ()
let _ = StringBuilder.add sb "hello"
let _ = StringBuilder.add sb " "
let _ = StringBuilder.add sb "world"
let _ = println (StringBuilder.toString sb)
$ fn sb_basic.l3
hello world
()
StringBuilder.add는 문자열과 문자(char) 모두 받을 수 있습니다:
$ cat sb_char.l3
let sb = StringBuilder.create ()
let _ = StringBuilder.add sb "hi"
let _ = StringBuilder.add sb ' '
let _ = StringBuilder.add sb '!'
let _ = println (StringBuilder.toString sb)
$ fn sb_char.l3
hi !
()
| 함수 | 설명 |
|---|---|
StringBuilder.create () | 빈 StringBuilder 생성 |
StringBuilder.add sb s | 문자열 또는 문자를 추가 |
StringBuilder.toString sb | 축적된 내용을 문자열로 반환 |
루프 안에서 문자열을 반복적으로 조립할 때, +보다 StringBuilder가 훨씬 효율적입니다.
부수 효과 순서 지정
함수형 언어에서 출력은 “부수 효과(side effect)“입니다 — 값을 계산하는 것이 아니라 세계에 변화를 가져다줍니다. FunLang에서 unit을 반환하는 연산을 순서대로 실행하려면 let _ =를 사용합니다:
$ cat sequence.l3
let _ = println "first"
let _ = println "second"
let _ = println "third"
let result = "done"
$ fn sequence.l3
first
second
third
"done"
각 let _ =는 unit 결과를 바인딩하고 버림으로써, 부수 효과가 순서대로
실행되도록 보장합니다.
왜 let _ =가 필요한가요? FunLang는 기본적으로 표현식 기반 언어입니다. println "first"는 unit 값 ()을 반환하는 표현식입니다. 이 결과를 이름 없이 버리면서 다음 표현식으로 넘어가는 방법이 let _ =입니다. _는 “이 이름에는 관심이 없다“는 관례적인 표기입니다. 시퀀싱이 자연스러운 명령형 언어와 달리, 순수 함수형 언어에서는 부수 효과의 순서를 명시적으로 표현해야 합니다.
문자열 함수와 파이프
문자열 함수는 파이프 연산자와 자연스럽게 결합됩니다:
fn> "hello" |> String.length
5
문자열 변환으로 파이프라인을 구성합니다:
$ cat string_pipe.l3
let result = 42 |> to_string |> fun s -> "answer: " ^^ s
$ fn string_pipe.l3
"answer: 42"
42 |> to_string은 "42"를 만들고, |> fun s -> "answer: " ^^ s는 "answer: "와 "42"를 ^^로 연결하여 "answer: 42"를 만듭니다. 파이프와 람다가 함께 쓰이는 전형적인 패턴입니다. 변환 단계가 많을수록 파이프라인 방식이 중첩 함수 호출보다 훨씬 읽기 좋습니다.
실용 예제: 형식화된 보고서
지금까지 배운 것을 조합해 구조화된 출력을 만드는 예입니다:
$ cat report.l3
let format_line label value =
label + ": " + to_string value
let _ = println (format_line "width" 800)
let _ = println (format_line "height" 600)
let result = "report complete"
$ fn report.l3
width: 800
height: 600
"report complete"
format_line은 레이블과 임의의 값을 받아 형식화된 문자열을 만드는 함수입니다. to_string이 모든 타입을 받기 때문에, format_line은 정수뿐 아니라 문자열, 튜플, 레코드 등 어떤 타입의 값도 출력할 수 있습니다. 이처럼 작은 헬퍼 함수를 만들어 반복을 줄이는 방식이 함수형 프로그래밍의 기본 사고방식입니다.
참고 사항
이 장에서 다룬 함수들의 핵심 특징을 정리합니다:
String.substring은 시작+길이를 사용합니다 (시작+끝이 아님):String.substring "hello" 1 3="ell"^^는 문자열 연결 연산자입니다:"hello" ^^ " world"="hello world"- **
to_string**은 모든 타입을 받습니다 — 문자열은 그대로, 복합 타입은 구조적 표현 - **
printf**는 커링되어 있습니다: 각%지정자가 하나의 추가 인자를 소비합니다 - 순서 지정: 모듈 수준에서 부수 효과를 체이닝하려면
let _ =를 사용하세요
문자열이 여러 문자의 시퀀스라면, 개별 문자를 다루는 방법도 필요합니다. 다음 장에서는 char 타입과 문자 변환 함수를 알아봅니다.
문자 타입 (Char Type)
앞 장에서 문자열을 다뤘으니, 이번에는 단일 문자를 살펴봅니다. FunLang는 문자열(string)과는 별도로 단일 문자를 표현하는 char 타입을 제공합니다. 작은따옴표로 문자 리터럴을 작성하며, 문자와 정수 사이의 변환 함수를 제공합니다.
문자 리터럴
작은따옴표로 단일 문자를 표현합니다:
fn> 'a'
'a'
fn> 'Z'
'Z'
fn> '0'
'0'
이스케이프 시퀀스도 지원합니다:
fn> '\n'
'\n'
fn> '\t'
'\t'
문자 변환
Char.toInt와 Char.ofInt로 문자와 ASCII 코드 사이를 변환합니다 (빌트인 char_to_int/int_to_char도 사용 가능):
$ cat char_conv.l3
let code = Char.toInt 'A'
let back = Char.ofInt 65
let result = code
$ fn char_conv.l3
65
Char.ofInt는 0~127 범위의 ASCII 코드만 지원합니다. 범위를 벗어나면 오류가 발생합니다.
이 함수들은 문자 기반 알고리즘에 유용합니다. 예를 들어, 대문자를 소문자로 변환하려면:
$ cat to_lower.l3
let toLower c =
let code = char_to_int c
if code >= 65 then
if code <= 90 then int_to_char (code + 32)
else c
else c
let result = toLower 'H'
$ fn to_lower.l3
'h'
문자 비교
문자는 비교 연산자로 순서를 비교할 수 있습니다. ASCII 코드 값 기준으로 비교됩니다:
fn> 'a' < 'z'
true
fn> 'A' > 'Z'
false
fn> 'a' = 'a'
true
문자와 문자열 사이의 비교도 가능합니다. 비교 연산자는 피연산자의 타입에 따라 자동으로 widening됩니다.
패턴 매칭에서의 문자
문자 리터럴은 패턴 매칭에서도 사용할 수 있습니다:
$ cat char_match.l3
let classify c =
match c with
| 'a' -> "lowercase a"
| 'A' -> "uppercase A"
| _ -> "other"
let result = classify 'A'
$ fn char_match.l3
"uppercase A"
Char 모듈 함수
Prelude의 Char 모듈은 문자 판별 및 변환 함수를 제공합니다. 위에서 char_to_int로 직접 구현한 toLower 같은 변환을 더 간단하게 할 수 있습니다:
$ cat char_module.l3
let _ = println (to_string (Char.isDigit '3'))
let _ = println (to_string (Char.isDigit 'a'))
let _ = println (to_string (Char.isLetter 'z'))
let _ = println (to_string (Char.toUpper 'a'))
$ fn char_module.l3
true
false
true
'A'
()
대소문자 판별과 변환도 제공됩니다:
$ cat char_case.l3
let _ = println (to_string (Char.isUpper 'A'))
let _ = println (to_string (Char.isLower 'a'))
let _ = println (to_string (Char.toLower 'Z'))
$ fn char_case.l3
true
true
'z'
()
| 함수 | 설명 |
|---|---|
Char.isDigit c | 숫자 문자(‘0’~‘9’)인지 확인 |
Char.isLetter c | 알파벳 문자인지 확인 |
Char.isUpper c | 대문자인지 확인 |
Char.isLower c | 소문자인지 확인 |
Char.toUpper c | 대문자로 변환 |
Char.toLower c | 소문자로 변환 |
Char.toInt c | 문자 → ASCII 코드 (int) |
Char.ofInt n | ASCII 코드 → 문자 (0~127) |
이 함수들을 사용하면 char_to_int/int_to_char로 ASCII 코드를 직접 계산할 필요 없이, 의도가 명확한 코드를 작성할 수 있습니다.
참고 사항
- 문자 리터럴:
'a','\n'(작은따옴표) - 문자열 리터럴:
"hello"(큰따옴표) — 서로 다른 타입 Char.toInt(char_to_int): 문자 → ASCII 코드 (int)Char.ofInt(int_to_char): ASCII 코드 → 문자 (0~127만)- 비교:
<,>,<=,>=,=모두 지원
문자열과 문자를 다루는 방법을 알았으니, 다음 장에서는 파이프 연산자와 함수 합성으로 데이터 처리 파이프라인을 구성하는 방법을 알아봅니다.
8장: 파이프와 합성 (Pipes and Composition)
함수형 프로그래밍에서 가장 우아한 아이디어 중 하나는 “함수를 조합하여 더 큰 함수를 만든다“는 것입니다. 그런데 그 조합 방식이 자연스럽지 않으면 코드가 오히려 복잡해집니다. f(g(h(x)))처럼 안쪽부터 읽어야 하는 중첩 호출은 사람의 직관과 반대 방향이거든요. 파이프와 합성 연산자는 이 문제를 정면으로 해결합니다.
파이프 연산자
파이프 연산자 |>는 값을 함수의 인자로 전달합니다. 설명은 간단하지만, 이 연산자가 코드를 읽는 방향을 바꿔놓습니다.
fn> 5 |> (fun x -> x + 1)
6
왼쪽의 값이 오른쪽 함수의 입력이 됩니다. 마치 공장 생산 라인처럼 데이터가 왼쪽에서 오른쪽으로 흘러가죠. F#에서 가져온 이 연산자는 Elixir, Elm 등 여러 함수형 언어에서도 핵심 기능으로 자리잡고 있습니다.
파이프 체인은 왼쪽에서 오른쪽으로 읽히며, 데이터 변환 파이프라인을 표현합니다:
$ cat pipe_chain.l3
let double x = x * 2
let inc x = x + 1
let result = 5 |> double |> inc
$ fn pipe_chain.l3
11
여기서 5 |> double |> inc는 inc (double 5) = inc 10 = 11을 계산합니다.
만약 파이프 없이 썼다면 inc (double 5)가 됩니다. 두 함수뿐이라 아직은 비슷해 보이지만, 변환 단계가 5개, 10개로 늘어나면 차이가 확연해집니다. 중첩 호출은 오른쪽에서 왼쪽으로 읽어야 하지만, 파이프 체인은 우리가 글을 읽는 방향, 즉 왼쪽에서 오른쪽으로 읽을 수 있습니다.
내장 함수와 파이프
파이프는 사용자 정의 함수뿐만 아니라 내장 함수와도 함께 동작합니다:
fn> "hello" |> String.length
5
Prelude 연산자도 파이프와 자연스럽게 결합됩니다. 예를 들어 ^^ 연산자로 문자열을 연결할 수 있습니다:
fn> "hello " ^^ "world"
"hello world"
이 패턴이 익숙해지면, 데이터 변환 파이프라인을 마치 레고 블록 쌓듯 구성할 수 있게 됩니다.
람다와 파이프
파이프 안에서 즉석으로 람다를 정의할 수도 있습니다:
fn> 10 |> (fun x -> x * x)
100
람다 주위의 괄호는 필수입니다. 이는 파서가 |>의 오른쪽을 하나의 표현식으로 인식해야 하기 때문입니다. 괄호를 빠뜨리면 파서가 혼란스러워집니다. 간단한 규칙이니 기억해두세요: 파이프 뒤에 람다가 오면 반드시 괄호로 감싸야 합니다.
순방향 합성
>> 연산자는 두 함수를 왼쪽에서 오른쪽 순서로 합성합니다. f >> g는 f를 먼저 적용한 다음 g를 적용하는 새로운 함수를 만듭니다. 파이프가 “지금 이 값을 변환하는” 것이라면, 합성은 “나중에 사용할 변환기를 만드는” 것입니다.
$ cat compose_fwd.l3
let double x = x * 2
let inc x = x + 1
let f = double >> inc
let result = f 5
$ fn compose_fwd.l3
11
f 5는 inc (double 5) = inc 10 = 11을 계산합니다.
수학에서의 함수 합성 g ∘ f와 비교해보세요. 수학적 표기법은 오른쪽에서 왼쪽으로 읽지만, >> 연산자는 왼쪽에서 오른쪽으로 읽을 수 있어 더 직관적입니다. double >> inc는 “먼저 두 배로 만들고, 그 다음 1을 더한다“고 소리 내어 읽을 수 있습니다.
역방향 파이프
<| 연산자는 |>의 반대 방향입니다. 오른쪽의 값을 왼쪽의 함수에 전달합니다. Haskell의 $ 연산자와 같은 역할을 합니다.
fn> (fun x -> x + 1) <| 5
6
<|는 우결합(right-associative)이므로, 중첩된 함수 적용에서 괄호를 줄일 수 있습니다:
$ cat backward_pipe.l3
let double x = x * 2
let inc x = x + 1
let result = println <| to_string <| inc <| double 5
$ fn backward_pipe.l3
11
()
println (to_string (inc (double 5)))와 같은 결과이지만, 괄호 중첩 없이 읽을 수 있습니다. 왼쪽에서 오른쪽으로 읽히는 |>와 달리 <|는 오른쪽에서 왼쪽으로 읽힙니다. 두 스타일을 섞어 쓰면 혼란스러울 수 있으니, 팀 내에서 일관된 방향을 택하는 것이 좋습니다.
역방향 합성
<< 연산자는 오른쪽에서 왼쪽으로 합성합니다. g << f는 “f를 먼저 적용한 다음 g를 적용한다“는 의미로, f >> g와 동일합니다:
$ cat compose_bwd.l3
let double x = x * 2
let inc x = x + 1
let g = inc << double
let result = g 5
$ fn compose_bwd.l3
11
double >> inc와 inc << double은 동일한 함수를 생성합니다.
<< 연산자는 Haskell의 (.) 합성 연산자와 방향이 같습니다. Haskell에 익숙하다면 <<가 더 자연스럽게 느껴질 수 있고, F#이나 OCaml에서 왔다면 >>가 더 편할 것입니다. 어떤 스타일을 선택하든 일관성을 유지하는 것이 중요합니다. 팀이나 코드베이스 안에서 하나의 방향으로 통일하면 코드를 읽을 때 방향 전환으로 인한 혼란이 없어집니다.
합성 체인
합성은 두 함수에만 국한되지 않습니다. 여러 단계를 체이닝하면 복잡한 변환을 명확하게 표현할 수 있습니다:
$ cat compose_chain.l3
let add1 x = x + 1
let mul2 x = x * 2
let sub3 x = x - 3
let f = add1 >> mul2 >> sub3
let result = f 5
$ fn compose_chain.l3
9
f 5 = sub3 (mul2 (add1 5)) = sub3 (mul2 6) = sub3 12 = 9.
이렇게 합성된 함수 f는 이름 있는 변환 파이프라인입니다. f를 정의한 순간부터 임의의 값에 반복 적용할 수 있고, 테스트도 독립적으로 가능합니다. 변환 로직이 한 곳에 모여있으니 나중에 수정할 때도 한 곳만 바꾸면 됩니다.
파이프 vs 합성
이 두 연산자를 언제 써야 할지 헷갈린다면, 간단한 기준이 있습니다.
파이프는 특정 값을 파이프라인을 통해 변환합니다:
$ cat pipe_example.l3
let double x = x * 2
let inc x = x + 1
let result = 5 |> double |> inc
$ fn pipe_example.l3
11
합성은 나중에 사용할 새로운 함수를 만듭니다:
$ cat comp_example.l3
let double x = x * 2
let inc x = x + 1
let transform = double >> inc
let a = transform 5
let result = transform 10
$ fn comp_example.l3
21
값이 있고 지금 바로 변환하고 싶을 때는 파이프를 사용하세요. 재사용 가능한 변환을 정의하고 싶을 때는 합성을 사용하세요.
실제로 생각해보면, 파이프는 “이 특정 데이터를 처리하는 과정“을 표현할 때 쓰고, 합성은 “이 처리 방식 자체를 캡처“할 때 씁니다. 위 예제에서 transform은 여러 번 호출할 수 있는 재사용 가능한 함수가 됩니다. 만약 파이프만 썼다면 같은 처리를 두 번 쓰기 위해 코드를 중복해야 했을 것입니다.
실용 예제: 데이터 파이프라인
실제 코드에서는 파이프와 합성을 같이 활용하는 경우가 많습니다. 파이프와 문자열 연산의 조합:
$ cat pipeline.l3
let result = "answer: " ^^ to_string 42
$ fn pipeline.l3
"answer: 42"
재사용 가능한 포매터를 위한 합성:
$ cat formatter.l3
let format_num x = "value=" ^^ to_string x
let result = format_num 99
$ fn formatter.l3
"value=99"
format_num은 한 번 정의하면 어떤 숫자에든 쓸 수 있는 포매터입니다. 이런 작은 변환 함수들을 합성으로 쌓아가다 보면, 복잡한 데이터 처리 로직도 단순한 블록들의 조합으로 표현할 수 있게 됩니다.
Prelude 연산자와 파이프라인
Prelude가 제공하는 연산자를 파이프라인과 결합하면 더 간결한 코드를 작성할 수 있습니다. Prelude의 전체 함수/연산자 목록은 9장: Prelude 표준 라이브러리에서 다룹니다. 여기서는 파이프라인과의 조합에 집중합니다.
++ (리스트 연결):
$ cat pipeline_ops.l3
let result = [1..3] ++ [10..13] ++ [20..22]
$ fn pipeline_ops.l3
[1; 2; 3; 10; 11; 12; 13; 20; 21; 22]
^^ (문자열 연결):
string_concat 대신 ^^ 연산자를 사용하면 더 읽기 쉽습니다. Python의 + 나 JavaScript의 +처럼 직관적인데, FunLang에서 +는 정수 덧셈에 예약되어 있으므로 문자열에는 별도의 연산자를 씁니다:
$ cat string_ops.l3
let greet name = "Hello, " ^^ name ^^ "!"
let result = greet "Alice"
$ fn string_ops.l3
"Hello, Alice!"
<|> (Option 대안):
여러 시도 중 첫 번째로 성공한 결과를 택하는 패턴입니다. 파싱이나 fallback 로직을 표현할 때 특히 유용합니다:
$ cat option_ops.l3
let tryParse s =
match s with
| "42" -> Some 42
| _ -> None
let result = tryParse "abc" <|> tryParse "42" <|> Some 0
$ fn option_ops.l3
Some 42
혼합 파이프라인:
여러 연산자를 파이프 |>와 함께 사용할 수 있습니다. 파이프라인의 각 단계가 명확히 구분되어, 코드를 읽는 사람이 데이터 흐름을 쉽게 추적할 수 있습니다:
$ cat mixed_pipeline.l3
// 리스트를 "[1, 2, 3]" 형태의 문자열로 변환
let formatList xs = "[" ^^ fold (fun acc -> fun x -> if acc = "" then to_string x else acc ^^ ", " ^^ to_string x) "" xs ^^ "]"
let result = [1..5] |> filter (fun x -> x > 2) |> formatList
$ fn mixed_pipeline.l3
"[3, 4, 5]"
우선순위
파이프와 합성 연산자의 우선순위는 처음에 직관에 어긋날 수 있습니다. 그러나 설계 의도를 이해하면 이해가 쉽습니다. 파이프는 “마지막에 적용“되어야 하므로 가장 낮은 우선순위를 가집니다. 덕분에 x + 1 |> f 같은 식에서 x + 1의 결과가 완전히 계산된 뒤 f로 넘어갑니다. 추가 괄호가 필요 없습니다.
합성 연산자 >>, <<는 파이프보다는 높지만 산술보다는 낮습니다. 함수들을 먼저 합성한 뒤, 그 합성된 함수에 파이프로 값을 보내는 자연스러운 흐름을 반영합니다.
| 연산자 | 우선순위 |
|---|---|
|>, <| | 가장 낮음 |
>>, << | 낮음 |
|| | … |
&& | … |
+, - | … |
*, / | 가장 높음 |
우선순위 때문에 예상치 못한 동작이 생긴다면, 괄호를 추가해 의도를 명확히 하는 것이 최선입니다. “괄호를 아낀다“는 미학보다 “코드를 오해 없이 읽는다“는 실용성이 중요합니다.
구현 참고
|>, <|, >>, <<는 컴파일러에 내장된 특수 연산자가 아니라, Prelude/Core.fun에 일반 함수로 정의되어 있습니다. 예를 들어 |>의 정의는 단 한 줄입니다:
#[left 1]
let (|>) x f = f x
#[left 1]은 fixity 속성으로, 이 연산자가 좌결합이며 우선순위 레벨 1(가장 낮음)임을 선언합니다. 이 메커니즘 덕분에 사용자도 동일한 방식으로 자신만의 연산자에 원하는 우선순위와 결합성을 부여할 수 있습니다. 자세한 내용은 13장: 사용자 정의 연산자를 참조하세요.
9장: Prelude 표준 라이브러리 (Prelude and Standard Library)
어떤 언어를 쓰든, 매번 처음부터 리스트 처리 함수를 직접 만들거나 “없음“을 표현하는 타입을 직접 정의하는 건 번거로운 일입니다. FunLang는 이런 공통적인 필요를 Prelude라는 표준 라이브러리로 해결합니다.
FunLang는 시작 시 Prelude라는 표준 라이브러리를 로드합니다. Prelude 파일은 명시적인 import 없이 모든 사용자 코드에서 사용 가능한 타입, 생성자, 함수를 제공합니다. Python의 builtins이나 Haskell의 Prelude와 비슷한 개념이지만, FunLang에서는 Prelude 자체도 .fun 파일로 작성되어 있어 언어의 일반 코드와 다를 바가 없습니다. 원하면 직접 읽어보고 확장할 수도 있습니다.
Prelude의 동작 방식
Prelude는 FunLang 바이너리와 같은 위치의 Prelude/ 디렉토리에 있는 .fun 파일로 구성됩니다. 시작 시 이 파일들은 의존성 분석을 거쳐 올바른 순서로 로드된 후, 각각 모듈로 파싱되고 타입 검사를 거쳐 평가됩니다. 이 파일들이 정의하는 타입, 생성자, 함수는 이후 모든 코드에서 사용 가능합니다.
로드 순서는 자동으로 결정됩니다. 각 파일이 선언하는 타입 생성자와 다른 파일에서 참조하는 생성자를 분석하여 의존성 그래프를 구축하고, 토폴로지 정렬로 순서를 정합니다. 예를 들어 List.fun이 Some과 None을 사용하면, 이를 선언한 Option.fun이 자동으로 먼저 로드됩니다. 의존성이 없는 파일 간에는 알파벳순으로 정렬됩니다.
현재 Prelude에는 다음 파일들이 포함되어 있습니다:
Prelude/Option.fun– Option 타입과 함수 (optionMap,optionBind,optionDefault,isSome,isNone등)Prelude/Array.fun– 배열 모듈 (Array.create,Array.get,Array.set,Array.sort,Array.ofSeq등)Prelude/Char.fun– 문자 모듈 (Char.isDigit,Char.isLetter,Char.toUpper,Char.toLower등)Prelude/Core.fun– ���심 ��차 함수 (id,const,compose) + 파이프/합성 연���자 (|>,<|,>>,<<)Prelude/HashSet.fun– HashSet 모듈 (HashSet.create,HashSet.add,HashSet.contains,HashSet.count)Prelude/Hashtable.fun– Hashtable 모듈Prelude/Int.fun– Int 모듈 (Int.parse,Int.toString)Prelude/List.fun– 리스트 처리 함수 (map,filter,fold,sort,tryFind,choose등)Prelude/MutableList.fun– MutableList 모듈Prelude/Queue.fun– Queue 모듈Prelude/Result.fun– Result 타입과 함수 (resultMap,resultBind,resultDefault등)Prelude/String.fun– String 모듈 (String.split,String.indexOf,String.replace,String.toUpper,String.toLower,String.trim,String.endsWith,String.startsWith등)Prelude/StringBuilder.fun– StringBuilder 모듈Prelude/Typeclass.fun– 타입 클래스 (Show,Eq)와 기본 타입 인스턴스 (int,bool,string,char)
Prelude/Option.fun:
type Option 'a =
| None
| Some of 'a
이 파일은 Option 타입과 None, Some 생성자를 정의하며, open 지시어 없이 어디서든 사용 가능합니다. 단 한 줄이지만, 이것 하나로 “값이 있을 수도 없을 수도 있음“이라는 개념을 타입 시스템에서 안전하게 표현할 수 있게 됩니다.
Option 타입 사용하기
함수형 프로그래밍에서 Option (또는 Maybe, Optional)은 아마 가장 중요한 타입일 것입니다. null을 사용하는 대신, 값의 부재를 타입 수준에서 명시함으로써 null 참조 오류를 컴파일 타임에 방지할 수 있습니다. 토니 호아르가 null을 “10억 달러짜리 실수“라고 부른 것을 기억하나요? Option은 그 실수를 타입 시스템으로 고치는 방법입니다.
Option 값 생성
Some과 None 생성자는 REPL과 파일 모드 모두에서 동작합니다:
fn> Some 42
Some 42
fn> Some "hello"
Some "hello"
fn> None
None
Some은 어떤 타입이든 감쌀 수 있습니다. None은 값이 없다는 의미입니다. 타입 파라미터 'a가 있어서 Option<int>, Option<string>, Option<Option<int>> 같은 타입이 모두 가능합니다.
추론된 타입을 확인합니다:
$ cat check_option.l3
let x = Some 42
$ fn --emit-type check_option.l3
x : Option<int>
컴파일러가 42가 int임을 보고 Option<int>로 타입을 추론합니다. 별도로 타입을 적어줄 필요가 없습니다.
Option에 대한 패턴 매칭
Option의 진짜 가치는 패턴 매칭과 함께 쓸 때 드러납니다. 값이 있는 경우와 없는 경우를 반드시 둘 다 처리해야 하므로, 실수로 None을 무시하는 일이 없습니다:
$ cat option_match.l3
let x = Some 42
let result =
match x with
| Some v -> v
| None -> 0
$ fn option_match.l3
42
Java나 Python에서 null 체크를 빠뜨리면 런타임에야 NPE나 AttributeError가 발생합니다. FunLang에서는 패턴 매칭이 불완전하면 컴파일러가 경고하므로, 실수가 훨씬 일찍 잡힙니다.
기본값으로 추출하기:
$ cat option_default.l3
let getOrDefault default opt =
match opt with
| Some x -> x
| None -> default
let result = getOrDefault 0 None
$ fn option_default.l3
0
getOrDefault는 매우 자주 쓰이는 패턴이므로 직접 정의해두면 편리합니다. Haskell의 fromMaybe, Rust의 unwrap_or와 같은 개념입니다.
일반적인 Option 패턴
Option 값을 다룰 때 반복되는 패턴 두 가지가 있습니다. 익혀두면 Option이 나오는 코드를 훨씬 자연스럽게 다룰 수 있습니다.
Option에 대한 맵핑 – Some 내부의 값에 함수를 적용합니다. None이면 그대로 None을 유지합니다:
$ cat option_map.l3
let optionMap f opt =
match opt with
| Some x -> Some (f x)
| None -> None
let double x = x * 2
let result =
match optionMap double (Some 5) with
| Some v -> v
| None -> 0
$ fn option_map.l3
10
None인지 먼저 확인할 필요 없이, optionMap은 값이 있을 때만 double을 적용합니다. “값이 있으면 변환하고, 없으면 없는 채로 둔다” — 이것이 functor 패턴의 핵심입니다.
바인딩 (flatMap) – 실패할 수 있는 연산을 체이닝합니다. 각 단계가 성공했을 때만 다음 단계로 넘어갑니다:
$ cat option_bind.l3
let optionBind f opt =
match opt with
| Some x -> f x
| None -> None
let safeDivide x =
if x = 0 then None else Some (100 / x)
let result =
match optionBind safeDivide (Some 5) with
| Some v -> v
| None -> 0
$ fn option_bind.l3
20
optionBind와 optionMap의 차이는 f의 반환 타입입니다. map에서 f는 일반 값을 반환하고, bind에서 f는 Option을 반환합니다. 이를 통해 여러 개의 실패 가능한 연산을 체이닝할 때 이중 중첩(Option<Option<a>>)을 피할 수 있습니다.
파이프와 함께 Option 사용하기:
파이프 연산자와 결합하면 Option 변환 체인을 더 읽기 쉽게 표현할 수 있습니다:
$ cat option_pipe.l3
let optionMap f opt =
match opt with
| Some x -> Some (f x)
| None -> None
let double x = x * 2
let result =
match (Some 5 |> optionMap double) with
| Some v -> v
| None -> 0
$ fn option_pipe.l3
10
Prelude 확장하기
Prelude가 .fun 파일로 구성되어 있다는 것은 단순히 구현 방식의 이야기가 아닙니다. 여러분이 직접 Prelude를 확장할 수 있다는 뜻입니다. 프로젝트에서 공통으로 쓰는 타입이나 함수를 Prelude에 추가하면, 모든 파일에서 import 없이 사용할 수 있습니다.
Prelude/ 디렉토리에 새 .fun 파일을 생성하여 자신만의 타입을 Prelude에 추가할 수 있습니다. 로드 순서는 의존성 분석으로 자동 결정되므로, 파일 이름을 신경 쓸 필요가 없습니다.
예를 들어, Prelude/Result.fun을 생성하면:
type Result 'a 'b =
| Ok of 'a
| Error of 'b
이 파일을 추가한 후, Ok과 Error 생성자가 모든 코드에서 사용 가능해집니다:
$ cat result_demo.l3
let safeDivide x y =
if y = 0 then Error "division by zero"
else Ok (x / y)
let result =
match safeDivide 10 3 with
| Ok v -> v
| Error _ -> 0
$ fn result_demo.l3
3
Result 타입은 Rust나 F#에서 오류 처리의 주력 도구입니다. Option이 “값이 있거나 없거나“라면, Result는 “성공했거나, 구체적인 이유와 함께 실패했거나“입니다. 예외를 사용하는 대신 Result를 반환하면, 오류 처리가 타입 시스템에 드러나므로 호출하는 쪽에서 처리를 강제할 수 있습니다.
Prelude 타입은 REPL과 파일 모드 모두에서 동작합니다. Prelude 파일의 생성자는 open 없이 사용 가능합니다.
Prelude 리스트 함수
Prelude/List.fun은 리스트를 다루는 8개의 표준 함수를 제공합니다. 이 함수들은 import 없이 REPL과 파일 모드 모두에서 바로 사용할 수 있는 실제 함수입니다.
다른 함수형 언어(Haskell, OCaml, F#)에서 이미 이름을 알고 있다면 바로 쓸 수 있고, 처음이라면 이 함수들을 익히는 것이 리스트 처리의 첫 걸음입니다.
한정된 접근 (Qualified Access)
Prelude 함수는 모듈 이름을 붙여서도 사용할 수 있습니다. List.map, List.length처럼 모듈 이름을 접두사로 붙이면 어디서 온 함수인지 명확해집니다:
$ cat qualified_prelude.l3
let n = List.length [1; 2; 3]
let doubled = List.map (fun x -> x * 2) [1; 2; 3]
let result = doubled
$ fn qualified_prelude.l3
[2; 4; 6]
한정된 접근과 비한정된 접근을 섞어 쓸 수도 있습니다. map과 List.map은 같은 함수입니다:
fn> length [1; 2; 3]
3
fn> List.length [1; 2; 3]
3
마찬가지로 Core.id, Core.compose, Option.None, Option.Some 등 다른 Prelude 모듈도 한정된 접근이 가능합니다.
리스트 변환: map, filter
map은 리스트의 각 요소에 함수를 적용합니다. 리스트 처리의 가장 기본적인 패턴으로, 각 원소를 독립적으로 변환할 때 씁니다:
fn> map (fun x -> x * 2) [1..5]
[2; 4; 6; 8; 10]
filter는 조건을 만족하는 요소만 남깁니다. map이 “모든 원소를 변환“한다면 filter는 “일부 원소만 선택“합니다:
fn> filter (fun x -> x > 3) [1..6]
[4; 5; 6]
이 두 함수는 대부분의 리스트 처리 코드의 80%를 커버합니다. Python의 리스트 컴프리헨션이나 map(), filter() 내장 함수와 같은 역할을 합니다.
리스트 축약: fold
fold는 리스트를 하나의 값으로 축약합니다. 합계, 최댓값, 문자열 합치기 등 리스트를 단일 결과로 만드는 모든 연산이 fold로 표현됩니다:
fn> fold (fun acc -> fun x -> acc + x) 0 [1..10]
55
fold는 처음엔 조금 낯설 수 있습니다. 핵심은 “누산기(accumulator)를 초기값에서 시작해 각 원소마다 업데이트해 나간다“는 것입니다. 여기서 0이 초기값이고, fun acc -> fun x -> acc + x가 매 원소마다 실행되는 업데이트 함수입니다. 1부터 10까지 더하면 55가 됩니다.
fold는 map과 filter를 포함한 거의 모든 리스트 연산을 직접 구현할 수 있을 만큼 강력합니다. 하지만 그렇다고 항상 fold를 쓸 필요는 없습니다. map이나 filter로 표현되는 코드가 훨씬 읽기 쉬울 때는 그것을 쓰세요.
리스트 정보: length, hd, tl
fn> length [1; 2; 3]
3
fn> hd [10; 20]
10
fn> tl [10; 20]
[20]
hd(head)는 첫 번째 원소를, tl(tail)은 나머지 전부를 반환합니다. OCaml의 명명 관습에서 온 이름입니다. hd와 tl을 빈 리스트에 적용하면 런타임 오류가 발생하므로, 빈 리스트 가능성이 있다면 패턴 매칭으로 먼저 확인하는 것이 안전합니다.
리스트 조작: reverse, append
fn> reverse [] [1; 2; 3]
[3; 2; 1]
fn> append [1; 2] [3; 4]
[1; 2; 3; 4]
reverse의 첫 번째 인자가 빈 리스트인 점이 눈에 띕니다. 이것은 누산기 패턴의 흔적입니다. 내부적으로 빈 리스트를 누산기 초기값으로 사용하면서 반전을 수행하는 방식이 인터페이스에 드러난 것입니다. 대부분의 경우 reverse [] myList 형태로 호출하면 됩니다.
리스트 정렬: sort, sortBy
List.sort는 리스트를 오름차순으로 정렬합니다. List.sortBy는 키 함수를 적용한 결과로 정렬합니다:
$ cat list_sort.l3
let r1 = List.sort [3; 1; 2]
let r2 = List.sortBy (fun x -> 0 - x) [1; 2; 3]
let _ = println (to_string r1)
let _ = println (to_string r2)
$ fn list_sort.l3
[1; 2; 3]
[3; 2; 1]
()
List.sortBy (fun x -> 0 - x)는 키를 부호 반전하여 내림차순 정렬을 구현합니다.
리스트 검색: exists, tryFind, choose, distinctBy
$ cat list_search.l3
let _ = println (to_string (List.exists (fun x -> x > 2) [1; 2; 3]))
let _ = println (to_string (List.tryFind (fun x -> x > 2) [1; 2; 3]))
let _ = println (to_string (List.tryFind (fun x -> x > 10) [1; 2; 3]))
let r = List.choose (fun x -> if x > 1 then Some (x * 10) else None) [1; 2; 3]
let _ = println (to_string r)
let d = List.distinctBy (fun x -> x % 2) [1; 2; 3; 4; 5]
let _ = println (to_string d)
$ fn list_search.l3
true
Some 3
None
[20; 30]
[1; 2]
()
| 함수 | 설명 |
|---|---|
List.exists pred xs | 조건을 만족하는 원소가 하나라도 있으면 true |
List.tryFind pred xs | 조건을 만족하는 첫 번째 원소를 Option으로 반환 |
List.choose f xs | f가 Some을 반환한 값만 모은 리스트 (filter + map) |
List.distinctBy f xs | 키 함수 f의 결과가 중복되지 않는 첫 번째 원소만 남김 |
리스트 변환 확장: mapi, item, isEmpty
$ cat list_transform.l3
let r1 = List.mapi (fun i -> fun x -> i + x) [10; 20; 30]
let _ = println (to_string r1)
let _ = println (to_string (List.item 1 [10; 20; 30]))
let _ = println (to_string (List.isEmpty []))
let _ = println (to_string (List.isEmpty [1]))
$ fn list_transform.l3
[10; 21; 32]
20
true
false
()
| 함수 | 설명 |
|---|---|
List.mapi f xs | 인덱스와 원소를 받는 함수로 매핑 (f i x) |
List.item n xs | n번째 원소 반환 (0-based) |
List.isEmpty xs | 빈 리스트인지 확인 |
List.head xs | 첫 번째 원소 (hd의 별칭) |
List.tail xs | 나머지 원소 (tl의 별칭) |
컬렉션 변환: List.ofSeq
List.ofSeq는 배열, HashSet, Queue, MutableList 등 임의의 컬렉션을 불변 리스트로 변환합니다:
$ cat list_ofseq.l3
let hs = HashSet.create ()
let _ = HashSet.add hs 3
let _ = HashSet.add hs 1
let _ = HashSet.add hs 2
let sorted = List.sort (List.ofSeq hs)
let _ = println (to_string sorted)
$ fn list_ofseq.l3
[1; 2; 3]
()
가변 컬렉션을 불변 리스트로 변환한 뒤 map, filter, sort 등 리스트 함수를 적용하는 패턴이 자주 쓰입니다. Array.ofSeq도 동일한 방식으로 배열로 변환합니다.
Prelude 핵심 함수
Prelude/Core.fun은 범용 고차 함수 3개를 제공합니다. 마찬가지로 import 없이 어디서든 사용할 수 있습니다.
이 함수들은 수학의 기본 원소처럼, 단순하지만 다양한 맥락에서 유용합니다. 처음엔 “이걸 왜 쓰나?” 싶을 수 있는데, 파이프라인이나 고차 함수와 결합했을 때 진가가 드러납니다.
id – 항등 함수
입력값을 그대로 반환합니다:
fn> id 42
42
“아무것도 안 하는 함수가 왜 필요한가?” — id는 함수를 인자로 받는 곳에 기본값으로 쓰기 좋습니다. 예를 들어 map id [1; 2; 3]은 리스트를 그대로 복사합니다. 또 f >> id = f, id >> f = f처럼 합성의 항등원이기도 합니다. Haskell이나 F#에서도 같은 이름으로 존재합니다.
const – 상수 함수
첫 번째 인자를 반환하고 두 번째 인자를 무시합니다:
fn> const 42 "ignored"
42
const는 두 인자를 받지만 첫 번째만 돌려줍니다. 이것이 어디서 쓰이냐면, 예를 들어 map (const 0) [1; 2; 3]은 리스트의 모든 원소를 0으로 바꿉니다. 콜백이나 고차 함수가 함수를 기대하는데 “항상 이 값을 반환하는 함수“가 필요할 때 const가 딱 맞습니다.
compose – 함수 합성
두 함수를 합성합니다. compose f g x는 f (g x)와 같습니다:
fn> compose inc double 5
11
compose는 >> 연산자의 함수 버전입니다. 연산자 형태(>>)가 더 읽기 편하지만, compose를 함수로 넘겨야 하는 경우에는 compose를 직접 씁니다. 예를 들어 함수들의 리스트를 fold로 합성하는 경우가 있습니다.
유틸리티 함수
fn> not true
false
fn> (min 3 5, max 3 5)
(3, 5)
fn> abs (0 - 42)
42
fn> (fst (1, 2), snd (1, 2))
(1, 2)
fn> ignore 42
()
fst와 snd는 튜플의 첫 번째, 두 번째 원소를 꺼냅니다. ignore는 값을 받아서 ()를 반환하는데, 부작용을 일으키는 함수의 반환값을 무시하고 싶을 때 유용합니다. F# 코드를 써봤다면 익숙한 이름들입니다.
파이프라인과 함께 사용하기
Prelude 함수들은 파이프 연산자 |>와 결합하면 강력한 데이터 처리 파이프라인을 구성할 수 있습니다. 이것이 FunLang에서 리스트 처리 코드를 가장 읽기 쉽게 쓰는 방법입니다:
$ cat pipeline.l3
let result =
[1..10]
|> filter (fun x -> x % 2 = 0)
|> map (fun x -> x * x)
$ fn pipeline.l3
[4; 16; 36; 64; 100]
“1부터 10까지의 수 중 짝수만 골라서, 각각 제곱한 결과” — 코드가 이 설명을 그대로 표현합니다. SQL의 WHERE와 SELECT처럼, 파이프라인의 각 단계가 한 가지 일만 담당합니다. 단계를 추가하거나 순서를 바꾸기도 쉽습니다.
Prelude 연산자
Prelude는 자주 사용하는 패턴을 위한 연산자도 제공합니다. 연산자는 중위 표기(infix)를 쓸 수 있어서, 일부 표현은 함수 형태보다 연산자 형태가 훨씬 자연스럽습니다.
|>, <| — 파이프 연산자
|>(순방향 파이프)와 <|(��방향 파이프)도 Core.fun에 정의된 Prelude 연산자입니다. |>는 왼쪽 값을 오른쪽 함수에 전달하고, <|는 오른쪽 값을 왼쪽 함수에 전달합니다:
fn> 5 |> (fun x -> x + 1)
6
fn> (fun x -> x + 1) <| 5
6
|>는 좌결합, <|��� 우결합이며, 둘 다 가장 낮은 우선순위(레벨 1)를 가집니다. 자세한 사용법은 8장: 파이프와 합성에서 다룹니다.
>>, << — 합성 연산자
>>(순방향 합성)과 <<(역방향 합성)도 Core.fun에서 정의됩��다:
fn> let f = (fun x -> x * 2) >> (fun x -> x + 1) in f 5
11
이 연산자들은 우선순위 레벨 2로, 파이프보다 높고 논리 연산자보다 낮습니다.
++ — ��스트 연결
append의 중위 연산자 버전입니다:
fn> [1; 2] ++ [3; 4; 5]
[1; 2; 3; 4; 5]
(++)를 함수로 사용할 수도 있습니다. 괄호로 감싸면 일반 함수처럼 고차 함수에 넘길 수 있습니다:
fn> fold (++) [] [[1; 2]; [3]; [4; 5]]
[1; 2; 3; 4; 5]
fold에 (++)를 넘겨서 리스트들을 하나로 합쳤습니다. Haskell의 concat과 같은 결과지만, 여기서는 fold와 ++의 조합으로 직접 구현한 셈입니다.
++는 INFIXOP2 (+ 와 같은 우선순위, 좌결합)입니다.
<|> — Option 대안
첫 번째 Some 값을 반환하거나, 모두 None이면 None을 반환합니다. 여러 소스에서 값을 찾을 때 fallback 체인을 표현하기에 딱 맞습니다:
fn> Some 1 <|> Some 2
Some 1
fn> None <|> Some 42
Some 42
fn> None <|> None
None
연쇄하여 fallback 패턴을 구현할 수 있습니다. “여러 파싱 방법을 순서대로 시도해서 첫 번째로 성공한 결과를 택한다“는 파서 콤비네이터 스타일의 코드를 자연스럽게 표현합니다:
$ cat fallback.l3
let tryParse s =
match s with
| "42" -> Some 42
| "0" -> Some 0
| _ -> None
let result = tryParse "abc" <|> tryParse "xyz" <|> tryParse "42" <|> Some 0
$ fn fallback.l3
Some 42
<|>는 INFIXOP0 (비교 연산자 수준, 좌결합)입니다.
^^ — 문자열 연결
string_concat의 중위 연산자 버전입니다:
fn> "hello" ^^ " " ^^ "world"
"hello world"
+ 연산자는 정수 덧셈이므로, 문자열 연결에는 ^^를 사용합니다. 이 점이 Python이나 JavaScript와 다른 부분인데, FunLang는 타입에 따라 다른 동작을 하는 오버로딩을 지원하지 않아서 연산자를 구분합니다. 처음엔 어색하지만 코드를 읽을 때 “지금 더하는 게 숫자인지 문자열인지” 즉시 알 수 있다는 장점이 있습니다:
$ cat string_build.l3
let formatPair key = fun value -> key ^^ "=" ^^ value
let result = formatPair "name" "Alice"
$ fn string_build.l3
"name=Alice"
^^는 INFIXOP1 (@ 와 같은 우선순위, 우결합)입니다.
런타임 내장 함수
Prelude 함수와는 별도로, FunLang에는 내장 환경(initialBuiltinEnv)에서 제공되는 런타임 내장 함수가 있습니다. 이것들은 .fun 파일이 아니라 인터프리터 자체에 내장되어 있으며, 특히 I/O나 타입 변환처럼 언어 런타임과 밀접한 기능들입니다:
| 함수 | 타입 | 설명 |
|---|---|---|
char_to_int | char -> int | 문자를 ASCII 코드로 변환 |
int_to_char | int -> char | ASCII 코드를 문자로 변환 |
failwith | string -> 'a | 메시지와 함께 예외 발생 |
string_length | string -> int | 문자열의 길이 (String.length) |
string_concat | string -> string -> string | 두 문자열을 연결 (^^ 연산자) |
string_sub | string -> int -> int -> string | 부분 문자열 (String.substring) |
string_contains | string -> string -> bool | 부분 문자열 포함 여부 (String.contains) |
to_string | 'a -> string | 모든 타입을 문자열로 변환 (문자열은 그대로) |
string_to_int | string -> int | 문자열을 정수로 파싱 |
print | string -> unit | 줄바꿈 없이 출력 |
println | string -> unit | 줄바꿈 포함 출력 |
printf | string -> ... | 형식화 출력 |
sprintf | string -> ... | 형식화 문자열 반환 |
printfn | string -> ... | 형식화 출력 + 줄바꿈 |
이들은 REPL과 파일 모드 모두에서 동작합니다:
fn> String.length "hello"
5
fn> to_string 42
"42"
fn> to_string (Some [1; 2; 3])
"Some [1; 2; 3]"
to_string은 모든 타입을 지원합니다 — ADT, 리스트, 튜플, 레코드 등. 문자열은 따옴표 없이 그대로 반환됩니다 (F# string 함수와 동일). 디버깅할 때 복잡한 값을 출력해보고 싶다면 to_string을 println과 함께 쓰면 됩니다.
자세한 내용은 7장: 문자열과 출력을 참조하세요.
Prelude vs 내장 함수 요약
두 분류를 헷갈리지 않으려면 간단한 기준이 있습니다. Prelude는 FunLang 코드로 작성된 라이브러리이고, 내장 함수는 인터프리터 자체에 박혀있는 기능입니다. 사용자 입장에서는 둘 다 import 없이 쓸 수 있어 차이가 없지만, Prelude는 직접 수정하거나 확장할 수 있다는 점이 다릅니다.
| 분류 | 출처 | 예제 |
|---|---|---|
| Prelude 타입+함수 | Prelude/*.fun 파일 | Option, Result, map, filter, fold, sort, tryFind, choose, id, compose, not, min, max, abs, fst, snd, ignore 등 |
| Prelude 타입 클래스 | Prelude/Typeclass.fun | show, eq — 타입 클래스와 기본 타입 인스턴스 (23장 참조) |
| Prelude 모듈 | Prelude/*.fun 파일 | String.trim, Char.isDigit, Array.sort, HashSet.create, Queue.create, MutableList.create, StringBuilder.create 등 |
| Prelude 연산자 | Prelude/*.fun 파일 | ++ (리스트 연결), <|> (Option 대안), ^^ (문자열 연결) |
| 런타임 내장 함수 | initialBuiltinEnv | string_length, string_concat, string_sub, string_contains, print, println, printf, sprintf, printfn 등 |
| 산술 연산자 | 내장 | +, -, *, /, % (모듈로) |
두 분류 모두 런타임에 실제로 동작하며, REPL과 파일 모드에서 import 없이 사용 가능합니다.
참고 사항
- Prelude 파일은
Prelude/디렉토리에서 의존성 순서로 자동 로드되는.fun파일입니다 - Prelude 생성자 (
None,Some)와 Prelude 함수 (map,filter등)는open없이 사용 가능합니다 - Prelude 함수는 모두 실제 런타임 함수로, 호출하면 정상적으로 결과를 반환합니다
- 런타임 내장 함수 (
print,string_length등)와 Prelude 래퍼 (String.length,String.contains등)는 어디서든 동작합니다 - 패턴 매칭은 파일 모드에서 Prelude 타입에 대해 동작합니다
10장: 모듈과 네임스페이스 (Modules and Namespaces)
코드가 조금만 커져도 이름 충돌이 발생합니다. parse라는 함수가 파일마다 있다면 어느 파일의 parse인지 명확하지 않습니다. width가 설정 값인지 캔버스 크기인지 알 수 없을 때도 있습니다. 모듈은 이 문제를 해결하기 위한 도구입니다.
FunLang의 모듈 시스템은 F#과 OCaml의 영향을 받았습니다. 중괄호나 end 키워드 없이 들여쓰기만으로 범위를 정의하며, 관련 있는 값과 함수를 하나의 이름 아래 묶어 논리적인 단위를 만들 수 있습니다.
모듈 선언
module M = 뒤에 들여쓰기된 본문으로 모듈을 정의합니다. 들여쓰기가 모듈의 경계를 결정하므로, 모듈 바깥으로 나오려면 들여쓰기를 줄이면 됩니다:
$ cat config.l3
module Config =
let width = 800
let height = 600
let title = "My App"
let result = Config.title + " (" + to_string Config.width + "x" + to_string Config.height + ")"
$ fn config.l3
"My App (800x600)"
들여쓰기가 모듈 본문의 범위를 결정합니다 – end 키워드가 필요하지 않습니다.
이 예제의 장점이 바로 느껴집니다. width나 height만으로는 무엇의 크기인지 불분명하지만, Config.width는 설정 값임이 분명합니다. 점 표기법이 문서 역할을 겸합니다.
한정된 접근
점 표기법(dot notation)으로 모듈 멤버에 접근합니다. 모듈에 정의된 값과 함수 모두에 적용됩니다:
$ cat qualified.l3
module Math =
let double x = x * 2
let triple x = x * 3
let result = Math.double 5 + Math.triple 3
$ fn qualified.l3
19
Math.double과 Math.triple은 단순히 double과 triple이 어디 있는지를 알려주는 것이 아닙니다. 이름이 충돌해도 Math.double과 String.double은 완전히 별개의 함수입니다. 대규모 코드베이스에서는 이 구분이 매우 중요합니다.
open 지시문
한정된 접근이 명확하긴 하지만, 한 모듈의 기능을 집중적으로 쓸 때는 매번 Math.를 붙이는 게 번거로울 수 있습니다. open은 모듈의 모든 멤버를 현재 스코프로 가져옵니다:
$ cat open_mod.l3
module M =
let x = 10
let y = 20
open M
let result = x + y
$ fn open_mod.l3
30
open M 이후에는 M. 접두사 없이 x와 y를 직접 사용할 수 있습니다.
open을 쓸 때는 주의가 필요합니다. 어떤 이름이 어느 모듈에서 왔는지 불분명해질 수 있기 때문입니다. Python에서 from module import *를 지양하는 것과 같은 이유입니다. 일반적으로 범위가 좁은 곳에서, 혹은 이름 충돌 위험이 없을 때 open을 쓰는 것이 좋습니다.
중첩 모듈
모듈 안에 모듈을 정의할 수 있습니다. 계층적인 구조가 필요할 때, 예를 들어 큰 서브시스템 안에 관련 있는 여러 하위 모듈이 있을 때 유용합니다. 각 수준은 더 깊은 들여쓰기를 사용합니다:
$ cat nested.l3
module Outer =
module Inner =
let value = 42
let result = Outer.Inner.value
$ fn nested.l3
42
연쇄된 한정 접근은 어떤 깊이에서든 동작합니다. Outer.Inner.value처럼 경로를 따라 내려가는 방식은 파일 시스템의 디렉토리 구조와 비슷합니다. 실제로 대규모 F# 프로젝트에서는 Domain.User.Repository.find 같은 형태로 네임스페이스를 구성하는 경우도 있습니다.
너무 깊은 중첩은 경로가 길어져 오히려 읽기 불편해질 수 있으니, 2~3단계가 실용적인 한도입니다.
여러 모듈
하나의 파일에 여러 모듈 선언을 포함할 수 있습니다. 모듈은 위에서 아래로 해석됩니다 – 나중의 모듈이 이전 모듈을 참조할 수 있습니다:
$ cat multi_mod.l3
module A =
let x = 10
module B =
let y = 20
let result = A.x + B.y
$ fn multi_mod.l3
30
이 순서 규칙은 중요합니다. B 안에서 A.x를 참조하는 것은 가능하지만, A 안에서 B.y를 참조하는 것은 불가능합니다. 순환 의존성이 원천 차단됩니다. 처음엔 제약처럼 느껴지지만, 이 규칙 덕분에 코드의 의존성 그래프가 항상 단방향(topological order)을 유지합니다. 코드베이스가 커져도 의존성이 엉키지 않습니다.
타입 선언을 포함하는 모듈
모듈은 값과 함수뿐 아니라 ADT 정의도 포함할 수 있습니다. 관련 있는 타입과 그 타입을 다루는 함수를 한 모듈에 묶으면 자연스러운 캡슐화가 됩니다. 생성자를 스코프에 가져오려면 open을 사용하거나, 인자 없는(nullary) 생성자에 대해 한정된 접근을 사용합니다:
$ cat mod_type.l3
module Colors =
type Color =
| Red
| Green
| Blue
open Colors
let result =
match Green with
| Red -> "red"
| Green -> "green"
| Blue -> "blue"
$ fn mod_type.l3
"green"
한정된 생성자 접근은 인자 없는 생성자와 데이터를 가진 생성자 모두에서 동작합니다:
$ cat mod_ctor.l3
module M =
type Opt =
| MNone
| MSome of int
let result = M.MSome 42
$ fn mod_ctor.l3
MSome 42
M.MSome 42처럼 모듈 이름을 붙여서 생성자를 사용할 수 있습니다. open M 없이도 생성자를 명확하게 참조할 수 있어서, 같은 이름의 생성자가 여러 모듈에 있어도 구분이 됩니다.
패턴 매칭을 사용하는 모듈 함수
모듈의 진짜 강점은 타입과 그 타입을 다루는 함수를 함께 묶을 때 나타납니다. OCaml이나 F#의 모듈 패턴과 동일합니다:
$ cat mod_fn.l3
module M =
type Opt =
| MNone
| MSome of int
let unwrap x =
match x with
| MSome v -> v
| MNone -> 0
let result = M.unwrap (M.MSome 42)
$ fn mod_fn.l3
42
M.unwrap은 M.Opt 타입을 이해하는 함수입니다. 타입과 함수가 같은 모듈에 있으니, unwrap을 수정할 때 타입 정의와 함께 볼 수 있어 맥락을 잃지 않습니다. 이것이 객체지향의 클래스가 제공하는 캡슐화를 함수형 방식으로 달성하는 방법입니다.
실용 예제: 계층화된 설정
모듈의 가장 일반적인 사용 사례 중 하나는 관련 설정 값들을 그룹화하는 것입니다. 모듈 없이 db_host, db_port, app_name, app_version처럼 긴 접두사를 붙여야 했을 값들을, 모듈을 쓰면 DB.host, App.version처럼 짧고 명확하게 표현할 수 있습니다:
$ cat layers.l3
module DB =
let host = "localhost"
let port = 5432
module App =
let name = "MyService"
let version = 1
let result = App.name + " v" + to_string App.version + " -> " + DB.host + ":" + to_string DB.port
$ fn layers.l3
"MyService v1 -> localhost:5432"
코드를 읽는 사람이 DB.host는 데이터베이스 설정이고 App.version은 애플리케이션 버전임을 이름만 봐도 알 수 있습니다. 모듈 이름이 문서 역할을 합니다.
파일 임포트
open "파일경로.fun"으로 외부 파일을 임포트할 수 있습니다. 임포트된 파일의 모든 바인딩(값, 함수, 타입, 모듈)이 현재 스코프에 추가됩니다:
$ cat lib.fun
let add x y = x + y
let double x = x * 2
$ cat main.l3
open "lib.fun"
let result = add 3 (double 4)
$ fn main.l3
11
경로는 임포트하는 파일의 디렉토리를 기준으로 해석됩니다. 절대 경로도 사용할 수 있습니다.
하위 디렉토리의 파일 임포트
상대 경로는 항상 임포트하는 파일의 위치를 기준으로 해석됩니다. 같은 디렉토리의 파일은 이름만으로, 다른 디렉토리의 파일은 상대 경로로 참조합니다:
project/
├── main.l3
├── lib/
│ ├── math.fun
│ └── helpers.fun
└── utils/
└── format.fun
$ cat lib/helpers.fun
let double x = x * 2
$ cat lib/math.fun
open "helpers.fun"
let square x = x * x
let squareDouble x = square (double x)
$ cat utils/format.fun
open "../lib/math.fun"
let formatSquare x = to_string (square x)
$ cat main.l3
open "utils/format.fun"
let result = formatSquare 7
$ fn main.l3
"49"
lib/math.fun 안의 open "helpers.fun"은 lib/ 디렉토리에서 helpers.fun을 찾습니다. utils/format.fun 안의 open "../lib/math.fun"은 utils/에서 한 단계 올라가 lib/math.fun을 찾습니다. 이 방식은 Rust의 모듈 해석과 동일한 원칙으로, 실행 디렉토리에 무관하게 일관된 결과를 보장합니다.
임포트된 모듈의 한정된 접근
임포트된 파일에 모듈이 정의되어 있으면, 한정된 접근(dot notation)으로 사용할 수 있습니다:
$ cat mathlib.fun
module Math =
let square x = x * x
let cube x = x * x * x
$ cat main.l3
open "mathlib.fun"
let result = Math.square 5 + Math.cube 2
$ fn main.l3
33
이 방식으로 여러 파일에 걸친 모듈 시스템을 구성할 수 있습니다.
순환 임포트 감지
FunLang는 순환 임포트를 자동으로 감지합니다. a.fun이 b.fun을 임포트하고 b.fun이 다시 a.fun을 임포트하면 오류가 발생합니다:
Error: Circular module dependency: a.fun → b.fun → a.fun
의존성 그래프는 항상 단방향이어야 합니다.
빈 줄과 가독성
모듈 본문 안에서 선언 사이에 빈 줄을 넣어 코드를 논리적으로 구분할 수 있습니다:
$ cat readable.l3
module Config =
let host = "localhost"
let port = 5432
let maxRetries = 3
let timeout = 30
let result = Config.host + ":" + to_string Config.port
$ fn readable.l3
"localhost:5432"
빈 줄은 모듈 본문뿐 아니라, let 본문이나 match 표현식 안에서도 허용됩니다. F#과 동일한 관례로, 관련 있는 선언을 그룹화하고 논리적 단위 사이에 빈 줄을 넣으면 읽기 좋은 코드가 됩니다.
모듈과 타입 클래스
모듈 안에서 타입 클래스 인스턴스를 선언할 수 있습니다. 타입과 그에 대한 인스턴스를 하나의 모듈로 캡슐화하는 것은 일반적인 패턴입니다:
$ cat mod_tc.l3
module Colors =
type Color = | Red | Green | Blue
instance Show Color =
let show c =
match c with
| Red -> "red"
| Green -> "green"
| Blue -> "blue"
open Colors
let result = show Green
$ fn mod_tc.l3
"green"
인스턴스는 전역적으로 동작하므로, open Colors 이후에 show Green이 바로 동작합니다. 타입 클래스에 대한 자세한 내용은 23장: 타입 클래스를 참조하세요.
참고 사항
- 들여쓰기 기반: 모듈 본문은 들여쓰기로 구분되며,
end나}가 아님 - 빈 줄 허용: 모듈 본문, let 본문, match 안에서 빈 줄로 코드를 구분할 수 있음
- 위에서 아래 순서: 모듈은 참조되기 전에 정의되어야 함 (순환 의존성 불가)
module M =은 중첩 모듈에=을 사용; 최상위module M은=이 없음- 타입 클래스 인스턴스: 모듈 안에 선언해도 전역적으로 동작 (23장 참조)
- 한정된 접근은 값, 함수, 생성자에 대해 동작함
open "file.fun"으로 외부 파일의 바인딩과 모듈을 임포트 가능 (경로는 임포트하는 파일 기준)
코드를 모듈로 구조화하고 외부 파일을 임포트할 수 있게 되었으니, 다음으로는 파일 시스템과 운영체제와 상호작용하는 방법을 알아봅니다.
파일 I/O와 시스템 함수 (File I/O and System Functions)
모듈로 코드를 구조화하고 외부 파일을 임포트할 수 있게 되었으니, 이번에는 파일 시스템과 운영체제와 상호작용하는 방법을 알아봅니다. FunLang는 파일 읽기/쓰기, 환경 변수, 디렉토리 탐색 등 시스템과 상호작용하는 내장 함수를 제공합니다. 이 함수들은 인터프리터에 내장되어 있으며 import 없이 사용할 수 있습니다.
파일 읽기와 쓰기
read_file / write_file
가장 기본적인 파일 I/O입니다. write_file은 파일에 문자열을 쓰고, read_file은 파일 전체를 문자열로 읽습니다:
$ cat file_rw.l3
let _ = write_file "/tmp/hello.txt" "hello world"
let content = read_file "/tmp/hello.txt"
let result = content
$ fn file_rw.l3
"hello world"
write_file은 기존 파일을 덮어씁니다. 파일이 없으면 새로 생성합니다.
append_file
기존 파일 끝에 내용을 추가합니다:
$ cat file_append.l3
let _ = write_file "/tmp/log.txt" "line1"
let _ = append_file "/tmp/log.txt" "\nline2"
let result = read_file "/tmp/log.txt"
$ fn file_append.l3
"line1\nline2"
read_lines / write_lines
줄 단위로 읽고 쓸 때 사용합니다. write_lines는 문자열 리스트를 각 줄로 쓰고, read_lines는 파일을 줄 단위로 읽어 리스트로 반환합니다:
$ cat file_lines.l3
let _ = write_lines "/tmp/data.txt" ["alice"; "bob"; "carol"]
let names = read_lines "/tmp/data.txt"
let result = length names
$ fn file_lines.l3
3
file_exists
파일 존재 여부를 확인합니다:
$ cat file_check.l3
let _ = write_file "/tmp/exists.txt" "data"
let result = file_exists "/tmp/exists.txt"
$ fn file_check.l3
true
에러 처리
파일이 없는 경우 read_file과 read_lines는 예외를 발생시킵니다. try-with로 처리할 수 있습니다:
$ cat file_error.l3
let result =
try
read_file "/tmp/nonexistent_file_xyz.txt"
with
| e -> "file not found"
$ fn file_error.l3
"file not found"
시스템 함수
get_cwd
현재 작업 디렉토리를 반환합니다:
$ cat get_cwd.l3
let cwd = get_cwd ()
let result = String.length cwd > 0
$ fn get_cwd.l3
true
get_env
환경 변수를 읽습니다. 변수가 설정되어 있지 않으면 예외가 발생합니다:
$ cat get_env.l3
let result =
try get_env "NONEXISTENT_VAR_XYZ"
with e -> "not set"
$ fn get_env.l3
"not set"
get_args
스크립트에 전달된 커맨드라인 인자를 문자열 리스트로 반환합니다:
$ cat show_args.l3
let args = get_args ()
let result = args
$ fn show_args.l3 -- foo bar
["foo"; "bar"]
path_combine
두 경로를 합칩니다:
fn> path_combine "/home/user" "file.txt"
"/home/user/file.txt"
dir_files
디렉토리의 파일 목록을 반환합니다:
$ cat dir_list.l3
let files = dir_files "/tmp"
let result = length files > 0
$ fn dir_list.l3
true
eprint
표준 오류(stderr)로 출력합니다. 디버깅이나 로그에 유용합니다:
$ cat debug.l3
let _ = eprint "debug: starting\n"
let result = 42
$ fn debug.l3
42
eprint의 출력은 stderr로 가므로, stdout의 결과와 섞이지 않습니다.
stdin_read_line
표준 입력에서 한 줄을 읽습니다. 대화형 프로그램에 유용합니다:
$ cat greet.l3
let _ = print "이름을 입력하세요: "
let name = stdin_read_line ()
let _ = println ("안녕하세요, " + name + "!")
let result = ()
$ echo "Alice" | fn greet.l3
이름을 입력하세요: 안녕하세요, Alice!
함수 요약
| 함수 | 타입 | 설명 |
|---|---|---|
read_file | string -> string | 파일 전체를 문자열로 읽기 |
write_file | string -> string -> unit | 파일에 문자열 쓰기 (덮어쓰기) |
append_file | string -> string -> unit | 파일 끝에 추가 |
file_exists | string -> bool | 파일 존재 여부 |
read_lines | string -> string list | 줄 단위로 읽기 |
write_lines | string -> string list -> unit | 줄 단위로 쓰기 |
get_cwd | unit -> string | 현재 작업 디렉토리 |
get_env | string -> string | 환경 변수 (없으면 예외) |
get_args | unit -> string list | 커맨드라인 인자 |
path_combine | string -> string -> string | 경로 합치기 |
dir_files | string -> string list | 디렉토리 파일 목록 |
eprint | string -> unit | stderr 출력 |
stdin_read_line | unit -> string | stdin에서 한 줄 읽기 |
참고 사항
- unit 인자:
get_cwd,get_args,stdin_read_line은()인자가 필요합니다 - 커링:
write_file,append_file,write_lines,path_combine은 커링됩니다.write_file "/tmp/f.txt" "content"형태로 호출 - 예외:
read_file,read_lines,get_env,dir_files는 대상이 없으면 예외 발생 —try-with로 처리 - 경로: 상대 경로는 현재 작업 디렉토리 기준, 절대 경로도 사용 가능
가변 데이터 구조 (Mutable Data Structures)
파일 I/O와 시스템 함수를 통해 외부 세계와 상호작용하는 방법을 배웠습니다. 이번 장에서는 FunLang가 제공하는 두 가지 가변 데이터 구조인 **배열(Array)**과 **해시테이블(Hashtable)**을 살펴봅니다.
FunLang는 기본적으로 불변(immutable) 함수형 언어입니다. 리스트, 튜플, 레코드는 한 번 생성되면 값이 바뀌지 않습니다. 하지만 특정 상황에서는 가변 상태가 훨씬 자연스럽고 효율적입니다. 배열은 인덱스로 O(1) 접근이 필요할 때, 해시테이블은 동적인 키-값 저장소가 필요할 때 유용합니다. 이 두 타입은 명시적으로 변이(mutation)를 수행한다는 점에서 다른 FunLang 값들과 구별됩니다.
배열 (Array)
배열은 고정 크기의 가변 시퀀스입니다. 한 번 생성하면 길이는 바뀌지 않지만, 각 원소는 제자리에서(in-place) 바꿀 수 있습니다. 인덱스 기반 접근이 O(1)이므로 순차 접근이 잦거나 특정 위치를 반복적으로 수정해야 할 때 리스트보다 유리합니다.
생성과 기본 연산
Array.create n v는 길이 n의 배열을 만들고 모든 원소를 v로 초기화합니다. Array.set arr i v로 인덱스 i의 원소를 v로 바꾸고, Array.get arr i로 읽습니다. Array.length arr는 배열의 길이를 반환합니다:
$ cat arr_basic.l3
let arr = Array.create 5 0
let _ = Array.set arr 0 10
let _ = Array.set arr 1 20
let _ = Array.set arr 2 30
let v = Array.get arr 2
let n = Array.length arr
let result = (arr, v, n)
$ fn arr_basic.l3
([|10; 20; 30; 0; 0|], 30, 5)
배열은 [|1; 2; 3|] 형식으로 출력됩니다. Array.set은 unit을 반환하므로 let _ =로 받습니다. 변이는 제자리에서 일어나므로 arr을 다시 바인딩할 필요가 없습니다.
리스트 변환
Array.ofList로 리스트를 배열로, Array.toList로 배열을 리스트로 변환할 수 있습니다. 두 함수를 조합하면 리스트로 데이터를 준비하고, 배열로 변환하여 인덱스 기반 수정을 한 뒤, 다시 리스트로 꺼낼 수 있습니다:
$ cat arr_conv.l3
let lst = [1; 2; 3; 4; 5]
let arr = Array.ofList lst
let _ = Array.set arr 2 99
let back = Array.toList arr
let result = back
$ fn arr_conv.l3
[1; 2; 99; 4; 5]
Array.set arr 2 99가 세 번째 원소를 99로 바꿨고, Array.toList로 리스트로 꺼내면 그 변화가 반영되어 있습니다.
고차 함수
배열도 리스트처럼 고차 함수를 지원합니다. Array.iter, Array.map, Array.fold, Array.init이 있습니다.
Array.iter — 각 원소에 부수 효과(side effect) 함수를 적용합니다. 반환값은 unit입니다:
$ cat arr_iter.l3
let arr = Array.ofList [10; 20; 30]
let _ = Array.iter (fun x -> println (to_string x)) arr
let result = "완료"
$ fn arr_iter.l3
10
20
30
"완료"
Array.map — 각 원소에 함수를 적용하여 새 배열을 반환합니다. 원본 배열은 변경되지 않습니다:
$ cat arr_map.l3
let arr = Array.ofList [1; 2; 3; 4; 5]
let squared = Array.map (fun x -> x * x) arr
let result = squared
$ fn arr_map.l3
[|1; 4; 9; 16; 25|]
Array.fold — 배열을 하나의 값으로 축약합니다. 콜백은 반드시 커링된 형태 fun acc -> fun x -> ...로 작성합니다:
$ cat arr_fold.l3
let arr = Array.ofList [1; 2; 3; 4; 5]
let total = Array.fold (fun acc -> fun x -> acc + x) 0 arr
let result = total
$ fn arr_fold.l3
15
(fun acc -> fun x -> acc + x)는 FunLang에서 두 인자를 받는 콜백을 작성하는 방식입니다. fun acc x -> ...는 파싱 에러이므로 반드시 커링 형태를 사용해야 합니다.
Array.init — 인덱스 i에 함수 f i를 적용한 값으로 배열을 초기화합니다:
$ cat arr_init.l3
let arr = Array.init 6 (fun i -> i * i)
let result = arr
$ fn arr_init.l3
[|0; 1; 4; 9; 16; 25|]
Array.init 6 f는 길이 6인 배열을 만들고 각 인덱스 i에 f i를 채웁니다. Array.create 6 v 뒤에 반복적으로 Array.set을 호출하는 것과 같지만 훨씬 간결합니다.
주의사항
범위 초과(Out-of-bounds): Array.get이나 Array.set에서 인덱스가 범위를 벗어나면 예외가 발생합니다. try-with로 처리할 수 있습니다:
$ cat arr_oob.l3
let arr = Array.create 3 0
let result =
try
Array.get arr 10
with
| e -> -1
$ fn arr_oob.l3
-1
참조 동등성: 배열은 참조 동등성을 사용합니다. 내용이 같은 두 배열이라도 = 연산자는 항상 false를 반환합니다. 동등성을 비교하려면 Array.toList로 변환한 뒤 리스트로 비교하거나, 직접 원소를 순회하는 방법을 사용하세요.
모듈 한정 이름: open Array를 사용하지 않습니다. 항상 Array.create, Array.get 등 모듈 한정 이름으로 호출합니다.
람다 안에서의 모듈 접근: 람다를 Array.iter 등의 고차 함수에 인라인으로 전달할 때, 람다 본체 안에서 Hashtable.set 같은 다른 모듈의 함수를 바로 호출하면 에러가 날 수 있습니다. 이 경우 모듈 함수를 미리 로컬 변수에 바인딩한 뒤 사용하세요.
해시테이블 (Hashtable)
해시테이블은 동적인 키-값 저장소입니다. 크기가 고정되지 않고, 어떤 FunLang 값이든 키나 값으로 쓸 수 있습니다. 빠른 키 조회(O(1) 평균), 동적 추가/삭제가 필요할 때 유용합니다.
생성과 기본 연산
Hashtable.create ()로 빈 해시테이블을 만들고, Hashtable.set, Hashtable.get, Hashtable.containsKey로 조작합니다:
$ cat ht_basic.l3
let ht = Hashtable.create ()
let _ = Hashtable.set ht "name" "Alice"
let _ = Hashtable.set ht "score" 42
let v = Hashtable.get ht "name"
let has = Hashtable.containsKey ht "score"
let result = (v, has)
$ fn ht_basic.l3
("Alice", true)
Hashtable.set ht key value는 unit을 반환하는 변이 연산입니다. Hashtable.get ht key는 값을 반환하며, 키가 없으면 예외가 발생합니다. Hashtable.containsKey ht key는 키 존재 여부를 bool로 반환합니다.
덮어쓰기: 이미 있는 키에 Hashtable.set을 호출하면 값이 덮어씌워집니다:
$ cat ht_overwrite.l3
let ht = Hashtable.create ()
let _ = Hashtable.set ht "score" 10
let _ = Hashtable.set ht "score" 99
let result = Hashtable.get ht "score"
$ fn ht_overwrite.l3
99
키 목록과 삭제
Hashtable.keys ht는 현재 테이블의 모든 키를 리스트로 반환합니다. Hashtable.remove ht key는 해당 키-값 쌍을 제거합니다:
$ cat ht_keys.l3
let ht = Hashtable.create ()
let _ = Hashtable.set ht "a" 1
let _ = Hashtable.set ht "b" 2
let _ = Hashtable.set ht "c" 3
let count_before = length (Hashtable.keys ht)
let _ = Hashtable.remove ht "b"
let count_after = length (Hashtable.keys ht)
let result = (count_before, count_after)
$ fn ht_keys.l3
(3, 2)
Hashtable.keys가 반환하는 리스트에는 삽입 순서가 보장되지 않습니다. 특정 순서로 키를 처리해야 한다면 Hashtable.keys로 얻은 리스트를 sort로 정렬한 뒤 사용하세요.
주의사항
키 순서 비결정적: Hashtable.keys의 반환 순서는 실행마다 달라질 수 있습니다. 키 목록 자체를 결과로 출력하는 예제는 작성하지 않는 것이 좋습니다.
없는 키 접근: Hashtable.get으로 없는 키에 접근하면 예외가 발생합니다. 키 존재 여부를 먼저 Hashtable.containsKey로 확인하거나, try-with로 처리하세요.
모듈 한정 이름: open Hashtable을 사용하지 않습니다. 항상 Hashtable.create, Hashtable.set 등 모듈 한정 이름으로 호출합니다.
해시테이블 순회 (for-in)
해시테이블의 모든 키-값 쌍을 순회하려면 for (k, v) in ht do 구문을 사용합니다. 튜플 패턴으로 키와 값을 동시에 바인딩할 수 있습니다:
$ cat ht_forin.l3
let ht = Hashtable.create ()
let _ = Hashtable.set ht "name" "Alice"
let _ = for (k, v) in ht do
let _ = println k
println v
$ fn ht_forin.l3
name
Alice
()
Hashtable.keys로 키 리스트를 얻어 순회하는 것보다 간결합니다. 순회 순서는 비결정적입니다.
HashSet
HashSet은 중복 없는 값의 집합입니다. HashSet.add는 이미 있는 값을 추가하면 false를 반환하고, 새로운 값이면 true를 반환합니다:
$ cat hashset_basic.l3
let hs = HashSet.create ()
let _ = println (to_string (HashSet.add hs 1))
let _ = println (to_string (HashSet.add hs 2))
let _ = println (to_string (HashSet.add hs 1))
let _ = println (to_string (HashSet.contains hs 1))
let _ = println (to_string (HashSet.contains hs 9))
let _ = println (to_string (HashSet.count hs))
$ fn hashset_basic.l3
true
true
false
true
false
2
()
for x in hs do 구문으로 순회할 수 있습니다:
$ cat hashset_forin.l3
let hs = HashSet.create ()
let _ = HashSet.add hs 42
let _ = for x in hs do println (to_string x)
$ fn hashset_forin.l3
42
()
| 함수 | 설명 |
|---|---|
HashSet.create () | 빈 HashSet 생성 |
HashSet.add hs v | 값 추가 (새로우면 true, 중복이면 false) |
HashSet.contains hs v | 값 존재 여부 |
HashSet.count hs | 원소 개수 |
중복 검사, 멤버십 테스트, 집합 연산이 필요할 때 유용합니다.
Queue
Queue는 FIFO(선입선출) 자료구조입니다. enqueue로 넣고 dequeue로 꺼냅니다:
$ cat queue_basic.l3
let q = Queue.create ()
let _ = Queue.enqueue q 10
let _ = Queue.enqueue q 20
let _ = Queue.enqueue q 30
let _ = println (to_string (Queue.count q))
let v1 = Queue.dequeue q ()
let _ = println (to_string v1)
let v2 = Queue.dequeue q ()
let _ = println (to_string v2)
let _ = println (to_string (Queue.count q))
$ fn queue_basic.l3
3
10
20
1
()
Queue.dequeue q ()는 가장 먼저 넣은 값을 꺼내 반환합니다. 빈 큐에서 dequeue하면 예외가 발생합니다.
for x in q do 구문으로 순회할 수 있습니다 (큐의 내용은 유지됩니다):
$ cat queue_forin.l3
let q = Queue.create ()
let _ = Queue.enqueue q 1
let _ = Queue.enqueue q 2
let _ = Queue.enqueue q 3
let _ = for x in q do println (to_string x)
$ fn queue_forin.l3
1
2
3
()
| 함수 | 설명 |
|---|---|
Queue.create () | 빈 Queue 생성 |
Queue.enqueue q v | 값을 큐의 뒤에 추가 |
Queue.dequeue q () | 앞에서 값을 꺼내 반환 (비어 있으면 예외) |
Queue.count q | 큐의 원소 개수 |
BFS(너비 우선 탐색) 등 FIFO가 필요한 알고리즘에 적합합니다.
MutableList
MutableList는 동적으로 크기가 변하는 가변 리스트입니다. 배열과 달리 크기가 고정되지 않고, 불변 리스트와 달리 제자리 수정이 가능합니다:
$ cat ml_basic.l3
let ml = MutableList.create ()
let _ = MutableList.add ml 10
let _ = MutableList.add ml 20
let _ = MutableList.add ml 30
let _ = println (to_string (MutableList.count ml))
let _ = println (to_string ml.[0])
let _ = println (to_string ml.[1])
let _ = println (to_string ml.[2])
$ fn ml_basic.l3
3
10
20
30
()
.[i]로 읽고, .[i] <- v로 인덱스 위치의 값을 수정할 수 있습니다:
$ cat ml_index.l3
let ml = MutableList.create ()
let _ = MutableList.add ml 100
let _ = MutableList.add ml 200
let _ = println (to_string ml.[0])
let _ = ml.[0] <- 999
let _ = println (to_string ml.[0])
$ fn ml_index.l3
100
999
()
for x in ml do 구문으로 순회할 수 있습니다:
$ cat ml_forin.l3
let ml = MutableList.create ()
let _ = MutableList.add ml 5
let _ = MutableList.add ml 10
let _ = MutableList.add ml 15
let _ = for x in ml do println (to_string x)
$ fn ml_forin.l3
5
10
15
()
| 함수 | 설명 |
|---|---|
MutableList.create () | 빈 MutableList 생성 |
MutableList.add ml v | 뒤에 값 추가 (크기 자동 증가) |
MutableList.count ml | 원소 개수 |
ml.[i] | 인덱스 읽기 |
ml.[i] <- v | 인덱스 쓰기 |
배열보다 유연한 가변 컬렉션이 필요할 때 사용합니다. C#의 List<T>나 Python의 list에 해당합니다.
언제 사용할까?
대부분의 FunLang 코드는 불변 리스트와 재귀 함수만으로 충분합니다. 가변 데이터 구조는 특정 상황에서 진가를 발휘합니다:
| 상황 | 권장 |
|---|---|
| 순차 처리, 변환, 필터링 | 리스트 + map/filter/fold |
| 인덱스로 O(1) 접근, 고정 크기 수정 | Array |
| 동적 크기, 인덱스 접근 + 추가 | MutableList |
| 동적 키-값 저장, 빈도 계산, 캐시 | Hashtable |
| 중복 없는 값 집합, 멤버십 테스트 | HashSet |
| FIFO 순서 처리 (BFS 등) | Queue |
예를 들어 정렬이나 수열 생성은 리스트로 충분하지만, 행렬 연산처럼 특정 위치를 반복적으로 읽고 쓰는 경우는 배열이 적합합니다. 단어 빈도를 집계하거나 결과를 메모이제이션(memoize)할 때는 해시테이블이 자연스럽습니다.
함수 요약
Array
| 함수 | 설명 |
|---|---|
Array.create n v | 길이 n, 초기값 v인 배열 생성 |
Array.get arr i | 인덱스 i의 원소 반환 (범위 초과 시 예외) |
Array.set arr i v | 인덱스 i의 원소를 v로 변경 (unit 반환) |
Array.length arr | 배열 길이 반환 |
Array.ofList lst | 리스트를 배열로 변환 |
Array.toList arr | 배열을 리스트로 변환 |
Array.iter f arr | 각 원소에 f를 적용 (unit 반환) |
Array.map f arr | 각 원소에 f를 적용한 새 배열 반환 |
Array.fold f init arr | 배열을 하나의 값으로 축약 (커링 콜백 필요) |
Array.init n f | f i로 인덱스 i를 초기화한 길이 n 배열 생성 |
Array.sort arr | 배열을 제자리 정렬 |
Array.ofSeq coll | 임의의 컬렉션을 배열로 변환 |
Hashtable
| 함수 | 설명 |
|---|---|
Hashtable.create () | 빈 해시테이블 생성 |
Hashtable.set ht k v | 키 k에 값 v 저장 (이미 있으면 덮어쓰기) |
Hashtable.get ht k | 키 k의 값 반환 (없으면 예외) |
Hashtable.containsKey ht k | 키 k 존재 여부 반환 |
Hashtable.keys ht | 모든 키의 리스트 반환 (순서 비보장) |
Hashtable.remove ht k | 키 k와 해당 값 제거 |
HashSet
| 함수 | 설명 |
|---|---|
HashSet.create () | 빈 HashSet 생성 |
HashSet.add hs v | 값 추가 (새로우면 true, 중복이면 false) |
HashSet.contains hs v | 값 존재 여부 |
HashSet.count hs | 원소 개수 |
Queue
| 함수 | 설명 |
|---|---|
Queue.create () | 빈 Queue 생성 |
Queue.enqueue q v | 값을 큐의 뒤에 추가 |
Queue.dequeue q () | 앞에서 값을 꺼내 반환 (비어 있으면 예외) |
Queue.count q | 큐의 원소 개수 |
MutableList
| 함수 | 설명 |
|---|---|
MutableList.create () | 빈 MutableList 생성 |
MutableList.add ml v | 뒤에 값 추가 |
MutableList.count ml | 원소 개수 |
ml.[i] | 인덱스 읽기 |
ml.[i] <- v | 인덱스 쓰기 |
StringBuilder
| 함수 | 설명 |
|---|---|
StringBuilder.create () | 빈 StringBuilder 생성 |
StringBuilder.add sb s | 문자열 또는 문자를 추가 |
StringBuilder.toString sb | 축적된 내용을 문자열로 반환 |
가변 변수 (Mutable Variables)
이전 장에서 배열(Array)과 해시테이블(Hashtable)이라는 가변 데이터 구조를 살펴봤습니다. 이들은 가변 컨테이너입니다 — 구조 자체가 제자리에서(in-place) 변합니다. 이번 장에서는 let mut으로 선언하는 **가변 변수(mutable variable)**를 소개합니다. 가변 변수는 변수 바인딩 자체를 바꿀 수 있게 해줍니다.
기본 사용법
let mut으로 가변 변수를 선언하고, <- 연산자로 새 값을 대입합니다:
$ cat mut_basic.l3
let mut x = 5
let _ = x <- 10
let result = x
$ fn mut_basic.l3
10
let mut x = 5는 x를 가변으로 선언합니다. x <- 10은 x의 값을 10으로 변경합니다. 대입 연산(<-)은 unit을 반환하므로 let _ =로 받습니다:
$ cat mut_unit.l3
let mut x = 5
let r = x <- 10
let result = r
$ fn mut_unit.l3
()
x <- 10의 반환값이 ()(unit)임을 확인할 수 있습니다. 이는 대입이 부수 효과(side effect)임을 명시합니다.
모듈 수준 가변 변수
파일의 최상위(top-level)에서도 let mut을 사용할 수 있습니다. 모듈 수준 가변 변수는 파일 전체에서 접근하고 변경할 수 있습니다:
$ cat mut_toplevel.l3
let mut counter = 0
let _ = counter <- counter + 1
let _ = counter <- counter + 1
let _ = counter <- counter + 1
let result = counter
$ fn mut_toplevel.l3
3
매번 counter <- counter + 1로 현재 값에 1을 더한 결과를 다시 대입합니다.
다양한 타입
가변 변수는 정수 외에도 다양한 타입에 사용할 수 있습니다.
문자열:
$ cat mut_string.l3
let mut greeting = "hello"
let _ = greeting <- "world"
let result = greeting
$ fn mut_string.l3
"world"
불리언(bool):
$ cat mut_bool.l3
let mut flag = true
let _ = flag <- false
let result = flag
$ fn mut_bool.l3
false
단, 한 번 선언된 가변 변수의 타입은 바꿀 수 없습니다. let mut x = 5 이후 x <- "hello"를 시도하면 타입 에러가 발생합니다 (에러 케이스 섹션 참조).
중첩 가변 변수
여러 개의 가변 변수를 동시에 사용할 수 있습니다:
$ cat mut_multi.l3
let mut x = 0
let mut y = 0
let _ = x <- 10
let _ = y <- 20
let result = (x, y)
$ fn mut_multi.l3
(10, 20)
각 가변 변수는 독립적으로 관리됩니다. x를 바꿔도 y에는 영향이 없습니다.
함수와 가변 변수
함수 본체(body) 안에서 let mut을 사용하면, 해당 가변 변수는 함수의 지역 변수가 됩니다:
$ cat mut_func.l3
let counter () =
let mut n = 0
let _ = n <- n + 1
let _ = n <- n + 1
let _ = n <- n + 1
n
let result = counter ()
$ fn mut_func.l3
3
counter를 호출할 때마다 n은 0에서 시작하여 3번 증가한 뒤 최종값 3을 반환합니다.
클로저 캡처
함수(클로저)는 바깥 스코프의 가변 변수를 읽고 쓸 수 있습니다:
$ cat mut_closure.l3
let mut count = 0
let inc () = count <- count + 1
let _ = inc ()
let _ = inc ()
let _ = inc ()
let result = count
$ fn mut_closure.l3
3
inc 함수는 바깥의 count를 캡처하여 호출될 때마다 값을 1씩 증가시킵니다. 가변 변수에 대한 클로저 캡처는 참조(reference)로 이루어지므로 함수 안에서의 변경이 바깥에도 반영됩니다.
인자를 받는 함수도 같은 방식으로 동작합니다:
$ cat mut_closure2.l3
let mut total = 0
let add n = total <- total + n
let _ = add 10
let _ = add 20
let _ = add 30
let result = total
$ fn mut_closure2.l3
60
여러 클로저가 하나의 가변 변수를 공유할 수도 있습니다:
$ cat mut_shared.l3
let mut x = 0
let inc y = x <- x + 1
let dec y = x <- x - 1
let get y = x
let _ = inc 0
let _ = inc 0
let _ = inc 0
let _ = dec 0
let result = get 0
$ fn mut_shared.l3
2
inc, dec, get 세 함수가 동일한 x를 공유합니다. 이 패턴은 간단한 상태 관리에 유용합니다.
재귀 함수와 가변 변수를 결합할 수도 있습니다:
$ cat mut_recursive.l3
let mut total = 0
let rec sum_list lst =
match lst with
| [] -> ()
| x :: rest ->
let _ = total <- total + x
sum_list rest
let _ = sum_list [1; 2; 3; 4; 5]
let result = total
$ fn mut_recursive.l3
15
변수 섀도잉
안쪽 스코프에서 같은 이름의 let mut을 선언하면, 바깥 변수와 독립적인 새 가변 변수가 만들어집니다:
$ cat mut_shadow.l3
let result =
let mut x = 10
let inner =
let mut x = 100
let _ = x <- 200
x
let _ = x <- 20
(inner, x)
$ fn mut_shadow.l3
(200, 20)
안쪽 x는 200으로, 바깥 x는 20으로 각각 독립 변경되었습니다. 이름이 같지만 서로 다른 변수입니다.
try-with와 가변 변수
예외 처리와 가변 변수를 함께 사용할 수 있습니다. try 블록에서 변경한 값은 예외가 발생해도 유지됩니다:
$ cat mut_try.l3
exception E
let result =
let mut x = 0
let _ =
try
let _ = x <- 42
raise E
with
| E -> x <- x + 1
x
$ fn mut_try.l3
43
x <- 42로 값이 바뀐 뒤 예외가 발생했지만, with 블록에서 x는 이미 42입니다. x <- x + 1로 43이 됩니다.
모듈 내부 가변 상태
module 안에서 let mut을 사용하면 모듈이 가변 상태를 캡슐화할 수 있습니다:
$ cat mut_module.l3
module Counter =
let mut value = 0
let inc x = value <- value + 1
let get x = value
let _ = Counter.inc 0
let _ = Counter.inc 0
let result = Counter.get 0
$ fn mut_module.l3
2
Counter.value는 모듈 외부에서 직접 접근할 수 있지만, Counter.inc과 Counter.get을 통해 제어된 인터페이스를 제공하는 패턴이 일반적입니다.
컬렉션 타입과 가변 변수
가변 변수는 리스트, 튜플, 배열 등 어떤 타입이든 담을 수 있습니다:
$ cat mut_collections.l3
let mut xs = [1; 2; 3]
let _ = xs <- [10; 20]
let _ = println (to_string xs)
let mut p = (1, 2)
let _ = p <- (10, 20)
let _ = println (to_string p)
let mut arr = Array.create 2 0
let _ = arr <- Array.ofList [100; 200]
let _ = println (to_string (Array.get arr 0))
$ fn mut_collections.l3
[10; 20]
(10, 20)
100
주의: 가변 변수를 재대입(<-)하면 변수가 가리키는 대상 전체가 바뀝니다. 배열의 개별 원소를 바꾸려면 Array.set을, 변수 전체를 다른 배열로 교체하려면 <-를 사용합니다.
함수에 값 전달
가변 변수의 값을 함수에 전달하면, 함수는 현재 값의 복사본을 받습니다. 함수 안에서 원래 가변 변수를 변경하는 것은 아닙니다:
$ cat mut_passval.l3
let add10 n = n + 10
let mut x = 5
let y = add10 x
let _ = x <- 99
let _ = println (to_string y)
let _ = println (to_string x)
$ fn mut_passval.l3
15
99
add10 x는 x의 현재 값(5)을 전달합니다. 이후 x <- 99로 x를 바꿔도 y(15)에는 영향이 없습니다.
파이프 연산자와 조합
<-의 오른쪽에 파이프 표현식을 쓸 수 있습니다:
$ cat mut_pipe.l3
let mut x = 5
let _ = x <- x |> (fun n -> n * 2)
let result = x
$ fn mut_pipe.l3
10
x |> (fun n -> n * 2)는 현재 x(5)를 2배로 만들어 10을 반환하고, 이 결과가 x에 대입됩니다.
조건문과 패턴 매칭
if-then-else의 결과를 가변 변수에 대입할 수 있습니다:
$ cat mut_cond.l3
let mut x = 0
let _ = x <- if true then 42 else 0
let result = x
$ fn mut_cond.l3
42
match 표현식도 동일하게 사용할 수 있습니다:
$ cat mut_match.l3
let mut label = "unknown"
let code = 1
let _ = label <-
match code with
| 0 -> "zero"
| 1 -> "one"
| _ -> "other"
let result = label
$ fn mut_match.l3
"one"
match를 <- 오른쪽에 쓸 때는 다음 줄로 내려서 들여쓰기합니다.
에러 케이스
E0320: 불변 변수에 대입
let(mut 없이)으로 선언한 변수에 <-를 쓰면 컴파일 에러가 발생합니다:
$ cat mut_err_immutable.l3
let x = 5
let _ = x <- 10
$ fn mut_err_immutable.l3
error[E0320]: Cannot assign to immutable variable 'x'. ...
--> mut_err_immutable.l3:2:6-14
|
2 | let _ = x <- 10
| ^^^^^^^^
= hint: Declare the variable with 'let mut' to allow assignment
소스 코드 스니펫이 문제 위치를 정확히 가리킵니다. 변수를 가변으로 만들려면 선언 시 let mut을 사용해야 합니다.
E0301: 타입 불일치
가변 변수에 다른 타입의 값을 대입하면 타입 에러가 발생합니다:
$ cat mut_err_type.l3
let mut x = 5
let _ = x <- "hello"
$ fn mut_err_type.l3
error[E0301]: Type mismatch: expected int but got string
--> mut_err_type.l3:2:6-20
|
2 | let _ = x <- "hello"
| ^^^^^^^^^^^^^^
x는 int로 선언되었으므로 string을 대입할 수 없습니다. 가변 변수의 타입은 선언 시점에 고정됩니다.
불변 vs 가변
FunLang는 기본적으로 불변을 선호합니다. 가변 변수는 필요할 때만 사용하세요.
| 항목 | 불변 (let) | 가변 (let mut) |
|---|---|---|
| 선언 | let x = 5 | let mut x = 5 |
| 재대입 | 불가 (에러) | x <- 10 |
| 타입 변경 | 해당 없음 | 불가 (같은 타입만) |
| 제네릭 | 가능 | 불가 (단형성) |
| 권장 상황 | 대부분의 코드 | 누적, 카운터, 상태 관리 |
언제 가변 변수를 사용할까:
- 루프에서 누적값을 쌓을 때
- 호출 횟수를 세는 카운터가 필요할 때
- 여러 단계에 걸쳐 상태를 점진적으로 변경할 때
불변으로 충분한 경우:
- 값을 한 번 계산하고 이름을 붙이는 경우
- 재귀와
fold로 누적이 가능한 경우 - 패턴 매칭으로 분기 처리하는 경우
대부분의 FunLang 코드는 let만으로 충분합니다. let mut은 가변 상태가 코드를 더 명확하고 간결하게 만드는 경우에 사용하세요.
구문 요약
| 구문 | 설명 |
|---|---|
let mut x = expr | 가변 변수 선언 |
x <- expr | 가변 변수에 새 값 대입 (unit 반환) |
let _ = x <- expr | 대입의 unit 반환값을 버림 |
let mut x = expr in body | 지역 가변 변수 (표현식 모드) |
let mutable x = expr | mut의 동의어 (F# 호환) |
명령형 에르고노믹스 (Imperative Ergonomics)
FunLang는 명령형 스타일 코드를 더 자연스럽게 작성할 수 있는 네 가지 문법 기능을 제공합니다. 이 장에서는 표현식 시퀀싱, 루프, 인덱싱 문법, else 없는 if 표현식을 살펴봅니다.
표현식 시퀀싱 (Expression Sequencing)
함수형 언어에서 여러 부수 효과(side effect)를 순서대로 실행하려면 전통적으로 let _ = e1 in e2 패턴을 사용했습니다. FunLang에서는 ; 연산자로 이를 간결하게 쓸 수 있습니다.
e1; e2는 e1을 평가한 뒤 그 결과를 버리고, e2를 평가하여 그 값을 반환합니다.
$ cat seq_basic.l3
let _ = println "hello"; println "world"
$ fn seq_basic.l3
hello
world
()
여러 단계를 체이닝할 수도 있습니다. 아래는 가변 변수와 결합하여 카운터를 증가시키는 예입니다:
$ cat seq_chain.l3
let result = let mut x = 0 in x <- 1; x <- x + 1; x <- x + 1; x
$ fn seq_chain.l3
3
함수 본체 안에서도 동일하게 사용할 수 있습니다:
$ cat seq_block.l3
let f () =
println "a"; println "b"; println "c"
let result = f ()
$ fn seq_block.l3
a
b
c
()
;은 오른쪽 결합(right-associative)이므로 e1; e2; e3는 e1; (e2; e3)로 파싱됩니다. 최종 반환값은 마지막 표현식의 값입니다.
루프 (Loops)
while 루프
while cond do body 형태의 루프는 조건(cond)이 참인 동안 본체(body)를 반복 실행합니다. 루프 전체의 반환값은 unit입니다.
$ cat while_basic.l3
let mut i = 0
let _ = while i < 3 do i <- i + 1
let _ = println (to_string i)
$ fn while_basic.l3
3
()
루프 본체에서 여러 문장을 실행하려면 ;으로 연결합니다. 들여쓰기 블록 안에서 ;을 사용하면 됩니다:
$ cat while_body.l3
let mut count = 0
let mut sum = 0
let _ =
while count < 4 do
sum <- sum + count; count <- count + 1
let _ = println (to_string sum)
$ fn while_body.l3
6
()
루프가 끝날 때 count는 4, sum은 0+1+2+3 = 6이 됩니다.
for 루프
for i = start to end do body는 i를 start부터 end까지 1씩 증가시키며 반복합니다. downto를 쓰면 1씩 감소합니다.
오름차순 (to):
$ cat for_asc.l3
let mut total = 0
let _ =
for i = 0 to 3 do
total <- total + i
let _ = println (to_string total)
$ fn for_asc.l3
6
()
i가 0, 1, 2, 3 순서로 실행되며, total은 0+1+2+3 = 6이 됩니다.
내림차순 (downto):
$ cat for_desc.l3
let mut total = 0
let _ =
for i = 3 downto 0 do
total <- total + i
let _ = println (to_string total)
$ fn for_desc.l3
6
()
to는 오름차순(start ≤ end), downto는 내림차순(start ≥ end)으로 반복합니다. start > end인 경우(to) 또는 start < end인 경우(downto) 루프 본체는 한 번도 실행되지 않고 unit을 반환합니다.
루프 변수의 불변성
for 루프 변수(i)는 불변입니다. 루프 본체 안에서 대입을 시도하면 E0320 에러가 발생합니다:
$ cat for_err.l3
let _ =
for i = 0 to 9 do
i <- 42
$ fn for_err.l3
error[E0320]: Cannot assign to immutable variable 'i'. ...
--> for_err.l3:3:8-15
|
3 | i <- 42
| ^^^^^^^
= hint: Declare the variable with 'let mut' to allow assignment
루프 카운터를 직접 수정할 필요가 있다면 별도의 let mut 변수를 사용하세요.
인덱싱 문법 (Indexing Syntax)
arr.[i] 형태의 인덱싱 문법으로 배열과 해시테이블에 더 직관적으로 접근할 수 있습니다.
배열 인덱싱
arr.[i]로 읽고, arr.[i] <- v로 씁니다. 기존의 array_get/array_set 함수보다 간결한 문법입니다.
읽기 (IndexGet):
$ cat arr_index_read.l3
let arr = array_create 3 0
let _ = array_set arr 0 10
let _ = array_set arr 1 20
let _ = array_set arr 2 30
let _ = println (to_string arr.[0])
let _ = println (to_string arr.[1])
let _ = println (to_string arr.[2])
$ fn arr_index_read.l3
10
20
30
()
쓰기 (IndexSet):
$ cat arr_index_write.l3
let arr = array_create 3 0
let _ = arr.[0] <- 42
let _ = arr.[1] <- 99
let _ = println (to_string arr.[0])
let _ = println (to_string arr.[1])
$ fn arr_index_write.l3
42
99
()
해시테이블 인덱싱
ht.[key]로 읽고, ht.[key] <- v로 씁니다.
읽기:
$ cat ht_index_read.l3
let ht = hashtable_create ()
let _ = hashtable_set ht "x" 100
let _ = hashtable_set ht "y" 200
let _ = println (to_string ht.["x"])
let _ = println (to_string ht.["y"])
$ fn ht_index_read.l3
100
200
()
쓰기:
$ cat ht_index_write.l3
let ht = hashtable_create ()
let _ = ht.["name"] <- "Alice"
let _ = ht.["score"] <- 95
let _ = println ht.["name"]
let _ = println (to_string ht.["score"])
$ fn ht_index_write.l3
Alice
95
()
체이닝
.[는 왼쪽 결합(left-associative)이므로 matrix.[r].[c]처럼 중첩 인덱싱이 가능합니다. 아래는 2D 배열(행렬) 예시입니다:
$ cat matrix.l3
let row0 = array_create 2 0
let row1 = array_create 2 0
let _ = row0.[0] <- 1
let _ = row0.[1] <- 2
let _ = row1.[0] <- 3
let _ = row1.[1] <- 4
let matrix = array_create 2 row0
let _ = matrix.[1] <- row1
let _ = println (to_string matrix.[0].[0])
let _ = println (to_string matrix.[1].[1])
$ fn matrix.l3
1
4
()
matrix.[0]은 row0 배열을 반환하고, matrix.[0].[0]은 그 첫 번째 원소(1)를 반환합니다.
else 없는 if 표현식
if cond then expr 형태로 else 없이 조건문을 쓸 수 있습니다. 이 경우 컴파일러가 암묵적으로 else ()를 추가합니다. 즉, if cond then expr else ()와 동일합니다.
부수 효과만 실행하고 결과를 버리는 경우에 유용합니다:
$ cat if_then.l3
let x = 5
let _ = if x > 0 then println "positive"
$ fn if_then.l3
positive
()
가변 변수와 함께 사용하는 실용적인 예시입니다:
$ cat if_then_mut.l3
let mut x = 0
let _ = if true then x <- 42
let result = x
$ fn if_then_mut.l3
42
암묵적 else ()가 붙으므로, then 브랜치가 unit이 아닌 값을 반환하면 타입 불일치 에러가 발생합니다:
$ cat if_then_err.l3
let _ = if true then 42
$ fn if_then_err.l3
error[E0301]: Type mismatch: expected int but got unit
--> if_then_err.l3:1:6-23
|
1 | let _ = if true then 42
| ^^^^^^^^^^^^^^^^^
if cond then expr는 then 브랜치가 unit인 경우에만 사용하세요. 값을 반환하는 조건문이라면 반드시 if cond then expr1 else expr2 형태로 작성하세요.
루프와 시퀀싱 조합 — 실용 예제
네 가지 기능을 모두 조합한 실용적인 예시입니다. 배열에서 짝수의 개수를 세는 프로그램입니다:
$ cat imperative_example.l3
let arr = array_create 5 0
let _ = arr.[0] <- 2
let _ = arr.[1] <- 3
let _ = arr.[2] <- 4
let _ = arr.[3] <- 7
let _ = arr.[4] <- 8
let mut even_count = 0
let _ =
for i = 0 to 4 do
if arr.[i] % 2 = 0 then even_count <- even_count + 1
let result = even_count
$ fn imperative_example.l3
3
arr.[i]로 배열 원소를 읽고 (인덱싱 문법)% 2 = 0으로 짝수 여부를 확인하고 (기본 연산)if ... then ...으로 조건부 실행하고 (else 없는 if)for i = 0 to 4 do로 루프를 돌리며 (for 루프)- 결과를
even_count에 누적합니다 (가변 변수 + 시퀀싱)
배열 원소 중 짝수(2, 4, 8)가 3개이므로 결과는 3입니다.
구문 요약
| 구문 | 설명 |
|---|---|
e1; e2 | 순서대로 평가, e2의 값 반환 |
e1; e2; e3 | 오른쪽 결합 체이닝 |
while cond do body | 조건이 거짓이 될 때까지 반복 |
for i = s to e do body | i를 s부터 e까지 1씩 증가 |
for i = s downto e do body | i를 s부터 e까지 1씩 감소 |
arr.[i] | 배열 원소 읽기 (IndexGet) |
arr.[i] <- v | 배열 원소 쓰기 (IndexSet) |
ht.[key] | 해시테이블 값 읽기 |
ht.[key] <- v | 해시테이블 값 쓰기 |
if cond then expr | else 없는 if (then 브랜치는 unit이어야 함) |
22장: 실용 프로그래밍 (Practical Programming)
FunLang는 일상적인 프로그래밍을 더 편리하게 만드는 세 가지 기능을 제공합니다. 뉴라인 암묵적 시퀀싱, 컬렉션 for-in 루프, 그리고 Option/Result 유틸리티 함수입니다. 이 장에서는 각 기능을 코드 예제와 함께 살펴봅니다.
뉴라인 암묵적 시퀀싱 (Newline Implicit Sequencing)
; 연산자로 표현식을 순서대로 실행할 수 있지만, 들여쓰기 블록 안에서는 줄 바꿈만으로도 동일한 효과를 낼 수 있습니다. 같은 들여쓰기 수준의 줄은 자동으로 ;으로 연결됩니다.
함수 본체에서의 뉴라인 시퀀싱
함수 본체에서 여러 줄에 걸쳐 표현식을 나열하면, 각 줄이 순서대로 실행됩니다. 마지막 표현식의 값이 함수의 반환값이 됩니다.
$ cat greet.l3
let greet name =
println ("Hello, " ^^ name)
println "Welcome to FunLang"
let _ = greet "Alice"
$ fn greet.l3
Hello, Alice
Welcome to FunLang
()
greet 함수는 println ("Hello, " ^^ name)과 println "Welcome to FunLang" 두 표현식을 순서대로 실행합니다. println의 반환값은 unit이므로, 마지막 println의 unit이 함수의 반환값이 됩니다.
if/else 본체에서의 뉴라인 시퀀싱
if/else의 then 브랜치와 else 브랜치도 들여쓰기 블록으로 여러 줄을 쓸 수 있습니다.
$ cat check.l3
let check x =
if x > 0 then
println "positive"
x * 2
else
println "non-positive"
0
let result = check 5
$ fn check.l3
positive
10
check 5를 실행하면 then 브랜치에서 println "positive"를 실행한 뒤 5 * 2 = 10을 반환합니다. 최상위 let result = check 5는 반환값 10을 출력합니다.
들여쓰기 수준에 주의하세요. then 브랜치의 표현식은 then 키워드보다 더 깊게 들여써야 하고, else 브랜치의 표현식은 else 키워드보다 더 깊게 들여써야 합니다.
컬렉션 for-in 루프 (For-In Collection Loops)
컬렉션의 원소를 순서대로 순회하는 for x in collection do 문법을 제공합니다. 리스트, 배열은 물론 Hashtable, HashSet, Queue, MutableList 등 모든 가변 컬렉션도 지원합니다. 인덱스가 필요 없을 때 for i = 0 to n do arr.[i]보다 훨씬 간결하게 컬렉션을 처리할 수 있습니다.
리스트 순회
$ cat list_iter.l3
let nums = [1; 2; 3]
let _ =
for n in nums do
println (to_string n)
$ fn list_iter.l3
1
2
3
()
for n in nums do 문법으로 리스트 nums의 각 원소를 n에 바인딩하며 순회합니다. 루프 변수 n은 불변이며, 루프 전체의 반환값은 unit입니다. 따라서 let _ =로 감싸서 최상위에서 실행합니다.
배열 순회
$ cat arr_iter.l3
let arr = array_create 3 0
let _ = arr.[0] <- 10
let _ = arr.[1] <- 20
let _ = arr.[2] <- 30
let _ =
for x in arr do
println (to_string x)
$ fn arr_iter.l3
10
20
30
()
배열도 동일한 문법으로 순회합니다. 원소는 인덱스 순서(0, 1, 2, …)로 처리됩니다.
Hashtable 순회와 패턴 분해
Hashtable을 for-in으로 순회할 때 튜플 패턴으로 키와 값을 동시에 바인딩할 수 있습니다:
$ cat ht_forin.l3
let ht = Hashtable.create ()
let _ = Hashtable.set ht "name" "Alice"
let _ = for (k, v) in ht do
let _ = println k
println v
$ fn ht_forin.l3
name
Alice
()
HashSet, Queue, MutableList도 동일한 for x in coll do 구문으로 순회할 수 있습니다.
루프 변수의 불변성
for-in 루프 변수는 for 범위 루프와 마찬가지로 불변입니다. 루프 본체 안에서 대입을 시도하면 E0320 에러가 발생합니다. 루프 안에서 집계가 필요하다면 외부에 let mut 변수를 선언하세요.
Option/Result 유틸리티 (Option/Result Utilities)
Prelude는 Option 타입과 Result 타입을 다루는 유틸리티 함수를 제공합니다. 패턴 매칭 없이 간결하게 Option/Result 값을 변환하고 조합할 수 있습니다.
optionMap과 optionBind
optionMap f opt는 opt가 Some x이면 Some (f x)를, None이면 None을 반환합니다. optionBind f opt는 opt가 Some x이면 f x(Option 반환)를, None이면 None을 반환합니다.
$ cat option_map_bind.l3
let doubled = optionMap (fun x -> x * 2) (Some 21)
let chained = optionBind (fun x -> if x > 10 then Some (x + 1) else None) doubled
let _ = println (to_string doubled)
let _ = println (to_string chained)
$ fn option_map_bind.l3
Some 42
Some 43
()
optionMap (fun x -> x * 2) (Some 21)은 Some 42를 반환합니다. optionBind는 Some 42에서 42를 꺼내 if 42 > 10 then Some 43을 반환합니다.
optionDefaultValue와 optionFilter
optionDefaultValue default opt는 opt가 None일 때 default를 반환합니다. optionFilter pred opt는 술어(predicate)를 만족하지 않으면 None으로 바꿉니다.
$ cat option_default_filter.l3
let safe = optionDefaultValue 0 (Some 42)
let fallback = optionDefaultValue 0 None
let filtered = optionFilter (fun x -> x > 5) (Some 10)
let rejected = optionFilter (fun x -> x > 5) (Some 3)
let _ = println (to_string safe)
let _ = println (to_string fallback)
let _ = println (to_string filtered)
let _ = println (to_string rejected)
$ fn option_default_filter.l3
42
0
Some 10
None
()
optionDefaultValue 0 (Some 42)는 Some이므로 안의 값 42를 반환합니다. optionDefaultValue 0 None은 기본값 0을 반환합니다. optionFilter (fun x -> x > 5) (Some 3)은 술어를 만족하지 않으므로 None을 반환합니다.
resultMap과 resultToOption
resultMap f r은 Ok x이면 Ok (f x)를, Error e이면 Error e를 반환합니다. resultToOption r은 Ok x이면 Some x를, Error _이면 None을 반환합니다.
$ cat result_map_opt.l3
let r = Ok 42
let mapped = resultMap (fun x -> x * 2) r
let asOption = resultToOption mapped
let errCase = resultToOption (Error "oops")
let _ = println (to_string mapped)
let _ = println (to_string asOption)
let _ = println (to_string errCase)
$ fn result_map_opt.l3
Ok 84
Some 84
None
()
resultMap으로 Ok 값을 변환하고, resultToOption으로 Result를 Option으로 변환합니다. Error 케이스는 None으로 변환되어 에러 메시지가 사라집니다.
Option/Result 유틸리티 함수 요약
| 함수 | 시그니처 | 설명 |
|---|---|---|
optionMap | (a -> b) -> Option a -> Option b | Some이면 함수 적용, None 전파 |
optionBind | (a -> Option b) -> Option a -> Option b | Option 반환 함수로 체이닝 |
optionFilter | (a -> bool) -> Option a -> Option a | 술어 불만족 시 None으로 변환 |
optionDefaultValue | a -> Option a -> a | None일 때 기본값 반환 |
optionIsSome | Option a -> bool | Some이면 true |
optionIsNone | Option a -> bool | None이면 true |
optionIter | (a -> unit) -> Option a -> unit | Some이면 부수 효과 실행 |
resultMap | (a -> b) -> Result a e -> Result b e | Ok이면 함수 적용, Error 전파 |
resultToOption | Result a e -> Option a | Ok → Some, Error → None |
resultDefaultValue | a -> Result a e -> a | Error일 때 기본값 반환 |
resultIter | (a -> unit) -> Result a e -> unit | Ok이면 부수 효과 실행 |
종합 예제 (Composition Example)
세 가지 기능을 조합한 예제입니다. Option 값의 리스트를 for-in으로 순회하면서 각 값에 함수를 적용합니다.
$ cat process.l3
let process items =
let _ =
for item in items do
let result = optionMap (fun x -> x * 2) item
println (to_string result)
()
let _ = process [Some 1; None; Some 3]
$ fn process.l3
Some 2
None
Some 6
()
process 함수는 Option 값의 리스트를 받아 각 원소에 optionMap (fun x -> x * 2)를 적용합니다. Some 1은 Some 2가 되고, None은 None으로 유지되며, Some 3은 Some 6이 됩니다.
함수 본체에서 뉴라인 시퀀싱(for 루프와 let result = ... → println ...)과 for-in 루프, optionMap을 자연스럽게 조합합니다.
구문 및 함수 요약
| 기능 | 예시 | 설명 |
|---|---|---|
| 뉴라인 시퀀싱 | 들여쓰기 블록 내 줄 바꿈 | 암묵적 ; 삽입 |
for x in list do | for n in nums do println (to_string n) | 리스트 원소 순회 |
for x in arr do | for x in arr do println (to_string x) | 배열 원소 순회 |
for (k, v) in ht do | for (k, v) in ht do println k | Hashtable 키-값 순회 |
for x in coll do | for x in hs do println (to_string x) | HashSet/Queue/MutableList 순회 |
optionMap f opt | optionMap (fun x -> x * 2) (Some 21) | Option 변환 |
optionBind f opt | optionBind f (Some x) | Option 체이닝 |
optionDefaultValue d opt | optionDefaultValue 0 None | 기본값 추출 |
optionFilter pred opt | optionFilter (fun x -> x > 0) opt | 조건부 필터 |
resultMap f r | resultMap (fun x -> x * 2) (Ok 42) | Result 변환 |
resultToOption r | resultToOption (Ok 42) | Result → Option 변환 |
11장: 예외 (Exceptions)
오류 처리는 모든 언어에서 쉽지 않은 문제입니다. 함수형 언어들은 보통 두 가지 접근을 씁니다. 하나는 Option이나 Result 같은 타입으로 오류를 값으로 표현하는 방법이고, 다른 하나는 예외(exception)를 발생시켜 호출 스택을 타고 올라가는 방법입니다. FunLang는 둘 다 지원하며, 이 장에서는 예외 메커니즘을 다룹니다.
예외는 “예상치 못한 상황“을 처리할 때 강력합니다. 깊이 중첩된 함수 안에서 발생한 오류를 모든 계층에서 하나씩 전달하지 않고, 한 번에 적절한 핸들러까지 올려보낼 수 있습니다. 다만, 예외를 남용하면 제어 흐름을 추적하기 어려워지므로, 정말 예외적인 상황에만 쓰는 것이 좋습니다. 예외와 Option/Result 중 언제 어떤 것을 써야 하는지는 바로 다음 12장: 에러 처리 전략에서 비교합니다.
예외 선언
exception으로 예외 타입을 선언합니다. FunLang의 예외는 ADT의 생성자와 비슷하게 생겼습니다. 이름을 선언하고, raise로 발생시키고, try-with로 잡습니다:
$ cat exc_basic.l3
exception NotFound
let result = try
raise NotFound
with
| NotFound -> 42
| _ -> 0
$ fn exc_basic.l3
42
OCaml이나 F#의 예외 구문과 거의 동일합니다. try-with 블록 안에서 예외가 발생하면 with 아래의 패턴들과 순서대로 매칭합니다. 매칭된 핸들러의 결과가 전체 try-with 식의 결과가 됩니다.
한 가지 중요한 점: FunLang에서 try-with는 식(expression)입니다. 값을 반환하므로 let result = 등에 바인딩할 수 있습니다. Java처럼 “제어 흐름을 위한 문장“이 아니라 F#처럼 “값을 생산하는 식“입니다.
데이터를 가진 예외
예외가 단순히 “무언가 잘못됐다“는 신호를 보내는 것을 넘어, 왜 잘못됐는지에 대한 정보를 담을 수 있습니다. of를 사용하면 예외에 데이터를 실을 수 있습니다:
$ cat exc_data.l3
exception InvalidArg of string
let result = try
raise (InvalidArg "bad input")
with
| InvalidArg msg -> "error: " + msg
| _ -> "unknown"
$ fn exc_data.l3
"error: bad input"
핸들러에서 InvalidArg msg처럼 패턴 매칭으로 데이터를 꺼낼 수 있습니다. 오류 메시지, 오류 코드, 또는 어떤 타입이든 담을 수 있어서 오류의 맥락을 풍부하게 전달할 수 있습니다.
참고: raise는 원자(atom)를 받으므로, 생성자 적용에는 괄호가 필요합니다: raise (InvalidArg "bad input"), raise InvalidArg "bad input"이 아닙니다. 이 점을 빠뜨리면 파서가 raise에 InvalidArg만 넘기고 "bad input"을 별개의 식으로 해석합니다. 컴파일 오류가 발생하니 금방 알아챌 수 있지만, 알아두면 헷갈리지 않습니다.
여러 핸들러
실제 코드에서는 여러 종류의 예외가 발생할 수 있습니다. 동일한 try-with에서 여러 예외 타입을 처리할 수 있고, 각 예외에 맞는 다른 응답을 제공할 수 있습니다:
$ cat exc_multi.l3
exception NotFound
exception Timeout of int
let result = try
raise (Timeout 30)
with
| NotFound -> "not found"
| Timeout secs -> "timeout after " + to_string secs + "s"
| _ -> "unknown"
$ fn exc_multi.l3
"timeout after 30s"
핸들러는 위에서 아래로 순서대로 시도됩니다. match 표현식과 동일한 규칙입니다. 첫 번째로 매칭되는 핸들러가 실행되므로, 더 구체적인 핸들러를 앞에, | _ -> 같은 포괄적인 핸들러를 뒤에 배치해야 합니다. 순서를 반대로 하면 구체적인 핸들러가 영원히 도달하지 못할 수 있습니다.
when 가드
때로는 같은 예외 타입이지만 데이터에 따라 다르게 처리해야 할 때가 있습니다. when 가드를 사용하면 패턴 매칭 후 추가 조건을 검사할 수 있습니다:
$ cat exc_guard.l3
exception Error of int
let result = try
raise (Error 42)
with
| Error x when x > 100 -> "big error"
| Error x -> "error: " + to_string x
| _ -> "unknown"
$ fn exc_guard.l3
"error: 42"
가드는 패턴이 매치된 후에 평가됩니다. 가드가 실패하면 다음 핸들러로 매칭이 계속됩니다.
42는 100보다 크지 않으므로 첫 번째 핸들러의 when x > 100 가드가 실패하고, 두 번째 핸들러 Error x로 넘어갑니다. 이 패턴은 “같은 예외지만 심각도에 따라 다르게 처리“하는 경우에 특히 유용합니다. HTTP 상태 코드처럼 숫자로 오류 코드를 넘길 때 범위별로 처리하는 코드를 when 가드로 깔끔하게 표현할 수 있습니다.
중첩된 try-with
예외는 발생한 위치에서 가장 가까운 핸들러로 이동합니다. 중첩된 try-with가 있으면 안쪽부터 먼저 시도합니다. 처리되지 않은 예외는 외부 핸들러로 전파됩니다:
$ cat exc_nested.l3
exception Inner
exception Outer
let result = try
try
raise Inner
with
| Outer -> "wrong"
| _ -> "inner caught"
with
| Inner -> "outer caught"
| _ -> "fallback"
$ fn exc_nested.l3
"inner caught"
내부 핸들러가 매치되지 않으면 예외가 외부로 전파됩니다. 내부의 raise를 raise Outer로 변경하면 내부 핸들러의 catch-all이 대신 매치됩니다.
이 예제에서 Inner 예외가 발생했을 때, 안쪽 try-with의 핸들러를 먼저 봅니다. Outer는 매칭 실패, _ -> "inner caught"는 모든 예외를 잡으므로 여기서 처리됩니다. 만약 안쪽에 | _ -> ...가 없었다면 Inner는 바깥쪽 try-with까지 전파되어 | Inner -> "outer caught"에 잡혔을 것입니다.
이런 전파 메커니즘 덕분에, 저수준 함수는 예외를 발생시키고 고수준 코드에서 한 번에 처리하는 구조를 만들 수 있습니다. 모든 중간 계층에서 오류를 전달하는 boilerplate가 필요 없습니다.
예외 재발생
어떤 핸들러도 매치되지 않으면 예외는 자동으로 재발생(re-raise)됩니다. 이것은 예외의 자동 전파 메커니즘입니다:
$ cat exc_reraise.l3
exception First
exception Second
let result = try
try
raise First
with
| Second -> "wrong"
| _ -> "inner fallback"
with
| First -> "outer caught first"
| _ -> "outer fallback"
$ fn exc_reraise.l3
"inner fallback"
First가 발생했을 때 안쪽 try-with를 보면, Second는 매칭 실패하지만 | _ -> 가 모든 것을 잡으므로 "inner fallback"이 반환됩니다. 만약 안쪽 | _ ->가 없었다면 First는 바깥쪽으로 전파되어 "outer caught first"가 되었을 것입니다.
중요한 점은, 핸들러가 하나도 매칭되지 않을 때 예외가 자동으로 재발생된다는 것입니다. 명시적으로 re-raise를 호출할 필요가 없습니다. 이 동작을 잘 이해하면 “이 레이어에서 처리할 예외“와 “상위 레이어로 올려보낼 예외“를 선택적으로 처리하는 구조를 설계할 수 있습니다.
비완전 핸들러 경고 (W0003)
예외는 개방 타입(open type)입니다. 새로운 예외를 어디서든 선언할 수 있기 때문에, 컴파일러는 현재 핸들러가 모든 가능한 예외를 커버하는지 정적으로 알 수 없습니다. 따라서 catch-all이 없는 핸들러에 대해 경고합니다:
$ cat exc_warn.l3
exception NotFound
let result = try
raise NotFound
with
| NotFound -> 42
$ fn exc_warn.l3
Warning: warning[W0003]: Non-exhaustive exception handler: not all exceptions are handled; consider adding a catch-all handler
--> :0:0-1:0
= hint: Add a catch-all handler or handle all possible exceptions
42
이 경고는 무시해도 코드가 동작하지만, 프로덕션 코드에서는 없애는 게 좋습니다. 미처 생각하지 못한 예외가 발생했을 때 프로그램이 조용히 죽는 것보다, catch-all에서 명시적으로 처리하는 것이 훨씬 안전합니다.
경고를 없애려면 | _ -> ...를 추가하세요:
$ cat exc_nowarn.l3
exception NotFound
let result = try
raise NotFound
with
| NotFound -> 42
| _ -> 0
$ fn exc_nowarn.l3
42
ADT의 패턴 매칭이 “닫힌 타입“에 대해 완전성을 보장할 수 있는 것과 달리, 예외는 “열린 타입“이라 항상 미지의 예외가 올 수 있습니다. | _ -> 0처럼 기본값을 제공하거나, | _ -> raise e처럼 잡은 예외를 다시 던지는 방법도 있습니다. 어떻게 처리할지는 상황에 따라 다르지만, 경고 자체를 무시하는 것은 피하세요.
실용 예제: 안전한 나눗셈
예외를 실제 코드에 적용하는 전형적인 사례입니다. 0으로 나누는 것은 수학적으로 정의되지 않으므로 예외로 처리합니다:
$ cat safe_div.l3
exception DivByZero
let safe_div a b =
if b = 0 then raise DivByZero
else a / b
let result = try
safe_div 10 0
with
| DivByZero -> -1
| _ -> -2
$ fn safe_div.l3
-1
safe_div 함수 자체는 예외 처리를 하지 않고 발생시키기만 합니다. 어떻게 처리할지는 호출하는 쪽의 맥락에 따라 다를 수 있기 때문입니다. 어떤 곳에서는 -1을 반환하고, 다른 곳에서는 0을 반환하거나, 또 다른 곳에서는 오류 메시지를 출력할 수 있습니다. 이렇게 발생과 처리를 분리하면 safe_div는 재사용 가능한 순수한 함수가 됩니다.
비교해보면, Option을 쓰는 방식 (if b = 0 then None else Some (a / b))은 오류를 값으로 표현해 타입에 드러냅니다. 호출하는 쪽이 None을 처리해야 한다는 것이 타입 시스템에 강제됩니다. 어느 방식을 택하느냐는 함수가 실패할 가능성이 얼마나 일상적인가에 달려있습니다. 빈번히 실패할 수 있는 연산이라면 Option이나 Result가 더 적합하고, 정말 예외적인 상황이라면 예외가 더 자연스럽습니다.
failwith 내장 함수
failwith는 문자열 메시지와 함께 예외를 발생시키는 내장 함수입니다. 간단한 오류 처리에 유용합니다:
$ cat failwith_demo.l3
let safeDivide a b =
if b = 0 then failwith "division by zero"
else a / b
let result =
try
safeDivide 10 0
with
| e -> 0
$ fn failwith_demo.l3
0
failwith는 커스텀 예외를 선언하지 않고도 빠르게 오류를 발생시킬 때 편리합니다. F#의 failwith와 동일합니다.
인라인 try-with
간단한 경우에는 try-with를 한 줄로 작성할 수 있습니다. 파이프 | 없이 바로 패턴과 핸들러를 쓸 수 있습니다:
$ cat inline_try.l3
let result = try failwith "boom" with e -> "caught"
$ fn inline_try.l3
"caught"
여러 핸들러가 필요하면 파이프를 사용하는 일반 형태를 쓰세요. 인라인 형태는 단일 catch-all 핸들러에 적합합니다.
구문 참고 사항
raise는 원자를 받음: 데이터를 가진 생성자에는 괄호를 사용:raise (Error msg)try들여쓰기:with핸들러의 파이프는match파이프와 같은 방식으로 정렬- 인라인 try-with:
try expr with ident -> expr형태로 한 줄 작성 가능 failwith:failwith "msg"로 예외를 빠르게 발생- 개방 타입: 예외 타입은 완전한 매칭이 불가능 (따라서 W0003 경고 발생)
- catch-all: 모든 예외를 포괄하려면 마지막 핸들러로
| _ -> ...를 추가
12장: 에러 처리 전략 (Error Handling Strategies)
프로그램에서 실패는 피할 수 없습니다. 파일이 없을 수 있고, 네트워크가 끊길 수 있고, 사용자가 잘못된 입력을 줄 수 있습니다. 문제는 “실패가 발생하는가?“가 아니라 “실패를 어떻게 표현하고 처리하는가?“입니다.
11장에서 예외(Exception)를 배웠습니다. 예외는 강력하지만, 함수의 시그니처만 봐서는 그 함수가 실패할 수 있는지 알 수 없다는 근본적인 한계가 있습니다. FunLang는 예외 외에도 Option과 Result라는 두 가지 타입 기반 접근법을 제공합니다. 이 세 가지 중 어떤 것을 선택하느냐에 따라 코드의 안전성, 합성 가능성, 가독성이 크게 달라집니다.
이 장은 같은 문제를 세 가지 방식으로 풀어보고, 각각의 장단점을 비교한 뒤, 실전에서의 선택 기준을 제시합니다. 함수형 프로그래밍 커뮤니티에서 왜 “예외보다 타입을 써라“라고 말하는지, 그 이유를 직접 확인하게 됩니다.
세 가지 접근법 비교
같은 문제를 세 가지 방식으로 풀어보겠습니다: 리스트에서 조건을 만족하는 첫 번째 원소를 찾는 함수입니다.
Exception 방식
$ cat find_exc.l3
exception NotFound
// 조건을 만족하는 첫 번째 원소를 찾고, 없으면 예외 발생
let rec find pred = fun xs ->
match xs with
| [] -> raise NotFound
| h :: t -> if pred h then h else find pred t
let result = try find (fun x -> x > 3) [1; 2; 3; 4; 5] with | NotFound -> 0 - 1
$ fn find_exc.l3
4
Option 방식
$ cat find_opt.l3
// 조건을 만족하는 첫 번째 원소를 Option으로 반환
let rec find pred = fun xs ->
match xs with
| [] -> None
| h :: t -> if pred h then Some h else find pred t
let result = optionDefault (0 - 1) (find (fun x -> x > 3) [1; 2; 3; 4; 5])
$ fn find_opt.l3
4
Result 방식
$ cat find_res.l3
// 조건을 만족하는 첫 번째 원소를 Result로 반환
let rec find pred = fun xs ->
match xs with
| [] -> Error "not found"
| h :: t -> if pred h then Ok h else find pred t
let result = resultDefault (0 - 1) (find (fun x -> x > 3) [1; 2; 3; 4; 5])
$ fn find_res.l3
4
세 가지 모두 같은 결과를 반환합니다. 그러나 코드의 의미와 안전성에서 큰 차이가 있습니다.
왜 Option/Result를 권장하는가
1. 타입 시스템이 실패를 표현한다
Exception은 타입 시그니처에 나타나지 않습니다:
// Exception: 타입만 보면 실패 가능성을 알 수 없음
// findIdx : ('a -> bool) -> 'a list -> int -> int
// ^^^
// 실패 시 예외를 던지지만, 타입에는 int만 보임!
// Option: 타입이 실패 가능성을 명시
// findIdx : ('a -> bool) -> 'a list -> int -> Option<int>
// ^^^^^^^^^^^
// None이 올 수 있다는 것을 호출자가 알 수 있음
// Result: 타입이 실패 이유까지 명시
// findIdx : ('a -> bool) -> 'a list -> int -> Result<int, string>
// ^^^^^^^^^^^^^^^^^^^^
// 에러 메시지가 포함될 수 있음을 호출자가 알 수 있음
Option이나 Result를 사용하면, 호출자가 반드시 실패 경우를 처리해야 합니다.
패턴 매칭에서 None이나 Error를 처리하지 않으면 소진 경고가 나옵니다.
Exception은 호출자가 try-with를 잊으면 프로그램이 크래시합니다.
2. 합성(composition)이 자연스럽다
Exception은 합성이 어렵습니다. 여러 실패 가능한 연산을 순서대로 실행하려면
중첩된 try-with가 필요합니다:
$ cat chain_exc.l3
exception ParseError of string
exception DivError of string
let parseInt s =
match s with
| "42" -> 42
| "0" -> 0
| _ -> raise (ParseError ("invalid: " + s))
let safeDivide a = fun b -> if b = 0 then raise (DivError "div/0") else a / b
let compute input = try
let n = parseInt input
try
safeDivide 100 n
with
| DivError msg -> 0 - 1
with
| ParseError msg -> 0 - 2
let r1 = compute "42"
let r2 = compute "0"
let r3 = compute "abc"
let result = (r1, r2, r3)
$ fn chain_exc.l3
(2, -1, -2)
Result를 사용하면 파이프라인으로 합성할 수 있습니다:
$ cat chain_res.l3
let parseInt s =
match s with
| "42" -> Ok 42
| "0" -> Ok 0
| _ -> Error ("invalid: " + s)
let safeDivide a = fun b -> if b = 0 then Error "div/0" else Ok (a / b)
let compute input = parseInt input |> resultBind (safeDivide 100) |> resultMap (fun x -> x + 1)
let r1 = compute "42"
let r2 = compute "0"
let r3 = compute "abc"
let result = (r1, r2, r3)
$ fn chain_res.l3
(Ok 3, Error "div/0", Error "invalid: abc")
resultBind와 resultMap으로 에러가 자동 전파됩니다:
parseInt "abc"→Error "invalid: abc"→resultBind가safeDivide를 건너뛰고 에러 전파- 에러가 발생한 정확한 지점의 메시지가 보존됩니다
3. 예외는 제어 흐름을 깨뜨린다
Exception은 비지역적(non-local) 제어 흐름입니다. raise는 현재 함수를
즉시 벗어나 가장 가까운 try-with까지 스택을 풀어올립니다. 이로 인해:
- 어디서 예외가 발생했는지 추적이 어렵습니다
- 중간 정리(cleanup) 코드가 실행되지 않을 수 있습니다
- 꼬리 호출 최적화(TCO)가
try본문에서 동작하지 않습니다
Option/Result는 일반적인 값입니다. 함수가 None이나 Error를 반환하면,
호출자는 평범한 패턴 매칭으로 처리합니다. 제어 흐름이 예측 가능합니다.
4. TCO와 함께 사용할 수 있다
try 본문은 꼬리 위치가 아닙니다 (예외 핸들러가 스택 프레임을 필요로 하기 때문).
따라서 try 안에서 깊은 재귀를 하면 스택 오버플로우가 발생할 수 있습니다.
Option/Result를 사용하면 재귀 함수가 try 없이 동작하므로 TCO가 적용됩니다:
$ cat tco_result.l3
let rec searchList pred = fun xs -> fun i ->
match xs with
| [] -> Error "not found"
| h :: t -> if pred h then Ok i else searchList pred t (i + 1)
let result = searchList (fun x -> x = 999999) [1..1000000] 0
$ fn tco_result.l3
Ok 999998
100만 개의 리스트를 검색해도 스택 오버플로우 없이 동작합니다.
Option vs Result: 언제 어떤 것을 쓰는가
Option — 실패 이유가 중요하지 않을 때
“값이 있거나 없거나“만 구분하면 될 때 Option을 사용합니다:
$ cat option_use.l3
let safeHead xs =
match xs with
| [] -> None
| h :: _ -> Some h
let safeDivide a = fun b -> if b = 0 then None else Some (a / b)
let result = Some [10; 20; 30] |> optionBind safeHead |> optionBind (safeDivide 100) |> optionDefault 0
$ fn option_use.l3
10
적합한 경우:
- 리스트에서 원소 찾기 (
find) - 맵에서 키 검색
- 파싱이 성공하거나 실패하거나 (이유 불필요)
- null 대체 (nullable 값)
<|> 연산자를 사용하면 fallback 체인을 더 간결하게 작성할 수 있습니다:
$ cat fallback.l3
let safeHead xs =
match xs with
| [] -> None
| h :: _ -> Some h
let safeDivide a = fun b -> if b = 0 then None else Some (a / b)
let result = safeDivide 10 0 <|> safeDivide 10 2 <|> Some 0
$ fn fallback.l3
Some 5
<|>는 첫 번째 Some 값을 반환하고, 모두 None이면 마지막 값을 반환합니다.
Result — 실패 이유가 중요할 때
에러 메시지나 에러 코드를 보존해야 할 때 Result를 사용합니다:
$ cat result_use.l3
let validateAge age = if age < 0 then Error "age cannot be negative" else if age > 150 then Error "age too large" else Ok age
let validateName name = if String.length name = 0 then Error "name cannot be empty" else Ok name
let validate name = fun age -> validateName name |> resultBind (fun _ -> validateAge age |> resultMap (fun a -> name + " (" + to_string a + ")"))
let r1 = validate "Alice" 30
let r2 = validate "" 25
let r3 = validate "Bob" (0 - 5)
let result = (r1, r2, r3)
$ fn result_use.l3
(Ok "Alice (30)", Error "name cannot be empty", Error "age cannot be negative")
적합한 경우:
- 사용자 입력 검증
- 파일/네트워크 작업 (에러 메시지 필요)
- 여러 단계의 처리 파이프라인
- API 응답 (에러 코드 + 메시지)
Exception — 정말 예외적인 상황에만
Exception은 프로그래밍 오류나 복구 불가능한 상황에 사용합니다:
$ cat exception_use.l3
exception InternalError of string
let rec processAll xs =
match xs with
| [] -> 0
| h :: t ->
if h < 0 then raise (InternalError "unexpected negative value")
else h + processAll t
let result = processAll [1; 2; 3; 4; 5]
$ fn exception_use.l3
15
적합한 경우:
- 프로그래밍 오류 (불변 조건 위반)
- 복구 불가능한 시스템 오류
- 방어적 프로그래밍 (도달할 수 없는 코드에 대한 안전장치)
Prelude 함수 요약
-- Option 함수
optionMap : ('a -> 'b) -> Option<'a> -> Option<'b>
optionBind : ('a -> Option<'b>) -> Option<'a> -> Option<'b>
optionDefault : 'a -> Option<'a> -> 'a
optionDefaultValue: 'a -> Option<'a> -> 'a -- optionDefault와 동일
optionFilter : ('a -> bool) -> Option<'a> -> Option<'a>
optionIter : ('a -> unit) -> Option<'a> -> unit
isSome : Option<'a> -> bool
isNone : Option<'a> -> bool
-- Result 함수
resultMap : ('a -> 'b) -> Result<'a, 'e> -> Result<'b, 'e>
resultBind : ('a -> Result<'b, 'e>) -> Result<'a, 'e> -> Result<'b, 'e>
resultMapError : ('e -> 'f) -> Result<'a, 'e> -> Result<'a, 'f>
resultDefault : 'a -> Result<'a, 'e> -> 'a
resultDefaultValue: 'a -> Result<'a, 'e> -> 'a -- resultDefault와 동일
resultIter : ('a -> unit) -> Result<'a, 'e> -> unit
resultToOption : Result<'a, 'e> -> Option<'a>
isOk : Result<'a, 'e> -> bool
isError : Result<'a, 'e> -> bool
권장 가이드라인
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 값이 있을 수도 없을 수도 있는 경우 | Option | 간단, 가벼움 |
| 실패 이유를 알아야 하는 경우 | Result | 에러 메시지 보존 |
| 여러 연산을 순서대로 합성 | Result + |> | 파이프라인 합성 |
| 프로그래밍 오류/불변 조건 위반 | Exception | 즉시 중단, 디버깅 |
| 깊은 재귀 내부 에러 처리 | Option/Result | TCO 호환 |
| 최상위 레벨 에러 복구 | Exception + try-with | 프로그램 크래시 방지 |
일반 원칙:
- 기본값은 Option 또는 Result를 사용하세요
- Exception은 마지막 수단으로만 사용하세요
- 타입이 실패 가능성을 말해주게 하세요 — 호출자가 놓치지 않도록
13장: 사용자 정의 연산자 (User-Defined Operators)
프로그래밍 언어가 제공하는 내장 연산자들은 범용적으로 설계되어 있습니다. +는 숫자를 더하고, =는 값을 비교합니다. 하지만 실제 코드를 쓰다 보면 “이 두 리스트를 이어붙이는 연산을 append xs ys 대신 xs ++ ys로 쓸 수 있으면 얼마나 좋을까“라는 생각이 자연스럽게 드는 순간이 있습니다.
사용자 정의 연산자는 바로 그 생각을 실현시켜주는 도구입니다. 함수 호출의 의미를 유지하면서, 코드가 마치 그 도메인의 고유한 언어처럼 읽히게 만들 수 있습니다. 파서 조합기 라이브러리에서 <|>로 대안을 표현하거나, 수식 라이브러리에서 **로 거듭제곱을 표현하는 것처럼, 연산자는 코드와 개념 사이의 거리를 좁혀줍니다.
FunLang는 기호 문자로 구성된 사용자 정의 중위 연산자를 지원합니다. 이를 통해 DSL(도메인 특화 언어)을 만들거나, 반복적인 함수 호출을 간결한 표현으로 대체할 수 있습니다.
연산자 정의하기
let (op) a b = body 구문으로 사용자 정의 중위 연산자를 선언합니다:
$ cat op_basic.l3
let (++) xs ys = append xs ys
let result = [1; 2] ++ [3; 4]
$ fn op_basic.l3
[1; 2; 3; 4]
눈여겨볼 점은 정의할 때는 (++) 형태로 괄호 안에 연산자를 쓰지만, 사용할 때는 xs ++ ys처럼 중위 형태로 자연스럽게 쓸 수 있다는 것입니다. 이는 F#과 OCaml이 공유하는 관습입니다.
연산자 이름은 기호 문자(! $ % & * + - . / < = > ? @ ^ | ~)의 조합입니다. 최소 2문자 이상이어야 합니다 (단일 문자 +, * 등은 내장 연산자).
단일 문자 연산자를 재정의할 수 없는 이유는 간단합니다. +의 의미를 바꾸는 순간 코드를 읽는 사람은 그 파일 안에서 +가 어떤 의미인지 항상 확인해야 합니다. 기존 연산자의 의미를 유지하는 것은 코드 가독성의 기본입니다.
let rec으로 재귀 연산자도 정의할 수 있습니다.
우선순위 규칙
연산자를 직접 정의할 수 있게 되면 곧 이런 질문이 생깁니다: a ++ b ** c처럼 여러 연산자가 섞이면 어떤 순서로 계산될까요?
FunLang는 OCaml에서 물려받은 우아한 해답을 씁니다. 연산자를 직접 파싱해서 우선순위를 결정하지 않고, 연산자의 첫 번째 문자만으로 우선순위 레벨을 결정합니다. 덕분에 여러분이 만드는 모든 연산자는 그 첫 글자를 통해 자동으로 적절한 우선순위 그룹에 속하게 됩니다.
연산자의 우선순위는 첫 번째 문자로 결정됩니다 (F#/OCaml 규칙):
| 레벨 | 첫 문자 | 결합성 | 예제 |
|---|---|---|---|
| INFIXOP0 (낮음) | = < > | & $ ! | 좌결합 | <|>, ===, != |
| INFIXOP1 | @ ^ | 우결합 | ^^, @> |
| INFIXOP2 | + - | 좌결합 | ++, +. |
| INFIXOP3 | * / % | 좌결합 | */, %% |
| INFIXOP4 (높음) | ** | 우결합 | **, *** |
같은 레벨의 연산자는 같은 우선순위를 가집니다. 예: ++ (INFIXOP2)는 +와 같은 우선순위, <|> (INFIXOP0)는 비교 연산자와 같은 우선순위.
이 체계가 갖는 실용적인 의미를 생각해봅시다. <|> 연산자를 만들면 그것은 자동으로 INFIXOP0, 즉 비교 연산자(<, >, =)와 같은 낮은 우선순위를 가집니다. 이는 직관적으로도 맞습니다. a < b <|> c < d라는 표현을 쓸 때 <가 먼저 평가되길 기대하는 것이 자연스럽기 때문입니다.
반면 ++는 INFIXOP2이므로 +와 같은 우선순위입니다. 수학적으로도 덧셈 수준의 연산이라는 뜻이고, x ++ y ** z에서 **가 먼저 결합됩니다 (INFIXOP4가 더 높으므로).
결합성도 중요합니다. ^^는 ^로 시작하므로 INFIXOP1이고, 우결합(right-associative)입니다. "a" ^^ "b" ^^ "c"는 "a" ^^ ("b" ^^ "c")로 파싱됩니다. 문자열 연결에서 우결합이 유리한 이유는, 오른쪽에서 차곡차곡 쌓는 방식이 임시 문자열 생성을 줄이기 때문입니다.
연산자를 함수로 사용하기
연산자가 단순히 “중위 문법“이기만 하다면 그다지 강력하지 않을 것입니다. FunLang 연산자의 진가는 다시 일반 함수처럼 다룰 수 있다는 데 있습니다.
괄호로 감싸면 연산자를 일반 함수처럼 사용할 수 있습니다:
$ cat op_as_func.l3
let (++) xs ys = append xs ys
let result = fold (++) [] [[1; 2]; [3]; [4; 5]]
$ fn op_as_func.l3
[1; 2; 3; 4; 5]
(++)는 fun xs -> fun ys -> append xs ys와 동일합니다.
fold (++) []는 리스트들의 리스트를 하나의 리스트로 평탄화합니다. (++)를 fold의 첫 번째 인자로 바로 넘길 수 있다는 것은, 연산자가 1급 값(first-class value)임을 의미합니다. Haskell에서 foldr (++) []와 정확히 같은 방식입니다.
이 패턴은 map, filter, fold와 조합할 때 특히 강력합니다. 연산자를 정의하면 함수의 세계와 연산자의 세계 사이를 자유롭게 오갈 수 있습니다.
Prelude 연산자
FunLang를 시작하는 순간부터 바로 쓸 수 있는 세 개의 연산자가 Prelude에 정의되어 있습니다. 이 세 연산자는 각각 다른 우선순위 레벨에 걸쳐 있어서, 연산자 분류 체계를 이해하는 데도 좋은 예시입니다.
| 연산자 | 타입 | 설명 | 예제 |
|---|---|---|---|
++ | 'a list -> 'a list -> 'a list | 리스트 연결 | [1;2] ++ [3;4] → [1;2;3;4] |
<|> | Option<'a> -> Option<'a> -> Option<'a> | Option 대안 | None <|> Some 42 → Some 42 |
^^ | string -> string -> string | 문자열 연결 | "a" ^^ "b" → "ab" |
<|>는 Haskell의 <|> (Alternative)와 같은 개념입니다. “왼쪽이 None이면 오른쪽을 시도한다“는 폴백(fallback) 패턴을 체인으로 연결할 때 코드가 훨씬 읽기 좋아집니다.
실용 예제
리스트 정렬 (quicksort with ++)
퀵정렬은 ++를 사용하면 알고리즘의 구조가 코드에 그대로 드러납니다. “피벗보다 작은 것들을 정렬한 결과” 더하기 “[피벗]” 더하기 “피벗보다 크거나 같은 것들을 정렬한 결과“라는 설명이 코드와 일대일로 대응합니다.
$ cat qsort_op.l3
let rec qsort xs =
match xs with
| [] -> []
| p :: rest ->
qsort (filter (fun x -> x < p) rest) ++ [p] ++ qsort (filter (fun x -> x >= p) rest)
let result = qsort [5; 3; 8; 1; 9; 2; 7]
$ fn qsort_op.l3
[1; 2; 3; 5; 7; 8; 9]
append 함수를 직접 썼다면 중첩된 호출이 알고리즘의 흐름을 가렸을 것입니다. ++를 쓰면 세 부분의 연결이 왼쪽에서 오른쪽으로 자연스럽게 읽힙니다.
문자열 포매팅
^^는 문자열 연결을 파이프라인과 자연스럽게 조합할 수 있게 해줍니다. 복잡한 문자열 포매팅 로직을 함수로 캡슐화하고, 그것을 파이프라인의 끝에 붙이는 패턴은 FunLang에서 자주 나타납니다.
$ cat format_op.l3
// 리스트를 "[1, 2, 3]" 형태의 문자열로 변환
let formatList xs = "[" ^^ fold (fun acc -> fun x -> if acc = "" then to_string x else acc ^^ ", " ^^ to_string x) "" xs ^^ "]"
let result = [1..5] |> filter (fun x -> x > 2) |> formatList
$ fn format_op.l3
"[3, 4, 5]"
여기서 ^^의 우결합성이 "[" ^^ ... ^^ "]" 전체를 올바른 순서로 결합시켜줍니다.
Option fallback 체인
<|>의 진가는 여러 소스에서 값을 시도할 때 드러납니다. 여러 파싱 전략을 순서대로 시도하고, 처음 성공한 결과를 택하는 이 패턴은 실제 파서나 설정 읽기 코드에서 자주 등장합니다.
$ cat fallback_op.l3
let tryParse s =
match s with
| "42" -> Some 42
| "0" -> Some 0
| _ -> None
let result = tryParse "abc" <|> tryParse "xyz" <|> tryParse "42" <|> Some 0
$ fn fallback_op.l3
Some 42
tryParse "abc"는 None을 반환하므로 <|>가 다음으로 넘어갑니다. tryParse "xyz"도 None이고, tryParse "42"에서 드디어 Some 42가 나오므로 여기서 체인이 멈춥니다. 만약 모든 시도가 실패했다면 마지막 Some 0이 기본값으로 사용됩니다.
이것을 if-else나 match로 쓰면 들여쓰기가 깊어지거나 임시 변수가 늘어납니다. <|>는 그 구조를 평평하게 만들어줍니다.
나만의 연산자 정의
어떤 연산자를 만들지는 도메인과 용도에 따라 달라집니다. 여기서 =?는 비교를 문자열로 변환하는 연산자입니다. 테스트나 디버깅 출력을 생성할 때 유용할 수 있습니다.
$ cat custom_op.l3
let (=?) a b = if a = b then "equal" else "not equal"
let r1 = 1 =? 1
let r2 = 1 =? 2
let result = r1 ^^ ", " ^^ r2
$ fn custom_op.l3
"equal, not equal"
=?는 =로 시작하므로 INFIXOP0입니다. 비교 연산자와 같은 낮은 우선순위를 가집니다. a + b =? c + d라고 쓰면 덧셈이 먼저 계산되고 그 결과를 =?로 비교합니다. 직관적으로 맞는 동작입니다.
연산자를 쓸 때와 쓰지 말아야 할 때
연산자를 정의할 수 있다고 해서 항상 정의해야 하는 것은 아닙니다. 연산자는 그것이 나타내는 연산이 해당 도메인에서 자주 사용되고, 수학적이거나 조합적인 의미가 명확할 때 빛납니다.
연산자가 도움이 되는 경우는 코드가 도메인 전문가도 읽을 수 있을 만큼 표현이 명확해질 때입니다. xs ++ ys는 수학의 집합 연결 표기와 유사하고, a <|> b는 논리합의 변형처럼 읽힙니다.
반면 연산자가 혼란을 주는 경우는 이름이 그 의미를 짐작하기 어려울 때입니다. a >>= b나 a ==> b 같은 연산자는 Haskell 배경이 없는 독자에게는 수수께끼입니다. 그런 경우에는 bind a b나 implies a b처럼 이름이 있는 함수가 낫습니다.
좋은 기준은 이렇습니다: 그 연산자를 처음 보는 팀원이 컨텍스트를 보고 30초 안에 의미를 짐작할 수 있다면 쓰세요. 그렇지 않다면 함수를 쓰세요.
Fixity 속성: 우선순위와 결합성 직접 지정
기본 규칙(첫 번째 문자 기반)으로 충분하지 않을 때가 있습니다. 예를 들어 파이프 연산자 |>는 |로 시작하므로 기본적으로 INFIXOP0(비교 수준)에 배치되지만, 실제로는 가장 낮은 우선순위를 가져야 합니다. FunLang의 fixity 속성은 이런 상황에서 연산자의 우선순위와 결합성을 직접 지정할 수 있게 합니다.
#[left N] 또는 #[right N] 형태의 속성을 연산자 정의 바로 앞에 붙입니다:
$ cat fixity_attr.l3
#[left 6]
let ($>) x f = f x
let double x = x * 2
let result = 5 $> double
let _ = println (to_string result)
$ fn fixity_attr.l3
10
여기서 #[left 6]은 $> 연산자를 좌결합, 우선순위 레벨 6(덧셈 수준)으로 지정합니다. 속성이 없었다면 $로 시작하므로 INFIXOP0(레벨 4)이 되었을 것입니다.
Fixity 레벨
| 레벨 | 대응하는 기본 연산자 |
|---|---|
| 1 | |>, <| (파이프) |
| 2 | >>, << (합성) |
| 3 | || (논리 OR) |
| 4 | INFIXOP0 (비교 수준) |
| 5 | INFIXOP1, :: (연결 수준) |
| 6 | +, -, INFIXOP2 (덧셈 수준) |
| 7 | *, /, %, INFIXOP3 (곱셈 수준) |
| 8 | INFIXOP4 (지수 수준) |
결합성 제어
#[right N]으로 기본 좌결합 연산자를 우결합으로 변경할 수 있습니다:
$ cat fixity_right.l3
#[right 6]
let (+->) a b = a + b
let result = 1 +-> 2 +-> 3
let _ = println (to_string result)
$ fn fixity_right.l3
6
#[right 6]으로 선언했으므로 1 +-> 2 +-> 3은 1 +-> (2 +-> 3) = 1 +-> 5 = 6으로 계산됩니다. 기본적으로 +로 시작하는 연산자는 좌결합이지만, fixity 속성으로 우결합으로 변경했습니다.
Prelude의 활용
Prelude의 |>, <|, >>, << 연산자도 이 fixity 속성으로 정의되어 있습니다. Prelude/Core.fun을 열어보면:
#[left 1]
let (|>) x f = f x
#[right 1]
let (<|) f x = f x
#[right 2]
let (>>) f g = fun x -> g (f x)
#[left 2]
let (<<) f g = fun x -> f (g x)
이처럼 fixity 속성은 특별한 컴파일러 마법이 아니라, 모든 사용자가 동일하게 활용할 수 있는 일반적인 메커니즘입니다.
주의 사항
(*는 블록 코멘트 시작이므로,*로 시작하는 연산자를 함수로 사용할 때는 공백이 필요합니다:( ** )(not(**)).- 단일 문자 연산자 (
+,*,=등)는 재정의할 수 없습니다. 이들은 내장 연산자입니다. - 연산자 이름은 2문자 이상이어야 합니다.
14장: GADT (일반화된 대수적 데이터 타입)
5장에서 대수적 데이터 타입(ADT)을 배웠습니다. ADT만으로도 대부분의 데이터 모델링은 충분합니다. 하지만 특정 상황에서 “타입 시스템이 더 많은 것을 보장해줬으면 좋겠다“는 생각이 드는 순간이 옵니다. GADT(Generalized Algebraic Data Types, 일반화된 대수적 데이터 타입)는 바로 그 지점에서 등장합니다.
이 장은 다른 장들보다 추상적인 내용을 다룹니다. 처음 읽을 때 모든 것이 한 번에 이해되지 않아도 괜찮습니다. 예제를 직접 실행해보면서, “일반 ADT로는 왜 안 되는가?“라는 질문에 초점을 맞추면 GADT의 존재 이유가 자연스럽게 보일 것입니다.
ADT의 한계: 왜 GADT가 필요한가
먼저 일반 ADT가 어디까지 할 수 있고, 어디서 한계에 부딪히는지 살펴봅시다.
ADT 복습
5장에서 배운 ADT는 “이 값은 A이거나 B이거나 C다“라는 합 타입(sum type)을 정의합니다:
type Shape =
| Circle of int
| Rect of int * int
Circle 5와 Rect (3, 4) 모두 Shape 타입입니다. 패턴 매칭으로 어떤 생성자인지 확인하고 내부 데이터를 꺼낼 수 있습니다. 이것만으로도 트리, 리스트, 옵션 같은 재귀적 데이터 구조를 표현하기에 충분합니다.
매개변수화된 ADT도 가능합니다:
type Option 'a =
| None
| Some of 'a
여기서 'a는 타입 매개변수입니다. Some 42는 Option<int>이고 Some "hello"는 Option<string>입니다. 하지만 핵심적인 제약이 있습니다: None과 Some 모두 정확히 같은 Option<'a> 타입을 만들어냅니다. 생성자가 타입 매개변수 'a를 특정 타입으로 고정할 수 없습니다.
문제 상황: 타입이 섞이는 표현식 언어
작은 수식 언어를 만든다고 생각해봅시다. 정수 리터럴과 불리언 리터럴, 그리고 덧셈을 지원하고 싶습니다.
일반 ADT로 시도해봅니다:
type Expr =
| IntLit of int
| BoolLit of bool
| Add of Expr * Expr
이 정의에는 치명적인 문제가 있습니다. Add (IntLit 1, BoolLit true)가 타입 오류 없이 만들어집니다. 정수와 불리언을 더하는 것은 의미가 없지만, 타입 시스템은 이를 막지 못합니다 — IntLit 1과 BoolLit true 모두 Expr 타입이니까요.
eval 함수도 문제입니다:
// 반환 타입이 뭐가 되어야 하나? int? bool?
let eval e = match e with
| IntLit n -> ???
| BoolLit b -> ???
| Add (a, b) -> ???
IntLit n에서는 int를, BoolLit b에서는 bool을 반환하고 싶지만, 함수의 반환 타입은 하나여야 합니다. 결국 별도의 Value 유니온을 만들어야 합니다:
type Value =
| IntVal of int
| BoolVal of bool
그리고 eval (IntLit 42)는 IntVal 42를 반환합니다. 이걸 정수로 쓰려면 다시 패턴 매칭을 해야 합니다. 래핑하고 언래핑하는 보일러플레이트가 계속 쌓입니다.
핵심 문제를 정리하면:
- 타입 시스템이 잘못된 표현(
Add(IntLit, BoolLit))을 막지 못합니다 - 평가 결과의 타입이 생성자에 따라 달라져야 하는데, ADT로는 이를 표현할 수 없습니다
- 유니온 래핑/언래핑이라는 런타임 보일러플레이트가 필요합니다
GADT란 무엇인가
GADT는 이 세 가지 문제를 모두 해결합니다. 핵심 아이디어는 단순합니다: 각 생성자가 타입 매개변수를 구체적인 타입으로 고정할 수 있다.
일반 ADT에서는 모든 생성자가 동일한 'a를 공유합니다:
// 일반 ADT — 모든 생성자가 Option<'a>를 만듦
type Option 'a =
| None
| Some of 'a
// None : Option<'a> ('a가 뭔지 모름)
// Some x : Option<'a> ('a가 x의 타입)
GADT에서는 각 생성자가 'a를 특정 타입으로 고정합니다:
// GADT — 각 생성자가 다른 타입의 Expr을 만듦
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
// IntLit 42 : int Expr ('a = int으로 고정)
// BoolLit true : bool Expr ('a = bool로 고정)
IntLit은 반드시 int Expr을, BoolLit은 반드시 bool Expr을 만듭니다. 'a가 “어떤 타입이든 될 수 있는 변수“가 아니라, 생성자마다 구체적인 타입으로 결정됩니다.
이것이 가능해지면, 패턴 매칭에서 **타입 정제(type refinement)**라는 강력한 능력이 생깁니다.
GADT 생성자 구문
각 생성자는 :와 ->를 사용하여 인자 타입과 반환 타입을 선언합니다:
$ cat expr.l3
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
let result = IntLit 42
$ fn expr.l3
IntLit 42
일반 ADT 구문과 비교해봅시다:
| 일반 ADT | GADT | |
|---|---|---|
| 구문 | IntLit of int | IntLit : int -> int Expr |
| 의미 | “int를 받아 'a Expr 생성” | “int를 받아 int Expr 생성” |
| 타입 매개변수 | 모든 생성자가 같은 'a | 각 생성자가 'a를 고정 |
IntLit : int -> int Expr를 읽는 방법: “int를 받아서 int Expr을 돌려주는 함수”. 생성자를 일종의 타입이 정해진 함수로 보는 관점입니다. Haskell에서도 같은 시각을 씁니다.
타입 매개변수는 타입 이름 뒤에 위치합니다: type Expr 'a. 반환 타입은 int Expr 형태입니다 (Expr int이나 Expr<int>가 아님).
중요: 하나의 타입 선언에서 한 생성자라도 GADT 구문(: ... -> ...)을 쓰면, 모든 생성자가 GADT로 취급됩니다. 일반 ADT 구문(of)과 GADT 구문(:)을 섞어 쓸 수 없습니다.
타입 정제 (Type Refinement)
GADT의 진짜 힘은 패턴 매칭에서 드러납니다. 생성자를 매치하는 순간, 컴파일러는 타입 매개변수가 무엇인지 정확히 알게 됩니다. 이것을 타입 정제라고 합니다.
먼저 : int 주석을 사용하여 모든 분기가 int를 반환하도록 강제하는 예부터 봅시다. (주석 없이 분기마다 다른 타입을 반환하는 다형적 버전은 “다형적 반환 타입” 절에서 다룹니다.)
$ cat eval.l3
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
let eval e =
(match e with
| IntLit n -> n
| BoolLit b -> if b then 1 else 0
: int)
let result = eval (IntLit 42)
$ fn eval.l3
42
이 코드에서 일어나는 일을 단계별로 추적해봅시다:
eval이e를 받습니다.e의 타입은'a Expr— 아직'a가 뭔지 모릅니다match e with— 패턴 매칭 시작IntLit n분기에 진입: 컴파일러는IntLit이int -> int Expr생성자임을 압니다. 따라서 이 분기 안에서'a = int이 확정됩니다.n은 반드시int입니다BoolLit b분기에 진입: 마찬가지로'a = bool이 확정됩니다.b는 반드시bool입니다- 각 분기는 독립적으로 타입이 정제됩니다 —
IntLit분기의'a = int이BoolLit분기에 영향을 주지 않습니다
일반 ADT였다면 컴파일러는 3번과 4번 단계에서 “그냥 int와 bool“밖에 모릅니다. 하지만 GADT에서는 “이 분기에서 'a가 int로 확정되었으므로, n이 int이고, 이 분기의 반환값도 int와 호환되어야 한다“는 추론까지 합니다.
타입 주석의 역할
GADT match에서 타입 주석은 선택사항입니다. 주석의 존재 여부와 종류에 따라 동작이 달라집니다:
주석 없음 (권장 — 다형적 반환) 주석 없이 match를 쓰면 컴파일러가 결과 타입을 자동으로 추론합니다. 각 분기는 독립적으로 정제됩니다:
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
let eval e =
match e with
| IntLit n -> n
| BoolLit b -> b
let r1 = eval (IntLit 42) // r1 : int = 42
let r2 = eval (BoolLit true) // r2 : bool = true
구체적 타입 주석 : int (단일 타입 강제)
구체적 타입을 지정하면 모든 분기가 그 타입을 반환해야 합니다. 재귀 평가기처럼 반환 타입을 하나로 고정하고 싶을 때 사용합니다:
let rec eval e =
(match e with
| IntLit n -> n
| BoolLit b -> if b then 1 else 0
: int)
: int 주석은 match의 끝에, 괄호 안에 위치합니다. 이 주석이 있으면 컴파일러는 **검사 모드(check mode)**에 진입하여, 모든 분기가 int를 반환하는지 확인합니다. BoolLit b 분기에서 b는 bool이지만 if b then 1 else 0으로 int를 반환하므로 타입이 맞습니다.
주석 없이 쓰면 다형적 반환이 되고, 구체적 주석을 쓰면 단일 타입으로 강제됩니다. 대부분의 경우 주석 없이 쓰는 것이 더 유연합니다. 재귀 평가기(let rec eval)처럼 반환 타입이 반드시 하나여야 하는 경우에만 구체적 주석을 사용하세요.
재귀 GADT
실용적인 표현식 언어는 중첩이 가능해야 합니다. 1 + (2 + 3) 같은 표현을 위해 재귀 생성자가 필요합니다:
$ cat calc.l3
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
| Add : int Expr * int Expr -> int Expr
let result =
let rec eval e =
(match e with
| IntLit n -> n
| BoolLit b -> if b then 1 else 0
| Add (a, b) -> eval a + eval b
: int)
eval (Add (IntLit 10, Add (IntLit 20, IntLit 12)))
$ fn calc.l3
42
Add : int Expr * int Expr -> int Expr의 의미를 잘 보세요:
- 두 인자 모두 **
int Expr**이어야 합니다 (단순한'a Expr이 아님) - 결과도
int Expr입니다
이것이 ADT와의 결정적 차이입니다. ADT에서 Add of Expr * Expr이라고 쓰면 Add (IntLit 1, BoolLit true)가 가능합니다. GADT에서 Add : int Expr * int Expr -> int Expr이라고 쓰면 Add (IntLit 1, BoolLit true)는 타입 오류입니다 — BoolLit true는 bool Expr이지 int Expr이 아니니까요.
컴파일 시점에 잘못된 표현을 차단한다는 것은, 런타임에 “정수와 불리언을 더할 수 없습니다” 같은 에러를 처리하는 코드가 필요 없다는 뜻입니다. 파이썬 같은 동적 언어에서는 이런 검사를 직접 구현해야 했을 것입니다.
다형적 반환 타입
가장 강력한 GADT 패턴 중 하나는 함수의 반환 타입이 입력 GADT의 타입 매개변수와 일치하는 것입니다. OCaml에서 eval : 'a expr -> 'a로 알려진 패턴입니다.
$ cat poly-eval.l3
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
let eval e =
match e with
| IntLit n -> n
| BoolLit b -> b
let _ = printf "%d\n" (eval (IntLit 42))
let result = eval (BoolLit true)
$ fn poly-eval.l3
42
true
eval의 타입은 'a Expr -> 'a입니다. eval (IntLit 42)는 int를 반환하므로 printf "%d"로 출력하고, eval (BoolLit true)는 bool을 반환합니다. 같은 함수가 입력의 GADT 타입 매개변수에 따라 다른 타입을 반환합니다. 이것이 일반 ADT로는 불가능한, GADT만의 능력입니다.
이것이 가능한 이유: 컴파일러는 match e with 각 분기에서 타입 정제를 통해 독립적으로 타입을 확인합니다. IntLit 분기에서는 n : int이므로 결과가 int, BoolLit 분기에서는 b : bool이므로 결과가 bool. 두 분기가 서로 다른 타입을 반환해도 되는 것은 GADT의 타입 정제 덕분입니다 — 일반 ADT나 non-GADT match에서는 모든 분기가 같은 타입을 반환해야 합니다.
GADT 완전성 검사
일반 ADT에서는 패턴 매칭이 모든 생성자를 다뤄야 합니다. Shape = Circle | Rect이면 Circle과 Rect 모두 분기가 있어야 합니다.
GADT에서는 타입 정보를 기반으로 불가능한 생성자를 컴파일러가 자동으로 걸러냅니다:
$ cat filter.l3
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
let eval_int e =
(match e with
| IntLit n -> n
: int)
let result = eval_int (IntLit 7)
$ fn filter.l3
7
BoolLit 분기가 없는데도 불완전 경고가 나타나지 않습니다. 왜일까요?
: int 주석 때문에 컴파일러는 e가 int Expr 타입임을 압니다. BoolLit은 bool Expr을 생성하는 생성자이므로, int Expr 값이 BoolLit일 수는 없습니다. 컴파일러가 이것을 증명해주기 때문에, 그 분기를 쓸 필요 자체가 없습니다.
이 특성은 타입 안전한 인터프리터를 만들 때 큰 가치가 있습니다. “정수 표현식을 평가하는 함수에 불리언 표현식이 들어왔을 때” 같은 불가능한 케이스를 예외로 처리하거나, unreachable!() 매크로로 표시하는 대신, 타입 시스템이 그런 상황을 원천 차단합니다.
여러 타입 매개변수의 활용
GADT는 하나의 타입으로 여러 종류의 값을 담으면서, 각 값의 구체적인 타입을 타입 시스템이 추적하게 합니다:
$ cat typed.l3
type Val 'a =
| VInt : int -> int Val
| VBool : bool -> bool Val
| VStr : string -> string Val
let show_int v =
(match v with
| VInt n -> to_string n
: string)
let result = show_int (VInt 99)
$ fn typed.l3
"99"
Val 'a는 세 가지 종류의 값을 담을 수 있지만, 타입 매개변수 'a가 각 값의 구체적인 타입을 보존합니다:
VInt 99는int Val— 정수를 담고 있다는 것이 타입에 나타남VBool true는bool Val— 불리언을 담고 있다는 것이 타입에 나타남VStr "hi"는string Val— 문자열을 담고 있다는 것이 타입에 나타남
show_int가 int Val만 받으므로, show_int (VBool true)는 컴파일 시점에 타입 오류입니다. 런타임에 “expected int but got bool” 같은 에러를 던질 필요가 없습니다.
이 패턴이 유용한 실제 사례:
- 타입 안전한 설정 시스템: 설정 키의 타입이 값의 타입을 결정 (
IntKey : string -> int Setting,StrKey : string -> string Setting) - 타입 안전한 직렬화/역직렬화: 데이터 형식의 타입 정보를 보존
- 컴파일러/인터프리터의 타입 안전한 IR(Intermediate Representation): GHC의 Core IR이 이 방식으로 설계됨
실용 예제: 타입 안전한 평가기
지금까지 배운 모든 것을 결합합니다 — 정수 연산과 부정(negation)을 가진 표현식 언어:
$ cat typed_eval.l3
type Expr 'a =
| IntLit : int -> int Expr
| BoolLit : bool -> bool Expr
| Add : int Expr * int Expr -> int Expr
| Neg : int Expr -> int Expr
let result =
// Neg 포함 GADT 평가기: Add(10, Neg(3)) = 10 + (-3) = 7
let rec eval e =
(match e with
| IntLit n -> n
| BoolLit b -> if b then 1 else 0
| Add (a, b) -> eval a + eval b
| Neg x -> 0 - eval x
: int)
eval (Add (IntLit 10, Neg (IntLit 3)))
$ fn typed_eval.l3
7
이 평가기가 보장하는 것들을 정리합시다:
Add의 인자는 반드시int Expr이다 —Add (IntLit 1, BoolLit true)는 컴파일 오류Neg의 인자도 반드시int Expr이다 — 불리언을 부정하려는 시도는 컴파일 오류- 평가 결과는 반드시
int이다 —eval의 모든 분기가int를 반환함이 컴파일 시점에 증명됨 - 불가능한 분기를 처리할 필요 없다 —
int Expr값에 대해BoolLit분기는 도달 불가능
이 구조는 실제 언어 인터프리터나 컴파일러의 작동 방식과 동일합니다. 타입 안전한 AST(Abstract Syntax Tree)를 GADT로 표현하고, 재귀 평가기로 값을 계산합니다.
다른 언어의 GADT
GADT는 FunLang만의 기능이 아닙니다. 여러 언어가 같은 아이디어를 각자의 방식으로 표현합니다. 다른 언어의 접근법을 비교하면 GADT의 본질이 더 명확해집니다.
Haskell — GADT의 원조
Haskell은 GADT를 가장 먼저 실용화한 언어입니다. {-# LANGUAGE GADTs #-} 확장으로 사용합니다:
{-# LANGUAGE GADTs #-}
data Expr a where
IntLit :: Int -> Expr Int
BoolLit :: Bool -> Expr Bool
Add :: Expr Int -> Expr Int -> Expr Int
eval :: Expr a -> a
eval (IntLit n) = n
eval (BoolLit b) = b
eval (Add x y) = eval x + eval y
Haskell에서 주목할 점은 eval의 반환 타입이 a라는 것입니다. IntLit 분기에서 a = Int로 정제되므로 n :: Int를 그대로 반환할 수 있고, BoolLit 분기에서는 a = Bool로 정제되므로 b :: Bool을 반환할 수 있습니다. 반환 타입이 입력에 따라 달라지는 함수를 타입 안전하게 정의한 것입니다. Haskell은 타입 추론이 강력해서 (match ... : int) 같은 명시적 주석 없이도 동작하는 경우가 많습니다.
FunLang와 비교하면:
| Haskell | FunLang | |
|---|---|---|
| 구문 | IntLit :: Int -> Expr Int | IntLit : int -> int Expr |
| 타입 매개변수 위치 | 뒤 (Expr Int) | 앞 (int Expr) |
| match 주석 | 대부분 불필요 (타입 추론) | 선택사항 (생략 시 다형적 반환) |
| 다형적 반환 | eval :: Expr a -> a 가능 | eval : 'a Expr -> 'a 가능 |
Haskell의 eval :: Expr a -> a는 “정수 표현식을 넣으면 정수가 나오고, 불리언 표현식을 넣으면 불리언이 나온다“를 타입 하나로 표현합니다. FunLang도 주석 없이 let eval e = match e with | IntLit n -> n | BoolLit b -> b처럼 쓰면 eval : 'a Expr -> 'a 타입이 추론됩니다 — Haskell과 동등한 수준의 다형적 반환을 지원합니다. 반환 타입을 하나로 고정하고 싶을 때는 (match ... : int) 주석을 추가하면 됩니다.
OCaml — FunLang의 직접적 영감
OCaml은 4.0부터 GADT를 지원합니다. FunLang의 GADT 구현은 OCaml의 접근법에서 직접적인 영감을 받았습니다:
type _ expr =
| IntLit : int -> int expr
| BoolLit : bool -> bool expr
| Add : int expr * int expr -> int expr
let eval : type a. a expr -> a = function
| IntLit n -> n
| BoolLit b -> b
| Add (x, y) -> eval x + eval y
OCaml에서 type a.는 locally abstract type 선언입니다. 컴파일러에게 “이 함수 안에서 GADT 타입 정제를 수행하라“고 알려줍니다. 주석 없이는 OCaml도 타입 정제를 할 수 없습니다. FunLang는 주석 없이도 자동으로 다형적 타입 변수를 생성하여 타입 정제를 수행하는 방식으로, OCaml보다 한 걸음 더 나아갔습니다. 반환 타입을 하나로 고정하고 싶을 때는 여전히 (match ... : int) 주석을 사용할 수 있습니다.
OCaml 구문에서 type _ expr의 _는 타입 매개변수를 익명으로 선언합니다. 각 생성자가 자신의 반환 타입에서 이 매개변수를 구체화합니다. FunLang에서는 type Expr 'a로 매개변수에 이름을 줍니다.
Scala — sealed trait으로 유사 구현
Scala는 언어 자체에 GADT 키워드가 없지만, sealed trait과 제네릭으로 비슷한 패턴을 만들 수 있습니다:
sealed trait Expr[A]
case class IntLit(n: Int) extends Expr[Int]
case class BoolLit(b: Boolean) extends Expr[Boolean]
case class Add(x: Expr[Int], y: Expr[Int]) extends Expr[Int]
def eval[A](e: Expr[A]): A = e match {
case IntLit(n) => n
case BoolLit(b) => b
case Add(x, y) => eval(x) + eval(y)
}
Scala에서 IntLit extends Expr[Int]은 “IntLit은 Expr의 타입 매개변수를 Int로 고정한다“는 뜻입니다. 이것이 FunLang의 IntLit : int -> int Expr과 정확히 같은 의미입니다. Scala의 패턴 매칭에서도 case IntLit(n) =>에 진입하면 컴파일러가 A = Int를 추론합니다.
객체지향 배경에서 왔다면 이 Scala 코드가 가장 친숙하게 느껴질 것입니다. extends Expr[Int]는 상속처럼 보이지만, 실제로는 GADT의 “생성자가 반환 타입을 고정한다“는 것과 동일한 효과를 냅니다.
TypeScript — 판별 유니온으로 흉내내기
TypeScript에는 GADT가 없지만, 판별 유니온(discriminated union)과 타입 좁히기(type narrowing)로 비슷한 효과를 낼 수 있습니다:
type Expr =
| { tag: "int"; value: number }
| { tag: "bool"; value: boolean }
| { tag: "add"; left: Expr; right: Expr }
function eval(e: Expr): number | boolean {
switch (e.tag) {
case "int": return e.value; // TypeScript knows: e.value is number
case "bool": return e.value; // TypeScript knows: e.value is boolean
case "add": return (eval(e.left) as number) + (eval(e.right) as number);
}
}
TypeScript의 switch (e.tag)는 각 분기에서 e의 타입을 좁혀줍니다. case "int":에서 TypeScript는 e.value가 number임을 압니다. 이것은 GADT의 타입 정제와 개념적으로 같습니다.
하지만 결정적인 차이가 있습니다:
eval의 반환 타입이number | boolean이다 — GADT처럼 “int 표현식이면 number, bool 표현식이면 boolean“이라고 타입에 표현할 수 없습니다Add의 인자를Expr로만 제한할 수 있다 — “int Expr만 받는다“고 표현할 수 없으므로Add(BoolLit, IntLit)같은 잘못된 조합을 컴파일 시점에 막지 못합니다as number캐스팅이 필요하다 — 타입 시스템이eval(e.left)의 결과가number임을 증명하지 못하므로, 프로그래머가 직접 보장해야 합니다
이 차이가 바로 “타입 좁히기“와 “GADT 타입 정제“의 본질적 차이입니다. 타입 좁히기는 런타임 태그(tag)를 보고 해당 분기의 타입을 좁히지만, 타입 매개변수 자체를 고정하지는 못합니다. GADT는 타입 매개변수를 고정하여, 반환 타입까지 입력 타입에 연동시킬 수 있습니다.
Rust — enum으로는 안 되는 것
Rust의 enum은 강력하지만 GADT를 지원하지 않습니다:
#![allow(unused)]
fn main() {
enum Expr {
IntLit(i32),
BoolLit(bool),
Add(Box<Expr>, Box<Expr>), // Expr일 뿐, "int Expr"이 아님
}
}
Rust에서 Expr에 타입 매개변수를 넣을 수는 있지만 (Expr<T>), 각 variant가 T를 다르게 고정하는 것은 불가능합니다. IntLit이 Expr<i32>를, BoolLit이 Expr<bool>을 만들어내게 하려면, 서로 다른 타입 (Expr<i32>와 Expr<bool>)이 되어 하나의 enum에 담을 수 없습니다.
Rust에서 이 문제를 해결하려면 trait object나 enum + 런타임 태그 패턴을 사용해야 합니다. 두 방법 모두 컴파일 시점 타입 안전성을 포기하거나, 상당한 보일러플레이트를 감수해야 합니다.
언어별 비교 요약
| 언어 | GADT 지원 | 구문 | 타입 주석 | 다형적 반환 |
|---|---|---|---|---|
| Haskell | 네이티브 (GADTs 확장) | data Expr a where IntLit :: Int -> Expr Int | 대부분 불필요 | eval :: Expr a -> a 가능 |
| OCaml | 네이티브 (4.0+) | type _ expr = IntLit : int -> int expr | type a. 필요 | 가능 |
| FunLang | 네이티브 | IntLit : int -> int Expr | 선택사항 (생략 시 다형적) | eval : 'a Expr -> 'a 가능 |
| Scala | sealed trait으로 유사 | case class IntLit(n: Int) extends Expr[Int] | 불필요 | 가능 |
| TypeScript | 판별 유니온 (제한적) | { tag: "int"; value: number } | 불필요 | 유니온 반환만 |
| Rust | 미지원 | — | — | — |
FunLang의 GADT는 OCaml의 설계를 가장 가깝게 따르며, Haskell보다 단순하지만 핵심 기능(타입 정제, 불가능한 분기 제거, 컴파일 시점 타입 안전성)은 동일하게 제공합니다.
ADT vs GADT: 언제 무엇을 쓸까
| 상황 | 선택 | 이유 |
|---|---|---|
| 단순 열거형 (Color, Direction) | ADT | 타입 매개변수가 불필요 |
| 재귀 데이터 (Tree, List) | ADT | 모든 노드가 같은 타입 |
| Option, Result | ADT | 감싸는 값의 타입이 하나 |
| 타입별로 다른 동작이 필요한 표현식 언어 | GADT | 생성자마다 반환 타입이 다름 |
| 잘못된 조합을 컴파일 시점에 차단하고 싶을 때 | GADT | 타입 수준의 제약 |
| 평가 결과 타입이 입력에 따라 달라질 때 | GADT | 타입 정제로 해결 |
경험 법칙: “이 잘못된 값은 타입 시스템이 막아줬으면 좋겠다“는 생각이 들 때가 GADT를 고려할 시점입니다. 그렇지 않다면 일반 ADT로 충분합니다. GADT의 추가적인 복잡성(타입 주석 필요, 구문이 더 복잡)은 타입 안전성의 이득이 있을 때만 감수할 가치가 있습니다.
GADT 구문 요약
| 기능 | 구문 |
|---|---|
| 타입 선언 | type Expr 'a = ... |
| 생성자 | IntLit : int -> int Expr |
| 반환 타입 | int Expr (매개변수가 이름 앞에 위치) |
| match 주석 | (match e with | ... : int) — 선택사항, 반환 타입 고정 시 사용 |
| 주석 없는 match | 컴파일러가 자동으로 다형적 타입 변수 생성 |
| 하나의 생성자라도 GADT이면 | 모든 생성자가 GADT로 취급됨 |
| 일반 ADT | GADT |
|---|---|
type T 'a = A of int | type T 'a = A : int -> int T |
모든 생성자가 T<'a> 생성 | 각 생성자가 T<concrete> 생성 |
| match에서 값만 분해 | match에서 값 분해 + 타입 정제 |
| 타입 주석 불필요 | (match ... : Type) 선택사항 |
| 모든 생성자 분기 필요 | 불가능한 생성자 자동 제외 |
23장: 타입 클래스 (Type Classes)
지금까지 FunLang에서 타입을 정의하고, 패턴 매칭하고, 함수를 조합하는 방법을 배웠습니다. 하지만 한 가지 빠진 조각이 있습니다 — “이 타입에 대해 이런 동작을 할 수 있다“는 사실을 타입 시스템 수준에서 표현하는 방법입니다.
예를 들어 to_string은 모든 타입을 문자열로 바꿀 수 있는 내장 함수입니다. 하지만 “문자열로 변환 가능하다“는 속성을 사용자가 직접 정의하고 확장할 수 있으면 어떨까요? 타입 클래스는 바로 이 질문에 대한 답입니다.
Haskell의 타입 클래스에서 직접 영감을 받은 이 기능은, 다형성 함수가 특정 동작을 요구할 수 있게 합니다. Java의 인터페이스나 Rust의 trait과 비슷한 역할이지만, 타입 정의와 분리되어 있어 기존 타입에도 새 동작을 추가할 수 있습니다.
타입 클래스 선언
타입 클래스는 typeclass 키워드로 선언합니다. 타입 변수와 메서드 시그니처의 목록을 정의합니다:
$ cat show_class.l3
typeclass Show 'a =
| show : 'a -> string
instance Show int =
let show x = to_string x
let result = show 42
$ fn show_class.l3
"42"
typeclass Show 'a는 “타입 'a에 대해 show라는 함수가 존재해야 한다“고 선언합니다. 메서드는 선행 파이프(|)와 타입 어노테이션으로 작성합니다 — ADT의 생성자 선언과 같은 구문입니다.
instance Show int는 “int 타입에 대해 Show의 구체적 구현을 제공한다“는 뜻입니다. 인스턴스 본문에서 let show x = ...로 실제 함수를 정의합니다.
여러 메서드를 가진 타입 클래스
타입 클래스는 메서드를 여러 개 가질 수 있습니다:
$ cat describable.l3
typeclass Describable 'a =
| describe : 'a -> string
| tag : 'a -> string
instance Describable int =
let describe x = to_string x
let tag x = "int"
let result = describe 42 + ":" + tag 42
$ fn describable.l3
"42:int"
인스턴스는 타입 클래스가 선언한 모든 메서드를 구현해야 합니다.
내장 인스턴스: Show와 Eq
FunLang는 Prelude에서 두 가지 타입 클래스와 기본 타입에 대한 인스턴스를 제공합니다. 별도의 선언 없이 바로 사용할 수 있습니다.
Show 클래스
Show는 값을 문자열로 변환하는 show 함수를 제공합니다. int, bool, string, char 네 가지 기본 타입에 대한 인스턴스가 내장되어 있습니다:
$ cat show_builtin.l3
let _ = println (show 42)
let _ = println (show true)
let _ = println (show 'x')
let _ = println (show "hello")
$ fn show_builtin.l3
42
true
x
hello
show는 to_string과 비슷하지만, 타입 클래스 시스템을 통해 동작합니다. 즉 사용자가 직접 정의한 타입에도 Show 인스턴스를 추가할 수 있습니다.
Eq 클래스
Eq는 두 값의 동등성을 비교하는 eq 함수를 제공합니다. 역시 int, bool, string, char에 대한 인스턴스가 내장되어 있습니다:
$ cat eq_builtin.l3
let _ = println (to_string (eq 1 1))
let _ = println (to_string (eq 1 2))
let _ = println (to_string (eq "hello" "hello"))
let _ = println (to_string (eq 'a' 'b'))
$ fn eq_builtin.l3
true
false
true
false
제약 추론 (Constraint Inference)
타입 클래스의 진정한 힘은 제약 추론에 있습니다. 함수가 타입 클래스 메서드를 사용하면, 컴파일러가 자동으로 해당 제약을 추론합니다:
$ cat show_twice.l3
let show_twice x = show x + show x
let result = show_twice 42
$ fn show_twice.l3
"4242"
show_twice는 show를 호출하므로, 컴파일러가 Show 'a => 'a -> string이라는 타입을 추론합니다. “타입 'a가 Show의 인스턴스일 때만 이 함수를 호출할 수 있다“는 의미입니다. show_twice 42를 호출하면 'a가 int로 결정되고, Show int 인스턴스가 자동으로 선택됩니다.
하나의 제약된 함수를 여러 타입에 사용할 수 있습니다:
$ cat show_poly.l3
let show_twice x = show x + show x
let _ = println (show_twice 42)
let _ = println (show_twice true)
$ fn show_poly.l3
4242
truetrue
show_twice 42에서는 Show int 인스턴스가, show_twice true에서는 Show bool 인스턴스가 자동으로 선택됩니다. 함수를 한 번만 작성하고 다양한 타입에 대해 재사용할 수 있는 것이 핵심입니다.
명시적 제약 어노테이션
제약을 직접 명시할 수도 있습니다. => 구문으로 제약과 타입을 구분합니다:
$ cat constrained_annot.l3
let f : Show 'a => 'a -> string = fun x -> show x
let result = f 42
$ fn constrained_annot.l3
"42"
제약이 추론 가능한 경우에는 생략해도 되지만, 복잡한 함수에서 의도를 명확히 하고 싶을 때 유용합니다.
고차 함수와 타입 클래스
타입 클래스 메서드는 일반 함수이므로, 고차 함수의 인자로 전달할 수 있습니다:
$ cat show_map.l3
let map_show lst = List.map show lst
let result = map_show [1; 2; 3]
$ fn show_map.l3
["1"; "2"; "3"]
List.map show [1; 2; 3]에서 show는 int -> string 함수처럼 동작합니다. Prelude의 Show int 인스턴스가 자동으로 선택됩니다. 타입 클래스 메서드가 일급 함수라는 사실이 파이프라인 스타일 프로그래밍과 자연스럽게 어울립니다.
에러 처리
타입 클래스 시스템은 잘못된 사용에 대해 명확한 에러 메시지를 제공합니다.
인스턴스가 없는 타입에 메서드 사용
$ cat no_instance.l3
let bad = show (fun x -> x)
$ fn no_instance.l3
error[E0701]: No instance of Show for 'x -> 'x
--> no_instance.l3:1:8-14
|
1 | let bad = show (fun x -> x)
| ^^^^^^
= hint: Add an instance declaration for this type
(Available instances: Show char, Show string, Show bool, Show int)
함수 타입에 대한 Show 인스턴스가 없으므로 컴파일 에러가 발생합니다. 에러 메시지에는 사용 가능한 인스턴스 목록이 포함되어 있어, 어떤 타입에 대해 show를 사용할 수 있는지 한눈에 볼 수 있습니다.
중복 인스턴스 선언
$ cat dup_instance.l3
typeclass Show 'a =
| show : 'a -> string
instance Show int =
let show x = to_string x
instance Show int =
let show x = to_string x
$ fn dup_instance.l3
error[E0702]: Duplicate instance declaration: Show int
--> dup_instance.l3:3:0-4:28
|
3 | instance Show int =
| ^^^^^^^^^^^^^^^^^^^
같은 타입에 대해 인스턴스를 두 번 선언하면 에러가 발생합니다. 어떤 구현을 선택해야 할지 모호해지기 때문입니다.
Eq 제약 위반
$ cat eq_error.l3
let result = eq (fun x -> x) (fun x -> x)
$ fn eq_error.l3
error[E0701]: No instance of Eq for 'z -> 'z
--> eq_error.l3:1:11-15
|
1 | let result = eq (fun x -> x) (fun x -> x)
| ^^^^
함수 타입은 동등성 비교가 불가능합니다. 수학적으로 두 함수가 같은지 판정하는 것은 일반적으로 불가능한 문제이며, FunLang의 타입 시스템은 이를 컴파일 타임에 방지합니다.
사용자 정의 타입에 인스턴스 추가하기
타입 클래스의 큰 장점은 사용자가 정의한 ADT에도 인스턴스를 추가할 수 있다는 것입니다. Prelude의 Show와 Eq에 대해 사용자 타입의 인스턴스를 바로 선언할 수 있습니다:
$ cat custom_show.l3
type Color =
| Red
| Green
| Blue
instance Show Color =
let show c =
match c with
| Red -> "Red"
| Green -> "Green"
| Blue -> "Blue"
let result = show Green
$ fn custom_show.l3
"Green"
타입 정의와 인스턴스 선언이 분리되어 있으므로, 이미 존재하는 타입에 새로운 동작을 추가할 수 있습니다. Java에서 기존 클래스에 인터페이스를 구현하려면 클래스 자체를 수정해야 하지만, 타입 클래스에서는 그럴 필요가 없습니다.
Eq도 마찬가지입니다:
$ cat custom_eq.l3
type Direction = | North | South | East | West
instance Eq Direction =
let eq a = fun b ->
match (a, b) with
| (North, North) -> true
| (South, South) -> true
| (East, East) -> true
| (West, West) -> true
| _ -> false
let _ = println (to_string (eq North North))
let result = eq North South
$ fn custom_eq.l3
true
false
모듈과 타입 클래스
타입 클래스는 모듈 시스템과 자연스럽게 결합됩니다. 타입과 인스턴스를 같은 모듈에 묶어서 캡슐화할 수 있습니다:
$ cat mod_typeclass.l3
module Shapes =
type Shape = | Circle | Square | Triangle
instance Show Shape =
let show s =
match s with
| Circle -> "circle"
| Square -> "square"
| Triangle -> "triangle"
open Shapes
let _ = println (show Circle)
let _ = println (show Square)
let result = show Triangle
$ fn mod_typeclass.l3
circle
square
"triangle"
모듈 안에서 선언된 인스턴스는 전역적으로 동작합니다 — open Shapes 이후에 show Circle이 바로 동작합니다. 인스턴스가 모듈 안에 있더라도 open 없이 인스턴스 자체는 유효합니다. open이 필요한 것은 생성자(Circle, Square)와 타입 이름을 스코프에 가져오기 위해서입니다.
타입 클래스 자체도 모듈 안에서 선언하고 open으로 가져올 수 있습니다:
$ cat mod_class.l3
module Render =
typeclass Renderable 'a =
| render : 'a -> string
open Render
instance Renderable int =
let render x = "[" + to_string x + "]"
let result = render 42
$ fn mod_class.l3
"[42]"
Prelude의 타입 클래스
Prelude/Typeclass.fun 파일에는 다음이 정의되어 있습니다:
typeclass Show 'a =
| show : 'a -> string
instance Show int =
let show x = to_string x
instance Show bool =
let show x = if x then "true" else "false"
instance Show string =
let show x = x
instance Show char =
let show x = to_string x
typeclass Eq 'a =
| eq : 'a -> 'a -> bool
instance Eq int =
let eq x = fun y -> x = y
instance Eq bool =
let eq x = fun y -> x = y
instance Eq string =
let eq x = fun y -> x = y
instance Eq char =
let eq x = fun y -> x = y
이 파일은 Prelude의 다른 파일과 마찬가지로 자동으로 로드됩니다. 따라서 show와 eq는 별도의 선언 없이 모든 코드에서 사용할 수 있습니다.
요약
타입 클래스는 “이 타입에 이런 동작이 가능하다“를 타입 시스템으로 표현하는 방법입니다:
| 구성 요소 | 구문 | 역할 |
|---|---|---|
| 타입 클래스 선언 | typeclass Show 'a = | show : 'a -> string | 메서드 시그니처 정의 |
| 인스턴스 선언 | instance Show int = let show x = ... | 특정 타입에 대한 구현 |
| 제약 어노테이션 | Show 'a => 'a -> string | 함수가 요구하는 인스턴스 명시 |
| 제약 추론 | (자동) | 메서드 사용 시 제약 자동 추론 |
Show와Eq타입 클래스가 Prelude에 내장되어 있으며,int,bool,string,char에 대한 인스턴스를 제공합니다- 사용자 정의 ADT에 대해
instance Show MyType = ...으로 인스턴스를 추가할 수 있습니다 - 타입 클래스 메서드는 일급 함수로, 고차 함수와 자연스럽게 결합됩니다
- 모듈 안에서 선언된 인스턴스는 전역적으로 동작합니다
- 인스턴스가 없는 타입에 메서드를 사용하면
E0701에러가 발생합니다
향후 버전에서는 제약된 인스턴스 (Show 'a => Show (list 'a)), 슈퍼클래스 제약, 자동 인스턴스 도출(derive) 등이 추가될 예정입니다.
15장: 알고리즘과 자료구조 (Algorithms and Data Structures)
이 장은 앞서 배운 모든 것을 실전에 적용하는 종합 실습입니다. 패턴 매칭, ADT, 재귀, 고차 함수, 모듈 — 지금까지 개별적으로 익혔던 도구들이 여기서 하나로 합쳐집니다.
함수형 프로그래밍으로 알고리즘을 구현하면 명령형 스타일과는 사뭇 다른 코드가 나옵니다. for 루프와 인덱스 변수 대신 재귀와 패턴 매칭이, 배열의 in-place 수정 대신 새로운 리스트의 생성이 중심이 됩니다. 처음에는 비효율적으로 보일 수 있지만, 코드의 정확성을 추론하기가 훨씬 쉽다는 장점이 있습니다. 각 함수가 입력을 받아 출력을 반환할 뿐 아무것도 변경하지 않으니까요.
먼저 FunLang에서 알고리즘을 작성할 때 알아두면 좋은 핵심 기능들을 정리합니다.
핵심 기능 정리
FunLang에서 알고리즘을 구현할 때 자주 사용하는 기능들입니다:
- 모듈 레벨
let rec: 재귀 함수를 최상위 선언으로 작성합니다. 표현식 내부의let rec ... in체인 대신, 각 함수를 독립된 선언으로 분리하면 가독성과 재사용성이 크게 향상됩니다. - 리스트 범위:
[1..100]으로 연속 정수 리스트를 간편하게 생성합니다. - 상호 재귀:
let rec f = ... and g = ...로 서로를 호출하는 함수를 정의합니다. - Or 패턴: 패턴 매칭에서 여러 패턴을 하나로 묶을 수 있습니다.
모듈 레벨 let rec의 제약 사항을 기억하세요:
- 매개변수는 하나만 직접 받습니다:
let rec f x = body - 두 번째 매개변수부터는 클로저로 전달합니다:
let rec f x = fun y -> body match표현식은 한 줄 또는 들여쓰기 기반 여러 줄로 작성할 수 있습니다
이제 이 기능들을 활용한 알고리즘을 하나씩 살펴보겠습니다.
표준 리스트 함수
참고:
map,filter,fold,length,reverse,append등은 이제 Prelude에서 제공됩니다. 아래 예제에서는 구현 방법을 보여주기 위해 직접 정의하지만, 실제 프로그램에서는 Prelude 함수를 바로 사용할 수 있습니다.
함수형 프로그래밍에서 map, filter, fold는 가장 기본적인 도구입니다.
모듈 레벨에 선언하면 프로그램 전체에서 재사용할 수 있습니다.
Map
리스트의 각 원소에 함수를 적용하여 새 리스트를 만듭니다:
$ cat map.l3
let rec map f = fun xs ->
match xs with
| [] -> []
| h :: t -> f h :: map f t
let result = map (fun x -> x * x) [1; 2; 3; 4; 5]
$ fn map.l3
[1; 4; 9; 16; 25]
map은 두 개의 매개변수를 받습니다. 첫 번째 f가 let rec 매개변수이고,
두 번째 xs는 fun xs -> ...를 통해 전달됩니다. 빈 리스트가 기저 사례이며,
비어 있지 않으면 head에 f를 적용하고 tail에 대해 재귀합니다.
Filter
조건을 만족하는 원소만 남깁니다:
$ cat filter.l3
let rec filter pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: filter pred t else filter pred t
let result = filter (fun x -> x % 2 = 0) [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
$ fn filter.l3
[2; 4; 6; 8; 10]
x % 2 = 0은 % (모듈로) 연산자로 짝수를 판별합니다.
Fold (왼쪽 폴드)
왼쪽 폴드는 리스트를 하나의 값으로 축약합니다. 이항 함수 f, 초기 누적자 acc,
리스트 xs 세 개의 매개변수를 받습니다:
$ cat fold.l3
let rec fold f = fun acc -> fun xs ->
match xs with
| [] -> acc
| h :: t -> fold f (f acc h) t
let result = fold (fun acc -> fun x -> acc + x * x) 0 [1; 2; 3; 4; 5]
$ fn fold.l3
55
세 개의 매개변수가 중첩 클로저로 전달됩니다: f가 let rec 매개변수,
acc가 첫 번째 fun, xs가 두 번째 fun입니다.
계산 과정은 0 + 1 + 4 + 9 + 16 + 25 = 55입니다.
Prelude를 활용한 간결한 코드
Prelude 함수를 사용하면 알고리즘을 더 간결하게 작성할 수 있습니다:
$ cat sieve_prelude.l3
let rec sieve xs =
match xs with
| [] -> []
| p :: rest -> p :: sieve (filter (fun n -> n % p <> 0) rest)
let result = sieve [2..50]
$ fn sieve_prelude.l3
[2; 3; 5; 7; 11; 13; 17; 19; 23; 29; 31; 37; 41; 43; 47]
filter는 Prelude에서 제공되므로 재정의할 필요 없이, sieve 함수만 작성하면
에라토스테네스의 체를 구현할 수 있습니다.
수론
팩토리얼
가장 기본적인 재귀 알고리즘입니다. 모듈 레벨 let rec으로 깔끔하게 표현됩니다:
$ cat factorial.l3
let rec fact n = if n <= 1 then 1 else n * fact (n - 1)
let result = fact 10
$ fn factorial.l3
3628800
fact 10은 10 * 9 * 8 * … * 1 = 3,628,800을 계산합니다.
함수 정의와 호출이 분리되어 읽기 좋습니다.
피보나치 수열
단순 재귀 피보나치를 리스트 범위와 map을 결합하여 수열 전체를 출력합니다:
$ cat fibonacci.l3
let rec fib n = if n <= 1 then n else fib (n - 1) + fib (n - 2)
let rec map f = fun xs ->
match xs with
| [] -> []
| h :: t -> f h :: map f t
let result = map fib [0..15]
$ fn fibonacci.l3
[0; 1; 1; 2; 3; 5; 8; 13; 21; 34; 55; 89; 144; 233; 377; 610]
[0..15]는 0부터 15까지 16개의 정수 리스트를 생성합니다.
map fib [0..15]는 각 인덱스에 대한 피보나치 값을 계산합니다.
GCD와 LCM
유클리드 알고리즘으로 최대공약수를 구하고, 이를 이용해 최소공배수를 유도합니다:
$ cat gcd_lcm.l3
let rec gcd a = fun b ->
if b = 0 then a else gcd b (a % b)
let lcm a = fun b ->
a / gcd a b * b
let result = (gcd 48 36, lcm 12 18)
$ fn gcd_lcm.l3
(12, 36)
gcd 48 36의 축약: gcd 48 36 -> gcd 36 12 -> gcd 12 0 -> 12.
lcm 12 18은 12 / gcd(12,18) * 18 = 12 / 6 * 18 = 36입니다.
a % b는 나머지(모듈로) 연산자입니다.
lcm은 재귀가 아니므로 let rec 없이 일반 let으로 정의합니다.
서로소 (Coprimes)
GCD와 리스트 범위를 결합하면 주어진 수와 서로소인 수를 구할 수 있습니다:
$ cat coprimes.l3
let rec gcd a = fun b ->
if b = 0 then a else gcd b (a % b)
let rec filter pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: filter pred t else filter pred t
let coprimes n = filter (fun k -> gcd n k = 1) [1..n]
let result = coprimes 12
$ fn coprimes.l3
[1; 5; 7; 11]
[1..12] 중에서 12와 GCD가 1인 수만 남깁니다. 이것이 오일러 토션트 함수
phi(12) = 4의 구체적인 원소들입니다. 리스트 범위 덕분에 [1..n]으로
간결하게 후보를 생성할 수 있습니다.
소수 판별 (isPrime)
주어진 범위에서 소수를 걸러냅니다:
$ cat is_prime.l3
let rec filter pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: filter pred t else filter pred t
let rec checkPrime n = fun d -> if d * d > n then true else if n % d = 0 then false else checkPrime n (d + 1)
let isPrime n = if n < 2 then false else checkPrime n 2
let result = filter (fun n -> isPrime n) [2..50]
$ fn is_prime.l3
[2; 3; 5; 7; 11; 13; 17; 19; 23; 29; 31; 37; 41; 43; 47]
checkPrime n d는 2부터 sqrt(n)까지 나눠보며 소수를 판별합니다.
isPrime은 비재귀 래퍼로, 2 미만인 경우를 먼저 걸러냅니다.
[2..50]에서 소수만 필터링하여 50 이하의 모든 소수를 구합니다.
거듭제곱
반복 곱셈으로 base^exp를 계산합니다:
$ cat power.l3
let rec power base = fun exp ->
if exp = 0 then 1 else base * power base (exp - 1)
let result = power 2 10
$ fn power.l3
1024
power 2 10은 2^10 = 1024를 계산합니다.
정렬 알고리즘
정렬은 알고리즘을 비교하기 좋은 주제입니다. 모듈 레벨 let rec 덕분에
각 헬퍼 함수가 독립 선언이 되어 구조가 명확합니다.
삽입 정렬
각 원소를 정렬된 리스트의 올바른 위치에 삽입하여 정렬합니다:
$ cat insertion_sort.l3
let rec insert x = fun xs ->
match xs with
| [] -> x :: []
| h :: t -> if x <= h then x :: h :: t else h :: insert x t
let rec sort xs =
match xs with
| [] -> []
| h :: t -> insert h (sort t)
let result = sort [5; 3; 8; 1; 9; 2; 7; 4; 6]
$ fn insertion_sort.l3
[1; 2; 3; 4; 5; 6; 7; 8; 9]
insert와 sort가 각각 독립된 최상위 함수입니다. insert는 정렬된 리스트에서
올바른 위치를 찾을 때까지 순회하고, sort는 tail을 재귀적으로 정렬한 후
head를 삽입합니다. 최악의 경우 O(n^2)이지만 구현이 단순하고 안정적(stable)입니다.
퀵정렬
피벗을 기준으로 분할하고, 각 부분을 재귀 정렬한 후 합칩니다:
$ cat quicksort.l3
let rec filter pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: filter pred t else filter pred t
let rec append xs = fun ys ->
match xs with
| [] -> ys
| h :: t -> h :: append t ys
let rec qsort xs =
match xs with
| [] -> []
| pivot :: rest ->
let lo = filter (fun x -> x < pivot) rest
in let hi = filter (fun x -> x >= pivot) rest
in append (qsort lo) (pivot :: qsort hi)
let result = qsort [5; 3; 8; 1; 9; 2; 7; 4; 6]
$ fn quicksort.l3
[1; 2; 3; 4; 5; 6; 7; 8; 9]
filter, append, qsort 세 함수가 각각 독립 선언입니다. 피벗은 리스트의
head를 사용합니다. filter로 피벗보다 작은 원소(lo)와 크거나 같은
원소(hi)를 분리한 후, 정렬된 결과를 append (qsort lo) (pivot :: qsort hi)로
조합합니다.
Prelude ++ 연산자를 사용하면 append를 재정의할 필요가 없어 더 간결합니다:
$ cat qsort_prelude.l3
let rec qsort xs =
match xs with
| [] -> []
| p :: rest ->
qsort (filter (fun x -> x < p) rest) ++ [p] ++ qsort (filter (fun x -> x >= p) rest)
let result = qsort [5; 3; 8; 1; 9; 2; 7]
$ fn qsort_prelude.l3
[1; 2; 3; 5; 7; 8; 9]
병합 정렬
리스트를 반으로 나누고, 각 반쪽을 정렬한 후 병합합니다. 여러 헬퍼 함수가 필요한 알고리즘에서 모듈 레벨 선언의 장점이 특히 드러납니다:
$ cat merge_sort.l3
let rec length xs =
match xs with
| [] -> 0
| _ :: t -> 1 + length t
let rec take n = fun xs ->
if n = 0 then []
else match xs with
| [] -> []
| h :: t -> h :: take (n - 1) t
let rec drop n = fun xs ->
if n = 0 then xs
else match xs with
| [] -> []
| _ :: t -> drop (n - 1) t
// 두 정렬된 리스트의 head를 비교하며 병합
let rec merge xs = fun ys ->
match xs with
| [] -> ys
| x :: xt ->
match ys with
| [] -> xs
| y :: yt -> if x <= y then x :: merge xt (y :: yt) else y :: merge (x :: xt) yt
// 리스트를 반으로 나눠 각각 정렬 후 병합 (O(n log n))
let rec msort xs =
let len = length xs
in if len <= 1 then xs
else let mid = len / 2
in merge (msort (take mid xs)) (msort (drop mid xs))
let result = msort [5; 3; 8; 1; 9; 2; 7; 4; 6]
$ fn merge_sort.l3
[1; 2; 3; 4; 5; 6; 7; 8; 9]
다섯 개의 함수가 각각 독립된 최상위 선언입니다. 각 함수를 독립적으로 읽고 이해할 수 있습니다.
take과 drop이 리스트를 중간점에서 분할하고, merge가 두 정렬된 리스트의
head를 비교하며 교차 배치합니다. O(n log n)이 보장됩니다.
트리 자료구조
이진 탐색 트리와 트리 정렬
대수적 데이터 타입으로 이진 트리를 정의하고, 삽입/구축/순회를 구현하여 트리 기반 정렬을 만듭니다:
$ cat tree_sort.l3
type Tree =
| Leaf
| Node of Tree * int * Tree
// BST 삽입: 값을 비교하여 왼쪽 또는 오른쪽 하위 트리에 재귀 삽입
let rec treeInsert x = fun t ->
match t with
| Leaf -> Node (Leaf, x, Leaf)
| Node (l, v, r) -> if x <= v then Node (treeInsert x l, v, r) else Node (l, v, treeInsert x r)
let rec buildTree xs =
match xs with
| [] -> Leaf
| h :: t -> treeInsert h (buildTree t)
let rec append xs = fun ys ->
match xs with
| [] -> ys
| h :: t -> h :: append t ys
let rec inorder t =
match t with
| Leaf -> []
| Node (l, v, r) -> append (inorder l) (v :: inorder r)
let result = inorder (buildTree [5; 3; 8; 1; 9; 2; 7])
$ fn tree_sort.l3
[1; 2; 3; 5; 7; 8; 9]
Tree 타입은 두 생성자를 가진 대수적 데이터 타입입니다: Leaf(빈 노드)와
Node(왼쪽 하위 트리, 값, 오른쪽 하위 트리). treeInsert는 노드 값과 비교하여
왼쪽 또는 오른쪽으로 재귀하며 BST 성질을 유지합니다. buildTree로 리스트를
트리로 변환하고, inorder 중위 순회로 정렬된 리스트를 추출합니다.
페아노 자연수
자연수를 대수적 타입으로 표현하고, 구조적 재귀로 덧셈과 곱셈을 정의합니다. 수학의 기초를 코드로 직접 표현하는 예제입니다:
$ cat peano.l3
type Nat =
| Zero
| Succ of Nat
let rec toInt n =
match n with
| Zero -> 0
| Succ p -> 1 + toInt p
let rec add a = fun b ->
match a with
| Zero -> b
| Succ p -> Succ (add p b)
let rec mul a = fun b ->
match a with
| Zero -> Zero
| Succ p -> add b (mul p b)
let three = Succ (Succ (Succ Zero))
let four = Succ (Succ (Succ (Succ Zero)))
let result = toInt (mul three four)
$ fn peano.l3
12
add는 a에서 Succ를 하나씩 벗기고 결과를 감쌉니다:
add (Succ (Succ Zero)) b = Succ (Succ b). mul은 반복 덧셈을 사용합니다:
mul (Succ (Succ Zero)) b = add b (add b Zero).
3 * 4 = 12를 toInt로 검증합니다. 모듈 레벨 선언 덕분에 toInt, add, mul이
각각 독립된 함수로 깔끔하게 정의됩니다.
응용 알고리즘
리스트 범위, 상호 재귀, or 패턴 등을 활용하는 알고리즘입니다.
에라토스테네스의 체
고대 그리스의 소수 알고리즘입니다. 리스트 범위 [2..50]으로 후보를 생성하고,
가장 작은 수의 배수를 반복적으로 제거합니다:
$ cat sieve.l3
let rec filter pred = fun xs ->
match xs with
| [] -> []
| h :: t -> if pred h then h :: filter pred t else filter pred t
let rec sieve xs =
match xs with
| [] -> []
| p :: rest -> p :: sieve (filter (fun n -> n % p <> 0) rest)
let result = sieve [2..50]
$ fn sieve.l3
[2; 3; 5; 7; 11; 13; 17; 19; 23; 29; 31; 37; 41; 43; 47]
[2..50]이 2부터 50까지의 리스트를 생성합니다. sieve는 리스트의 첫 원소 p를
소수로 확정하고, 나머지에서 p의 배수를 filter로 제거한 후 재귀합니다.
n % p <> 0은 % 연산자로 n이 p의 배수가 아닌지 검사합니다.
이 알고리즘은 리스트 범위 없이는 후보 리스트를 수동으로 나열해야 했을 것입니다.
[2..50] 한 표현으로 깔끔하게 해결됩니다.
Collatz 수열
콜라츠 추측은 어떤 양의 정수에서 시작하든 “짝수면 반으로, 홀수면 3n+1“을 반복하면 결국 1에 도달한다는 것입니다. 수열을 추적합니다:
$ cat collatz.l3
let rec collatz n = fun acc ->
if n = 1 then n :: acc else if n % 2 = 0 then collatz (n / 2) (n :: acc) else collatz (3 * n + 1) (n :: acc)
let rec rev acc = fun xs ->
match xs with
| [] -> acc
| h :: t -> rev (h :: acc) t
// 수열의 길이만 확인 (27에서 시작하면 112단계를 거쳐 1에 도달)
let seq = rev [] (collatz 27 [])
let result = length seq
$ fn collatz.l3
112
collatz는 꼬리 재귀 함수로, 누적자 acc에 각 단계의 값을 기록합니다.
결과가 역순으로 쌓이므로 rev로 뒤집습니다. 27에서 시작하면 112단계를 거쳐
1에 도달합니다. n % 2 = 0으로 짝수/홀수를 판별합니다.
FizzBuzz
프로그래밍 면접의 고전 문제입니다. 리스트 범위와 map을 결합합니다:
$ cat fizzbuzz.l3
let rec map f = fun xs ->
match xs with
| [] -> []
| h :: t -> f h :: map f t
let fizzbuzz n =
let r3 = n % 3
let r5 = n % 5
match (r3, r5) with
| (0, 0) -> "FizzBuzz"
| (0, _) -> "Fizz"
| (_, 0) -> "Buzz"
| _ -> to_string n
let result = map fizzbuzz [1..20]
$ fn fizzbuzz.l3
["1"; "2"; "Fizz"; "4"; "Buzz"; "Fizz"; "7"; "8"; "Fizz"; "Buzz"; "11"; "Fizz"; "13"; "14"; "FizzBuzz"; "16"; "17"; "Fizz"; "19"; "Buzz"]
fizzbuzz는 % 연산자로 3과 5의 나머지를 구하고 튜플로 만들어 패턴 매칭합니다.
(0, 0)이면 둘 다 나누어지므로 “FizzBuzz”, (0, _)이면 3만 나누어지므로 “Fizz”,
(_, 0)이면 5만 나누어지므로 “Buzz”, 나머지는 숫자 자체를 문자열로 변환합니다.
[1..20]으로 1부터 20까지 범위를 생성하고 map fizzbuzz로 변환합니다.
상태 머신 (상호 재귀)
let rec ... and ... 구문은 서로를 호출하는 함수를 정의할 수 있게 합니다.
상태 머신은 상호 재귀의 대표적인 활용 사례입니다:
$ cat state_machine.l3
let rec stateA xs =
match xs with
| [] -> "ended in A"
| 0 :: rest -> stateB rest
| _ :: rest -> stateA rest
and stateB xs =
match xs with
| [] -> "ended in B"
| 1 :: rest -> stateA rest
| _ :: rest -> stateB rest
let r1 = stateA [1; 0; 1; 0]
let r2 = stateA [1; 0; 0]
let result = (r1, r2)
$ fn state_machine.l3
(ended in B, ended in B)
두 상태 A, B 사이를 전이하는 간단한 오토마톤입니다:
- 상태 A: 입력이 0이면 상태 B로 전이, 그 외에는 A에 머무름
- 상태 B: 입력이 1이면 상태 A로 전이, 그 외에는 B에 머무름
let rec stateA ... and stateB ...로 두 함수가 서로를 호출할 수 있습니다.
첫 번째 입력 [1; 0; 1; 0]은 A -> A -> B -> A -> B 경로를 따라 상태 B에서
끝납니다. 두 번째 [1; 0; 0]은 A -> A -> B -> B로 역시 상태 B에서 끝납니다.
요약
| 패턴 | 문법 |
|---|---|
| 모듈 레벨 재귀 | let rec f x = body |
| 다중 매개변수 재귀 | let rec f x = fun y -> body |
| 리스트 범위 | [1..100] |
| 상호 재귀 | let rec f x = ... and g y = ... |
| ADT + 재귀 | type T = ... let rec f t = match t with ... |
이 장에서 사용한 핵심 기능:
- 모듈 레벨
let rec: 재귀 함수를 최상위 선언으로 작성합니다. 각 함수가 독립적이어서 읽기 쉽고 재사용하기 좋습니다. - 리스트 범위:
[1..n]으로 연속 정수 리스트를 생성합니다. 에라토스테네스의 체, 소수 필터링, FizzBuzz 등에서 활용됩니다. - 상호 재귀:
let rec f = ... and g = ...로 서로를 호출하는 함수를 정의합니다. 상태 머신, 홀수/짝수 판별기 등에서 활용됩니다. - 대수적 데이터 타입은 재귀 함수와 자연스럽게 결합되어 트리, 수식 언어, 사용자 정의 수 타입에 활용됩니다.
16장: CLI 참조 (CLI Reference)
이 장에서는 FunLang의 모든 커맨드라인 모드와 옵션을 다룹니다.
표현식 모드
--expr로 단일 표현식을 평가합니다:
$ fn --expr '1 + 2'
3
표현식은 파싱, 타입 검사, 평가를 거칩니다. 결과는 표준 출력으로 출력됩니다.
다양한 결과 타입:
$ fn --expr '42'
42
$ fn --expr '"hello"'
"hello"
$ fn --expr 'true'
true
$ fn --expr '[1; 2; 3]'
[1; 2; 3]
$ fn --expr '(1, "hello", true)'
(1, "hello", true)
$ fn --expr '()'
()
$ fn --expr 'fun x -> x + 1'
<function>
표현식 모드에서는 let ... in 구문으로 로컬 바인딩을 사용합니다:
$ fn --expr 'let x = 5 in let y = 10 in x + y'
15
표현식 모드는 한 줄만 지원하며, 들여쓰기를 지원하지 않습니다.
파일 모드
프로그램 파일을 평가합니다:
$ cat hello.l3
let greeting = "hello"
let result = greeting + " world"
$ fn hello.l3
"hello world"
파일 모드는 들여쓰기 기반 문법을 지원합니다. 마지막 let 바인딩의 값이
출력됩니다. 최종 결과 전에 부수 효과를 실행하려면 let _ =를 사용합니다:
$ cat greet.l3
let _ = println "starting..."
let name = "world"
let result = "hello " + name
$ fn greet.l3
starting...
"hello world"
파일 모드는 FunLang의 모든 기능을 지원합니다: 타입 선언, 모듈, 예외, 들여쓰기 기반 패턴 매칭, 여러 줄 표현식.
.l3 확장자는 관례이며 강제되지 않습니다 – 어떤 파일 이름이든 사용 가능합니다.
AST 출력
--emit-ast로 파싱된 추상 구문 트리를 확인합니다:
$ fn --emit-ast --expr '1 + 2'
Add (Number 1, Number 2)
$ fn --emit-ast --expr 'fun x -> x + 1'
Lambda ("x", Add (Var "x", Number 1))
$ fn --emit-ast --expr 'let x = 5 in x + 1'
Let ("x", Number 5, Add (Var "x", Number 1))
파일 모드에서는 각 선언이 표시됩니다:
$ cat ast_demo.l3
let x = 42
let add a b = a + b
$ fn --emit-ast ast_demo.l3
LetDecl ("x", Number 42)
LetDecl ("add", Lambda ("a", Lambda ("b", Add (Var "a", Var "b"))))
파싱 문제를 디버깅하는 데 유용합니다 – 코드가 의도한 대로 파싱되었는지 확인합니다.
타입 출력
--emit-type으로 추론된 타입을 확인합니다:
$ fn --emit-type --expr '1 + 2'
int
$ fn --emit-type --expr 'fun x -> x + 1'
int -> int
$ fn --emit-type --expr '"hello"'
string
파일 모드에서는 사용자 정의 최상위 바인딩의 타입이 모두 표시됩니다 (알파벳순 정렬, 내장 및 Prelude 바인딩 제외):
$ cat types_demo.l3
let x = 42
let greet name = "hello " + name
let result = greet "world"
$ fn --emit-type types_demo.l3
greet : string -> string
result : string
x : int
다형 타입은 타입 변수를 표시합니다:
$ fn --emit-type --expr 'fun x -> x'
'a -> 'a
$ fn --emit-type --expr 'fun f -> fun x -> f x'
('a -> 'b) -> 'a -> 'b
토큰 출력
--emit-tokens로 렉서의 원시 토큰을 확인합니다 (IndentFilter 적용 전):
$ fn --emit-tokens --expr '1 + 2'
NUMBER(1) PLUS NUMBER(2) EOF
파일 모드에서는 NEWLINE(n) 토큰이 각 줄의 들여쓰기 수준을 나타냅니다:
$ cat tokens_demo.l3
let result =
if true then 1
else 2
$ fn --emit-tokens tokens_demo.l3
LET IDENT(result) EQUALS NEWLINE(4) IF TRUE THEN NUMBER(1) NEWLINE(4) ELSE NUMBER(2) NEWLINE(0) EOF
필터된 토큰 출력
--emit-filtered-tokens로 IndentFilter를 거친 토큰을 확인합니다. NEWLINE(n)이 INDENT/DEDENT/IN/SEMICOLON으로 변환된 결과를 볼 수 있습니다:
$ cat filtered_demo.l3
module M =
let x = 1
let y = 2
let result = M.x + M.y
$ fn --emit-filtered-tokens filtered_demo.l3
MODULE IDENT(M) EQUALS INDENT LET IDENT(x) EQUALS NUMBER(1) LET IDENT(y) EQUALS NUMBER(2) DEDENT LET IDENT(result) EQUALS IDENT(M) DOT IDENT(x) PLUS IDENT(M) DOT IDENT(y) EOF
들여쓰기 관련 파싱 문제를 디버깅할 때 유용합니다. --emit-tokens는 렉서의 원시 출력을 보여주고, --emit-filtered-tokens는 파서가 실제로 받는 토큰 스트림을 보여줍니다.
타입 체크만 수행
--check는 파일을 타입 체크하지만 실행하지 않습니다. open으로 임포트된 파일도 모두 포함됩니다:
$ cat good.l3
let x = 1 + 2
let y = x * 3
$ fn --check good.l3
OK (0 warnings)
타입 오류가 있으면 에러 메시지만 출력하고 종료합니다:
$ cat bad.l3
let x = 1 + "hello"
$ fn --check bad.l3
error[E0301]: Type mismatch: expected int but got string
--> bad.l3:1:6-18
= hint: Check that all branches of your expression return the same type
CI/CD 파이프라인이나 에디터 통합에서 코드를 실행하지 않고 검증할 때 유용합니다.
의존성 트리
--deps는 파일의 임포트 의존성 트리를 출력합니다:
$ fn --deps main.l3
main.l3
lib/math.fun
lib/helpers.fun
utils/format.fun
lib/math.fun (cached)
순환 의존성이 있으면 감지하여 보고합니다.
Prelude 경로 설정
--prelude로 Prelude 디렉토리 경로를 지정합니다:
$ fn --prelude /path/to/my/Prelude myfile.l3
Prelude 경로 우선순위: --prelude > LANGTHREE_PRELUDE 환경 변수 > funproj.toml 설정 > 자동 탐색.
프로젝트 빌드
funproj.toml 파일이 있는 디렉토리에서 build와 test 서브커맨드를 사용할 수 있습니다:
$ fn build # 모든 [[executable]] 타겟 타입 체크
$ fn build myapp # 특정 타겟만 타입 체크
$ fn test # 모든 [[test]] 타겟 실행
$ fn test mytest # 특정 테스트만 실행
funproj.toml 형식:
[project]
name = "myproject"
prelude = "Prelude"
[[executable]]
name = "myapp"
main = "src/main.l3"
[[test]]
name = "mytest"
main = "tests/test.l3"
REPL
인자 없이 langthree를 실행하여 대화형 세션을 시작합니다:
$ fn
FunLang REPL v14.0
Type :help for commands, #quit or Ctrl+D to exit.
fn> 1 + 2
- : int = 3
fn> let x = 42
val x : int = 42
fn> let y = x * 2
val y : int = 84
fn> x + y
- : int = 126
fn> #quit
영속적 바인딩
REPL에서 let 바인딩은 다음 줄에서도 유지됩니다. 타입 선언, 모듈, 타입 클래스 인스턴스도 영속적입니다:
fn> type Color = | Red | Green | Blue
type defined
fn> deriving Show Color
deriving applied
fn> show Green
- : string = "Green"
REPL 명령
| 명령 | 설명 |
|---|---|
:type <expr> | 표현식의 추론된 타입만 표시 (평가하지 않음) |
:load <file> | 파일을 REPL 환경에 로드 |
:help | 사용 가능한 명령 목록 |
#quit / #exit | REPL 종료 (Ctrl+D도 가능) |
fn> :type fun x y -> x + y
int -> int -> int
fn> :type map
('a -> 'b) -> 'a list -> 'b list
출력 형식
- 표현식:
- : int = 3(타입과 값을 함께 표시) - 선언:
val x : int = 42(이름, 타입, 값) - 타입 정의:
type defined - 타입 클래스:
typeclass defined/instance defined
진단 모드 요약
| 플래그 | 설명 | 표현식 | 파일 |
|---|---|---|---|
| (없음) | 평가 후 결과 출력 | N/A | 마지막 바인딩의 값 |
--expr | 표현식 평가 | 표현식 결과 | N/A |
--emit-ast | 파싱된 AST 표시 | 표현식의 AST | 모든 선언 |
--emit-type | 추론된 타입 표시 | 표현식의 타입 | 모든 바인딩 타입 |
--emit-tokens | 렉서 원시 토큰 표시 | 원시 토큰 | NEWLINE(n) 포함 |
--emit-filtered-tokens | 필터된 토큰 표시 | 필터된 토큰 | INDENT/DEDENT/IN 포함 |
--check | 타입 체크만 (실행 안 함) | N/A | 오류/경고 출력 |
--deps | 의존성 트리 표시 | N/A | 임포트 트리 |
--prelude | Prelude 경로 지정 | 적용 | 적용 |
build [name] | 프로젝트 타입 체크 | N/A | funproj.toml |
test [name] | 프로젝트 테스트 실행 | N/A | funproj.toml |
| (인자 없음) | REPL 대화형 세션 | 줄 단위 평가 | N/A |
진단 플래그는 --expr 또는 파일 이름과 함께 사용합니다:
$ fn --emit-type --expr '1 + 2'
int
$ fn --emit-type types_demo.l3
greet : string -> string
result : string
x : int
오류 메시지
타입 오류에는 오류 코드, 소스 위치, 소스 스니펫, 힌트가 포함됩니다. 파일 모드에서는 해당 소스 라인과 ^^^ 밑줄이 표시됩니다:
$ cat type_error.l3
let x = 1
let y = x + "hello"
$ fn type_error.l3
error[E0301]: Type mismatch: expected int but got string
--> type_error.l3:2:6-19
|
2 | let y = x + "hello"
| ^^^^^^^^^^^^^
= hint: Check that all branches of your expression return the same type
표현식 모드에서는 소스 파일이 없으므로 위치만 표시됩니다:
$ fn --expr '"hello" + 1'
error[E0301]: Type mismatch: expected string but got int
--> <expr>:1:6-11
= hint: Check that all branches of your expression return the same type
“Did you mean?” 제안
변수명이나 함수명에 오타가 있으면 유사한 이름을 제안합니다:
$ cat typo.l3
let x = 1
let result = prnt (to_string x)
$ fn typo.l3
error[E0303]: Unbound variable: prnt
--> typo.l3:2:11-17
|
2 | let result = prnt (to_string x)
| ^^^^^^
= hint: Did you mean 'print'?
변수, 생성자, 모듈, 타입 클래스 이름에 대해 제안이 동작합니다.
파싱 오류
파싱 오류에는 예상하지 못한 토큰과 소스 위치가 포함됩니다:
$ cat parse_err.l3
let f x = = y
$ fn parse_err.l3
Error: parse error: unexpected EQUALS at parse_err.l3:1:8
|
1 | let f x = = y
| ^
표현식 모드의 파싱 오류는 위치 정보 없이 표시됩니다:
$ fn --expr '1 +'
Error: parse error
경고
FunLang는 잠재적 문제에 대해 경고를 발생시킵니다. 경고는 실행을 막지 않으며, 프로그램은 그대로 실행됩니다.
W0001: 불완전한 패턴 매칭
match 표현식이 모든 생성자를 다루지 않을 때 발생합니다:
$ cat incomplete.l3
type Color =
| Red
| Green
| Blue
let result =
match Red with
| Red -> 1
| Green -> 2
$ fn incomplete.l3
Warning: warning[W0001]: Incomplete pattern match. Missing cases: Blue
--> incomplete.l3:6:4-8:16
|
6 | match Red with
| ^^^^^^^^^^^^^^
= hint: Add the missing cases or a wildcard pattern '_' to cover all values
1
W0002: 중복 패턴
패턴 절에 도달할 수 없을 때 발생합니다:
$ cat redundant.l3
type Color =
| Red
| Green
| Blue
let result =
match Red with
| Red -> 1
| Green -> 2
| Blue -> 3
| Red -> 4
$ fn redundant.l3
Warning: warning[W0002]: Redundant pattern in clause 4. This case will never be reached.
--> redundant.l3:6:4-11:10
|
6 | match Red with
| ^^^^^^^^^^^^^^
= hint: Remove the unreachable pattern
1
W0003: 불완전한 예외 핸들러
try ... with 블록이 가능한 모든 예외를 처리하지 않을 때 발생합니다:
$ cat handler.l3
exception MyError of string
let result =
try
raise (MyError "oops")
with
| MyError msg -> msg
$ fn handler.l3
Warning: warning[W0003]: Non-exhaustive exception handler: not all exceptions are handled; consider adding a catch-all handler
--> :0:0-1:0
= hint: Add a catch-all handler or handle all possible exceptions
"oops"
이 경고를 없애려면 | _ -> ... 포괄 핸들러를 추가하세요.