나루 프로그래밍 언어, (아마도) 대강 보기

« 문서 목록

이 문서는 강 성훈이 설계한 일반 목적 프로그래밍 언어인 나루 프로그래밍 언어의 소개글이자 설계 문서입니다. 이 문서는 나루 프로그래밍 언어의 개발 버전인 제 4판을 기준으로 하며, 따라서 언어가 바뀌면 따라서 바뀔 수 있습니다.

이 문서에는 왜 언어의 각 기능이 그렇게 설계되었는지 설명 내지는 변명하는 문서가 딸려 있으며, 해당 설명 내지 변명으로의 링크는 이렇게 표시됩니다. (참고: 위키에서는 딱히 다르게 표시되지는 않습니다.)

이 문서는 독립된 문서위키 문서로 제공되며, 최근 변경은 머큐리얼 저장소에서 볼 수 있습니다. The English version of this document is available here.

기초

간단한 예제에서 시작합시다.

use naru
"hello world!" println()

use naru는 나루 코드를 나타내는 표시로 생략할 수 있습니다. "hello world!"는 문자열 리터럴이고, println() 메소드로 주어진 값을 출력할 수 있습니다. 보통 각 문장은 줄 바꿈으로 분리됩니다만, 필요하다면 ;를 써서 한 줄에 여러 문장을 쓸 수 있습니다.

나루는 명령형(imperative) 프로그래밍 언어로, 많은 다른 언어와 같이 변수에 값을 넣거나 변수의 값을 읽어 오는 게 가능합니다.

nirvana = ()                   -- "널" 값
sunny = true                   -- 진리값
radius = 42                    -- 정수
almostpi = 3.1415926535897932  -- 실수
something = 4 + 5j             -- 복소수 (다만 복소단위 i는 'j'로 표시)
myname = 'Kang Seonghoon'      -- 문자열 (음, 작은 따옴표냐 큰 따옴표냐는 관계가 없습니다)
poem = """
  ...
  그러나, 겨울이 지나고 나의 별에도 봄이 오면,
  무덤 위에 파란 잔디가 피어나듯이
  내 이름자 묻힌 언덕 위에도
  자랑처럼 풀이 무성할 거외다.
"""                            -- 여러 줄로 이루어진 문자열은 따옴표 세 개로 구분함

위에서 볼 수 있듯 --부터 줄 끝까지는 짧은 주석으로 프로그램 실행시에는 무시됩니다. 또한 (----)를 사용해서 긴 주석을 쓸 수도 있습니다. 편의상 앞으로 이 문서에서는 앞 문장의 결과를 --> 주석으로 표시하도록 하겠습니다.

마지막 문장에는 여러 줄로 이루어진 문자열이 있는데, 이러한 문자열은 같은 종류의 따옴표 세 개로 묶거나 다른 방법을 써서 개행 문자를 표시해야 합니다. 또한 위 코드에서 볼 수 있듯, 모든 나루 코드는 유니코드 텍스트로 읽힙니다.

나루는 여러 연산들을 기본 지원합니다. 예를 들자면 다음과 같은 산술 연산들이 있겠습니다:

(15 + 21) println()       --> 36
(15 - 21) println()       --> -6
(15 * 21) println()       --> 315
(15 / 21) println()       --> 5/7

잠시만, 분수라고요? 네, 나루는 언어 차원에서 분수를 지원합니다. 정수를 다른 정수로 나누면 분수가 나올 수도 있습니다. 나눗셈의 정수 몫과 나머지를 구하려면 다른 연산자를 써야 합니다:

(49 // 21) println()      --> 2
(49 % 21) println()       --> 7

만약 정말로 실수 값을 보고 싶다면 나누는 수나 나눠지는 수를 실수로 만들면 됩니다. 소숫점(.)이 들어 있는 모든 숫자는 항상 실수로 인식됩니다. 또는 Real from(...)을 써서 정수를 실수로 바꿀 수도 있습니다.

(49.0 / 21) println()            --> 2.333333333333
(Real from(49) / 21) println()   --> 2.333333333333

나루의 정수는 크기 제한이 없으며, 메모리가 충분하다면 얼마든지 큰 수를 쓸 수 있습니다.

(2 ** 88) println()       --> 309485009821345068724781056

나루는 문자열을 덧붙이기 위한 연산자도 제공하는데, 이 연산자는 정확히 문자열 두 개에 대해서만 작동합니다.

("Hello, " ~ "world!") println()                --> Hello, world!
("pi is about " ~ 3.14) println()               -- 오류
("pi is about " ~ String from(3.14)) println()  --> pi is about 3.14

문자열이 아닌 것을 문자열처럼 덧붙이는 경우가 워낙 많기 때문에, 나루는 문자열이 아닌 것을 자동으로 문자열로 바꿔 주는 이른바 접붙인(interpolated) 문자열도 지원합니다.

pi = 3.1415296535897932
#"pi is about #(22/7), and almost equals to #pi." println()
    --> pi is about 22/7, and almost equals to 3.1415296535897932.

뭔가 결정을 내려야 하는 일은 흔한데 나루에서는 조건문을 이 용도로 쓸 수 있습니다. 비교는 보통 생각하는 방식대로 이루어집니다. (딱 하나 주의할 점은, =은 대입이고 ==는 비교라는 것입니다. =를 조건문의 조건으로 쓸 수는 없습니다.)

age = 15
if age == 0 then
    "태어난 걸 축하합니다!" println()
elseif age < 13 then
    "당신은 어린이입니다." println() 
elseif age <= 18 then
    "당신은 청소년입니다." println()
else
    "당신은 어른입니다." println()
end

elseifelse 부분은 꼭 필요한 건 아니고 생략할 수 있습니다. 마찬가지로 then 키워드도 뒤에 다른 문장이 뒤따르지 않는다면 뺄 수 있습니다.

age = 15
if 13 <= age <= 18
    "당신은 청소년입니다." println()
end

여기서 13 <= age <= 18는 생각하는 그대로의 의미로, age가 13 이상이고 18 미만인 숫자면 true가 됩니다. 절대로 (13 <= age) <= 18과 같은 식으로 해석되지는 않습니다. 위 코드는 다음과 같이 다시 쓸 수도 있습니다:

age = 15
if 13 <= age <= 18 then "당신은 청소년입니다." println() end

문장 안에서 조건을 표현하기 위해서 then-else 수식을 쓸 수도 있습니다:

(13 <= age <= 18 then "당신은 청소년입니다." else "당신은 누구입니까?") println()

조건 수식은 if-then 문장과 혼동되기 쉽기 때문에 항상 괄호로 묶여 있어야 합니다(다만 함수 호출의 유일한 인자로 쓴다면 생략할 수 있습니다).

반복문에 대해 말하기 전에, 우선 간단한 값들로부터 만들어질 수 있는 더 복잡한 값들 몇 가지를 살펴 보기로 합시다. 아마 다들 익숙할지 모르겠지만 한 번 더 각각의 특징을 살펴 봅시다:

이것들을 어떤 상황에 써야 하는지 알아 보기 위해 간단한 예를 들면, 휴대전화에 있는 주소록을 생각해 봅시다. 주소록의 각 항목(이름, 전화번호, 주소 등)은 순서쌍으로 저장되는데, 보통 항목은 이름으로 찾아 보기 때문에 이름을 키로 하는 대응을 써서 주소록을 저장합니다. 만약 최근에 전화를 건 사람들의 목록을 저장하고 싶다면 해당하는 항목들을 벡터나, 만약 순서에 신경쓰지 않는다면 집합에 저장하면 될 것입니다.

today = (2010, "Nov", 6)             -- 순서쌍
numbers = [0, 1, 2, 2, 3, 3, 3, 3]   -- 벡터
genders = {"male", "female"}         -- 집합
countries = {                        -- 대응
    "KR" => "Republic of Korea",
    "JP" => "Japan",
    "US" => "United of States",      -- 마지막의 쉼표를 유심히 보세요!
}

마지막 문장에서 볼 수 있듯 짝이 맞는 괄호 안에서는 보통 때와는 달리 모든 줄바꿈이 무시됩니다. 또한 목록 뒤에 쉼표가 나와도 무시되는데, 위와 같이 긴 벡터나 대응 등을 쓰는 데 유용합니다.

반복문은 대응되는 복잡한 값으로부터 만들어집니다. 예를 들어서, numbers에 들어 있는 값들에 대해서 반복을 해서 각 값을 출력할 수 있습니다:

for num <- numbers do
    num println()
end

만약 대응에 대해 반복하면 키와 값을 함께 얻게 됩니다. (조건문의 then과 마찬가지로, 반복문의 do 또한 생략할 수 있습니다.)

for code, name <- countries
    code println()
    name println()
end

“모든 반복문이 복잡한 값을 필요로 한다는 건 알겠는데, 그럼 0부터 1억까지의 숫자를 출력하려면 어떻게 해야 하나요?” 걱정 마세요, 여기에는 (무려) 두 가지 방법이 있습니다:

-- 첫번째 방법
for num <- 0..1_0000_0000
    num println()
end

-- 두번째 방법
num = 0
while num <= 1_0000_0000
    num println()
end

긴 숫자 안에 들어 있는 밑줄(_)은 무시됩니다. 두번째 방법에는 while 반복문이 나오는데, 이 반복문은 가장 일반적인 형태입니다만 제대로 쓰기 힘들 수 있습니다. for 등의 다른 방법으로 표현이 안 되는 경우에만 쓰세요. (덤: 0..1_0000_0000은 중간에 있는 숫자들을 모조리 저장하지 않으므로 여러 번 써도 큰 문제가 없습니다.)

구조화

위의 기본적인 나루 코드는 나루를 계산기로 쓴다면 충분하겠지만, 실제로 프로그래밍을 하다 보면 프로그램에 구조를 만들어야 할 때가 있습니다. 나루에서 구조를 만드는 가장 기초적인 단위는 함수입니다.

double := fun (x)
    return x * 2
end

새 연산자 :=는 “선언 연산자”라고 부르며 “대입 연산자”(=)와는 구분됩니다. :=double은 어떤 방법으로도 바뀔 수 없다는 의미로, 딱히 함수가 아니더라도 이를테면 상수를 정의하는 데 쓸 수 있습니다.

PI := 3.1415926535897932

강제는 아닙니다만 웬만하면 상수 이름은 모두 대문자로 쓰는 것이 관례입니다. 마찬가지로 함수 이름은 상수 이름으로부터 구분하기 위해 대문자로 시작하지 않습니다.

함수로 돌아 가서, fun ... end는 새 함수를 만듭니다. 이 예제의 함수는 인자가 x 하나만 있고, 예상했듯 x에 2를 곱한 값을 반환합니다. 이 함수는 충분히 짧기 때문에 다음과 같이 더 줄여 쓸 수도 있습니다:

double := fun (x) -> x * 2

이렇게 두고 보니 double은 사실은 함수가 아니라, 함수 “값”을 담고 있는 이름이라는 생각도 들게 됩니다. 이 생각은 실제로 맞긴 한데, 대부분의 경우 둘을 굳이 구분할 필요는 없으므로 보통은 그냥 “함수 double”이라고 부르게 마련입니다. 따라서 굳이 함수 값에 대해 생각할 필요 없이 다음과 같이 쓰는 것도 가능합니다:

double(x) := fun
    return x * 2
end

또는 더 짧게:

double(x) := fun -> x * 2

이 “일관된” 선언 문법은 함수 값과 유사합니다만, 함수 값에서는 fun 뒤에 항상 괄호로 묶인 인자 목록이 와야 하는 반면 선언 문법에서는 아무 것도 오지 않는다는 차이가 있습니다. 뒤에 이 문법이 어떻게 확장되는지 자세히 알아 볼 것입니다.

함수들은 자기 자신을 호출할 수 있으며…

fibonacci(i) := fun
    if i == 1 then return 1
    elseif i == 2 then return 1
    else return fibonacci(i-1) + fibonacci(i-2)
    end
end
for i <- 1..10
    #"fib(#i) = #(fib(i))" println()
end

또는 다른 함수의 인자로 쓸 수도 있는 데다가…

greeting(msg, decoration) := fun
    decoration(msg) println()
end

nope(msg) := fun -> msg
squared(msg) := fun -> "[" ~ msg ~ "]"

greeting("Hello, world!", nope)        --> Hello, world!
greeting("안녕하세요!", squared)       --> [안녕하세요!]

심지어 다른 함수의 반환값이 될 수도 있습니다.

twice(f) := fun
    return fun (x) -> f(f(x))
end
fourtimes := twice(twice)
sixteentimes := fourtimes(fourtimes)
sixteentimes(double)(1) println()      --> 65536

여기서 볼 수 있듯 fun ... end는 꼭 := 오른편이 아니더라도 아무 곳에서나 나타날 수 있습니다. 또한 fun (x) -> f(f(x))라는 코드 그 자체는 고정되어 있지만, 여기서 만들어지는 함수는 f의 값에 따라 그 의미가 바뀌며 심지어 twice 바깥에서도 원래의 f 값에 의존하게 됩니다. 프로그래밍 언어 이론에서는 이를 “정적 스코핑”이라고도 부릅니다. (함수 선언과 함수 값의 차이를 이제 아시겠나요?)

인자들에 기본값을 줘서 선택 인자로 만들 수 있습니다. 잘 쓰이지 않는 인자들에 적절한 기본값을 넣어 두면 그 함수를 사용하기가 더 편리할 것입니다.

splitstring(string, delim = " \t\r\n", preservedelims = false) := fun
    -- ...
end

선언과 대입의 차이와 마찬가지로, 기본값에서 =:=는 서로 다른 의미로 쓰입니다. 전자는 함수가 호출되기 직전에 평가되는 반면(물론 그렇다고 호출 시점의 변수를 사용하거나 할 수 있는 건 아닙니다), 후자는 함수가 선언된 시점에서 바로 평가됩니다. 이 구분은 바뀔 수 있는 값들이 기본값으로 쓰일 때 큰 차이를 가져 옵니다만, 당장은 :=는 특수한 상황에서만 쓰고 대부분 =를 쓴다고 생각하는 게 편할 것입니다.

선택 인자 중 전부는 아니고 일부만 호출할 때 주고 싶다면 키워드 인자를 사용할 수 있습니다.

splitstring("alpha beta", preservedelims: true) println()

키워드 인자는 꼭 선택 인자가 아니더라도 인자를 주는 순서를 바꾸는 용도로 쓸 수도 있습니다.

splitstring(preservedelims: true, string: "alpha beta") println()

인자 갯수가 고정되어 있지 않은 함수는 마지막 인자에 ...를 덧붙여서 표시할 수 있으며, 이 가변 인자는 앞의 인자들을 뺀 나머지 인자들의 순서쌍으로 인식됩니다.

printf(fmt, args...) := fun
    -- ...
end

종종 키워드 인자로만 표현이 가능한 인자가 필요할 수도 있습니다. 예를 들어서 printf라는 함수가 있었는데, 이 함수를 이미 쓰고 있는 곳을 건들지 않은 채 outfile이라는 인자를 새로 추가하고 싶다고 합시다. 이 경우 가변 인자 뒤에 해당하는 인자를 덧붙일 수 있습니다:

printf(fmt, args..., outfile = Console) := fun
    -- ...
end

일관성을 위해 가변 인자의 이름을 생략하는 것도 가능합니다. 이 경우 이 함수는 넘치는 키워드 인자를 빼고는 항상 인자 수가 고정됩니다. (이렇게 보면, 인자 갯수가 고정된 함수는 항상 인자 목록 맨 뒤에 , ...가 붙어 있다고 생각해도 좋습니다.)

splistring(string, delim = " \t\r\n", ..., preservedelims = false) := fun
    -- ...
end

반대로 생각해서, 인자 앞에 \를 붙여서 함수에 여러 개의 인자를 주는 것도 가능합니다: (순서쌍은 후에 좀 더 자세히 설명합니다.)

args = (10, "고자")
printf("%d억 받기 대 %s 되기\n", \args)

이런 식으로 인자를 “평평하게” 펴는 문법은 꼭 마지막 인자가 아니라 인자 목록 중간에도 올 수 있습니다. 이 기능은 인자 목록을 다른 함수에 전달하는 데 매우 유용합니다.

클래스와 이것 저것

나루는 개체 지향 프로그래밍(OOP; 흔히 “객체 지향”이라고도 합니다)을 좀 지원합니다. 하지만 여기서는 “개체 지향”이라는 말을 매우 조심해서 쓸텐데, 이는 이 낱말 자체가 매우 모호한데다가 보통 개체 지향이라는 말은 나루가 지원하지 않고 지원할 예정도 없는 기능을 함께 함의하는 경우가 많기 때무입니다. 사실은 나루에서는 “개체” 내지 “객체”라는 말도 쓰지 않으며, “값”이라는 좀 더 명확하고 딱히 개체 지향과 관련이 없는 낱말을 대신 씁니다.

뭐, 일단은 계속 진행합시다. 나루 코드는 필요한 만큼 클래스를 정의할 수 있습니다:

Person(name, age) := class
    var name
    var birthday = Date today - Duration days(floor(age * 365))

    teenager := _
    teenager get() := fun -> 13 <= age <= 18

    greet(lang='en') := fun
        if lang == 'en'
            #"Hello, #(self name)!" println()
        elseif lang == 'ko'
            #"#(self name) 씨 안녕하세요." println()
        else
            "..." println()
        end
    end

    self species := "Homo sapiens sapiens"

    var self population = 0
    self exterminate() := fun
        self population = 0
    end
end

hacker = Person('J. Random Hacker', 93/4)
Person population += 1
hacker species println()      --> Homo sapiens sapiens
floor(hacker age) println()   --> 23
hacker teenager println()     --> false
hacker greet()                --> Hello, J. Random Hacker!
hacker name = "홍길동"
hacker greet('ko')            --> 홍길동 씨 안녕하세요.
Person exterminate()
Person population println()   --> 0

모든 클래스에는 다음과 같은 요소들이 들어 있습니다:

0개 이상의 생성자 인자

이 인자들은 class 키워드 뒤나, 일관된 선언 문법을 쓸 경우 클래스 이름 뒤에 옵니다. 위 코드에서는 Person은 두 개의 생성자 인자, nameage를 가집니다. 클래스 이름을 함수처럼 써서 호출하면 주어진 인자로부터 해당 클래스의 값이 만들어져 반환됩니다. 생성자 인자는 클래스 안쪽에서도 사용 가능하고, 바깥에서도 보통의 속성 문법(value attr)을 써서 접근할 수 있습니다.

1개 이상의 기반 클래스 (생략 가능)

기반 클래스의 목록은 class 키워드 및 (만약 있다면) 생성자 인자 뒤에 따라 옵니다. 기반 클래스는 구체적(concrete; class로 선언된 클래스)이거나 추상적(abstract; trait으로 선언된 클래스)일 수 있으며, 기반 클래스가 구체적이라면 클래스 이름 뒤에 대응되는 생성자 인자가 들어 와야 합니다.

0개 이상의 변경 가능한 속성

변경 가능한 속성(attribute)은 var 키워드로 선언됩니다. (클래스 바깥에서도 이 문법을 쓸 수 있긴 한데, 좀 의미가 다릅니다.) 생성자 인자는 기본적으로 변경이 불가능하지만, 변경 가능한 속성들은 속성 대입 문법(value attr = newvalue)을 써서 변경할 수 있습니다. 만약 변경 가능한 속성과 생성자 이름이 똑같다면, 해당 이름은 변경 가능하며 두 용도 모두로 쓸 수 있습니다.

0개 이상의 변경 불가능한 속성

이들은 변경 가능한 속성과 비슷하지만 var이 앞에 붙지 않습니다. 한 번 정의되면 이들 속성은 변경할 수 없습니다. TODO is there a difference between x = ... and x := ... here?

속성의 초기값

초기값은 생성자 인자와 이름이 같은 변경 가능한 속성에 대해서는 꼭 쓸 필요는 없으나, 나머지 경우에는 항상 써야 합니다. 이들은 처음으로 생성자가 호출될 때 단 한 번 평가되는데, 이 때는 아직 self가 존재하지 않기 때문에 self age와 같은 표현은 불가능하며 age라고만 쓸 수 있습니다. (몇 가지 분명히 해 두자면, 그냥 age를 메소드 안에서 쓰는 것도 가능합니다만 이 경우 그 의미는 self age가 가리키듯 self 안의 “현재” 속성값이 아니라, self가 처음 만들어질 적에 생성자 인자로 들어 온 값이 됩니다. 그냥 age를 함수 인자와 같은 의미로 생각하는 게 이해에 도움이 될 것입니다.)

초기값을 정하지 않을 수도 있는데, 이 경우 빈 자리를 나타내는 _를 초기값 대신에 씁니다. 이 경우 이 클래스의 자식 클래스는 어떻게든 해당 초기값을 정해야 하며, 모든 필요한 초기값을 (_가 아닌 값으로) 정하지 않은 구체적 클래스는 만들 수 없습니다. 또한 _는 부속성(subattribute)으로 정하는 것도 가능합니다.

0개 이상의 메소드

메소드는 보통의 일관된 선언 문법을 사용하는 함수 선언과 유사하지만, self 인자가 자동으로 메소드 인자에서 사용 가능하다는 것이 다릅니다. (self는 실제로는 키워드이므로 인자 이름으로 쓰고 싶어도 쓸 수는 없습니다.) 그냥 선언 문법을 써서 메소드를 정의하는 것도 안 되는 건 아닙니다만, 이 경우 self는 사용할 수 없게 되며 오로지 생성자 인자만 쓸 수 있게 됩니다.

속성과 마찬가지로 _를 서서 함수 몸체를 생략할 수 있으며, 이 경우 자식 클래스나 부속성을 사용해서 나중에 함수 몸체를 정해야 한다는 의미가 됩니다.

0개 이상의 부속성부메소드

부속성(subattribute)과 부메소드(submethod)는 속성 및 메소드에 따라 붙은 추가적인 정보입니다. 이들은 보통 속성(부속성의 경우)이나 메소드(부메소드의 경우)와 비슷하게 정의됩니다만, 이를 정의할 속성이나 메소드의 이름이 앞에 덧붙는다는 차이가 있습니다(great doc := ...). 선언 방법 및 self의 존재 여부와 같은 사소한 차이를 빼면 부속성과 부메소드는 사실상 같습니다.

부속성과 부메소드는 자유롭게 쓸 수 있지만, 몇몇은 컴파일러가 특수하게 처리합니다:

  • doc 부속성은 해당 속성·메소드의 내장 문서를 제공합니다.
  • get 부메소드는 해당 속성·메소드가 _로 선언되어 있는 상태에서 그 속성·메소드를 접근하려고 할 때 호출됩니다. 즉, SomeClass attr이 빈 자리로 남아 있을 경우 (만약 있다면) SomeClass attr get()이 대신 호출됩니다. 이 부메소드는 메소드에도 적용 가능하며, 이 경우 그 반환값은 또 다른 함수여야 합니다(!).
  • set 부메소드는 get과 비슷하지만, 해당 속성·메소드가 = 연산자로 대입될 때 호출됩니다. set은 인자 하나를 받아야 하며 그 반환값은 무시됩니다. :=를 제외한 다른 종류의 대입(+= 등)는 자동으로 =를 사용한 대입으로 변환됩니다.
  • bind 부메소드는 set과 비슷하지만, 해당 속성·메소드가 := 연산자로 대입될 때 호출됩니다.
클래스 속성, 메소드, 부속성 및 부메소드

이들은 인스턴스(클래스로부터 생성된 값)에 속한 보통 속성, 메소드, 부속성 및 부메소드와 비슷합니다만, self가 앞에 붙습니다. 이들은 클래스로부터 직접 접근할 수도 있고 인스턴스로부터 접근할 수도 있습니다. 클래스 메소드와 부메소드의 경우 self는 클래스에 대한 정보를 담고 있는 클래스 값을 가리키며, 심지어 인스턴스에서 호출할 때도 인스턴스가 들어 오지는 않습니다. (같은 이유로, 이들 메소드 안에서는 생성자 인자를 사용할 수 없습니다.)

나루에는 엄밀한 의미에서 접근 범위(visibility)가 없습니다. 클래스 바깥에서 쓰지 않게 하고 싶은 이름에는 _를 붙일 수는 있습니다만(_debug처럼), 그렇다고 사용자가 해당 이름을 아예 못 쓰게 되는 건 아닙니다. 다만 _를 붙이는 것 자체는 해당 이름이 언제고 바뀔 수 있다는 걸 사용자에게 알려 주므로 좋은 습관이라 할 수 있습니다.

클래스 선언은 함수 선언과 매우 유사합니다만, 함수 선언과는 달리 임의의 문장을 클래스 선언에 넣을 수는 없습니다. 만약 생성자에서 복잡한 일을 하고 싶다면 initialize 메소드를 써야 합니다:

NonNegative(value) := class
    initialize() := fun
        if value < 0 then
            #"경고: #(value)가 0보다 작습니다." println()
        end
        return self
    end
end

NonNegative(4) println()       --> <NonNegative @b0d3a0>
NonNegative(-4)                --> 경고: -4가 0보다 작습니다.

앞에서 말했듯이 여기서 말하는 value는 생성자 인자를 의미하는 것이므로 self value로 쓰지 않아도 됩니다. 사실은, 다음 두 코드는 정확히 동일합니다:

Date1(year, month, day) := class
    var year
    var month
    var day
end

Date2(year, month, day) := class
    -- TODO still needs explicit slot definition here
    initialize := fun
        self year = year
        self month = month
        self day = day
    end
end

이런 식으로 생성자 인자가 변경 가능한 속성이었으면 하는 경우가 자주 있기 때문에, 사실은 다음과 같이 간단하게 쓰는 것도 가능합니다:

Date(var year, var month, var day) := class
end

상속

클래스는 이미 존재하는 다른 클래스에서 상속받을 수 있습니다:

Date(year, month, day) := class
    weekday() := fun
        leapyear(y) := fun -> y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)

        years = self year - 1
        days = 365 * years + years // 4 - years // 100 + years // 400
        if leapyear(self year) then
            days += [0, 3, 3, 6, 1, 4, 6, 2, 5, 0, 3, 5][self month - 1]
        else
            days += [0, 3, 4, 0, 2, 5, 0, 3, 6, 1, 4, 6][self month - 1]
        end
        weekday = (days + self day - 1) % 7
        return ["월", "화", "수", "목", "금", "토", "일"][weekday]
    end
end

DateTime(year, month, day, hour, minute, second) := class <- Date(year, month, day)
end

"#(DateTime(2010, 11, 7, 18, 40, 2) weekday())요일"      --> 일요일

여기서 값 DateTime(y,m,d,h,n,s)Date의 메소드가 사용될 때는 Date(y,m,d)와 같은 의미가 됩니다. 이미 존재하는 Date weekday의 선언을 DateTime weekday가 덮어 씌우는 것도 가능하며, 이 경우 DateTime weekday 안에서 super weekday()를 사용해서 기반 클래스의 weekday 메소드를 호출할 수 있습니다.

뒤에서 살펴보겠지만 나루는 다중 상속 또한 지원합니다. 그리고 위의 Date 클래스처럼 기반 클래스 목록이 빠져 있을 때는 Thing 클래스로부터 자동으로 상속받게 됩니다.

TODO

트레이트

종종 인스턴스화는 될 일이 없지만 자식 클래스에게 공통된 기능을 제공하기 위한 클래스 “틀”이 필요한 경우가 있습니다. 예를 들어서, Circle, Square, Rectangle 클래스가 있다면 아무래도 SquareRectangle의 자식 클래스이겠습니다만, CircleRectangle 또한 area 같은 메소드를 제공하기 위해서 공통의 클래스가 필요할 것입니다. 따라서 나루는 추상적 클래스, 즉 트레이트(trait)를 지원합니다.

Shape := trait
    circumference := _
    area := _

    -- 대부분의 모양은 회전 대칭을 가지지 않으므로...
    rotational_symmetry := false
end

PI := 3.1415926535

Circle(radius) := class <- Shape
    circumference := 2 * PI * radius
    area := PI * radius**2
    rotational_symmetry := true
end

Rectangle(width, height) := class <- Shape
    circumference := 2 * (width + height)
    area := width * height
end

Square(side) := class <- Rectangle(side, side)
    rotational_symmetry := true
end

트레이트는 클래스와 매우 유사합니다만 생성자 인자가 아예 없습니다. 트레이트에도 구체적인 속성이나 메소드가 들어 있을 수 있으며, 심지어 모든 속성이나 메소드를 트레이트에 밀어 넣은 뒤 몸체가 비어 있는 자식 클래스를 해당 트레이트로부터 만들어 내는 것도 가능합니다. 트레이트는 다중 상속에서 훨씬 더 편하게 쓸 수 있기 때문에 추상적 클래스를 정의하거나 복잡한 클래스 계층을 만드는 데 흔히 쓰입니다.

기반 클래스 목록이 없을 때 자동으로 사용되는 Thing은 트레이트의 대표적인 예입니다. 물론 Thing에는 유용한 여러 메소드가 존재하지요.

다형성

다형성(polymorphism)이라는 말은 어려워 보이지만, 사실 프로그래밍 언어에서는 그냥 여러 가지 것들을 한 가지로 일반화하는 모든 방법을 가리킵니다. 이를테면 클래스를 일반화하기 위해서 기반 클래스를 도입하는 것도 다형성의 일종으로, 흔히 서브타입 다형성(subtype polymorphism)이라 부릅니다. 또 다른 종류의 다형성으로는 (보통은 타입인) 인자를 가지고 하나의 클래스 틀로부터 여러 가지의 비슷한 클래스를 만들어 내는 게 있는데, 이는 매개화 다형성(parametric polymorphism)이라고 부릅니다. 나루는 두 종류의 다형성을 모두 지원하지만, 여기서 모든 걸 설명하기는 좀 어렵군요.

(덤: 네, 사실은 이것 말고도 세 번째 다형성, 즉 임시변통 다형성[ad-hoc polymorphism]이 있고, C++의 오버로딩 같은 게 여기 해당합니다. 하지만 이 문서에서는 이를 다형성으로 취급하지는 않는데, 다른 다형성들은 문맥에 따라 다르게 행동하는 하나의 무언가를 만들어 내지만 임시변통 다형성은 그냥 이름이 같은 여러 개의 무언가를 만들어 내기 때문입니다. 어쨌든 나루는 이 임시변통 다형성 역시 합집합 타입을 통해 지원하긴 합니다.)

다중 상속

나루는 서브타입 다형성을 제공하는 과정에서 다중 상속 또한 지원합니다. C++를 써 본 사람이라면 이 즈음에서 두려워하겠지만, 나루의 다중 상속 지원은 C++보다는 더 안전합니다. 먼저 이 예제를 봅시다:

A(x, y) := class
end

B(x, y, z) := class <- A(x, y)
end

C(y, x) := class <- A(x, y)
end

D(x, y, z) := class <- B(x, y, z), C(y, x)
end

이 코드에서 D(x, y, z)는 각 기반 클래스의 메소드를 쓸 때 B(x, y, z), C(y, x)A(x, y)와 같은 의미로 해석됩니다. 이런 식으로 정의된 생성자 인자는 서로 충돌할 수 없습니다. 이를테면, C의 선언에서 C(x, y) := class <- A(x, x)라고 썼다면 D의 생성자 인자에서는 x == y를 만족해야 하는데 그렇지 못 하므로 오류가 됩니다. (같은 이유로, 기반 클래스 목록에 나오는 생성자 인자는 임의의 수식이 될 수 없습니다.) 트레이트는 생성자 인자가 없으므로 이 문제에서 자유롭습니다.

서로 충돌하는 속성, 부속성, 메소드 등등은 모두 오류로 처리됩니다:

A := trait
    f() := _
end

B := trait <- A
    f() := fun -> 42
end

C := trait <- A
    f() := fun -> "some string"
end

D() := class <- B, C
    -- 오류: f()가 정의되어 있지 않으므로 `B f`와 `C f`가 충돌
end

비슷하게 속성 및 메소드 등의 타입도 제약을 받는데, 만약 B fC fA f의 타입보다 더 좁게 선언되었다면, D f의 타입 또한 적어도 B fC f의 타입만큼은 좁도록 선언되어야 합니다. (자세한 규칙은 교집합 타입을 보세요.)

TODO overriding rules; this is too complex and has lots of corner cases. heck.

정적 인자

나루는 생성자 인자 앞에 {}로 묶인 정적 인자를 통해 매개화 다형성을 지원합니다: (일단은 못 본 문법은 여기서 무시하세요.)

LinkedList{+T}(head: T, tail: LinkedList{T}? = none) := class
    print() := fun
        self head print()
        if next <- self tail then
            ", " print()
            next print()
        end
    end

    global String from(self) := fun
        return "<LinkedList of type #T>"
    end
end

LinkedList(3, LinkedList(4, LinkedList(5))) print()    --> 3, 4, 5

여기서 {+T}는 타입 인자로, 대응이라면 {Key, Value} 같은 것이 될 겁니다. 하지만 +는 무엇일까요? 이건 이를테면 LinkedList{Rational} 타입의 값이 나와야 할 자리에 LinkedList{Int} 타입의 값을 넣어도 된다는 말입니다. IntRational이 들어 갈 수 있는 모든 곳에 들어 갈 수 있으므로 LinkedList{Int}LinkedList{Rational}이 동등하게 작동해야 한다는 건 일리가 있습니다. 마찬가지로 -를 쓰면, 순서가 바뀌어 LinkedList{Int} 타입을 쓸 자리에 LInkedList{Rational}을 쓸 수 있다는 의미가 됩니다. +- 같은 게 없다면 둘은 서로 호환되지 않습니다. 프로그래밍 언어 이론의 용어로 말하자면, +는 타입 인자가 공변적(covariant)이고 -반변적(contravariant)임을 나타내며, 아무 것도 없을 때는 불변적(invariant)임을 나타냅니다.

대부분의 경우 +-를 직접 쓰려고 머리를 썩일 필요는 없습니다(웬만해서는 안 쓰는 게 맞습니다)만, 아주 일반적인, 이를테면 콜렉션 클래스 같은 걸 만들 때는 이 부분에 있어 주의를 기울일 필요가 있습니다. Rational의 목록이 쓰일 수 있는 곳에서는 웬만하면 Int의 목록이 쓰일 수 있으면 참 좋겠죠, 안 그래요? 하지만 여기에는 몇 가지 문제가 있는데, 변경 가능한 콜렉션의 경우 공변적일 수 없습니다. (간단하게 설명하면, 콜렉션의 원소를 읽을 때는 공변적인 타입 인자가 필요하고 변경할 때는 반변적인 타입 인자가 필요합니다. 둘 다 만족하려면 불변적이어야 하죠.)

타입 인자를 가진 클래스를 사용하는 건 정의하는 것보다는 훨씬 쉽습니다. 위 코드의 맨 마지막 줄은 사실 다음과 같습니다:

LinkedList{Int}(3, LinkedList{Int}(4, LinkedList{Int}(5))) print()

하지만 우리는 이미 headInt를 받으므로 모든 T 타입 인자가 Int로 처리된다는 걸 알기 때문에, 위 코드에서는 {Int} 인자를 모두 생략했습니다. 물론 이런 식으로 추론이 불가능한 경우(이를테면 아무 인자도 없는 목록) 직접 인자를 써 줘야 합니다. (한 가지 대안으로는 type 키워드를 쓰는 게 있는데, 뒤에서 설명할 것입니다.)

T는 공변적이므로 다음 두 줄은 서로 동일합니다:

LinkedList(4.0, LinkedList(3/4, LinkedList(5))) print()
LinkedList{Real}(4.0, LinkedList{Rational}(3/4, LinkedList{Int}(5))) print()
-- 여기서는 LinkedList{Real}가 올 자리에 LinkedList{Rational}(...)를 썼음

반변적인 T의 경우에도 비슷한 대응이 성립합니다. 따라서 타입 유추는 타입 인자가 공변적·반변적·불변적이냐에 따라 영향을 받습니다.

클래스 선언의 타입 인자는 생성자 인자와 동일하게 취급되어, Tself T 같은 수식을 클래스 안에서 쓸 수 있습니다. 당연한 얘기지만 타입 인자를 변경 가능한 속성으로 다시 선언할 수는 없습니다.

TODO type parameters on functions; in this case we may automatically infer + and - but it will be inconsistent with ones on classes…

TODO restrictions on type parameters (maybe {T <- U} or so)

오류 다루기

대부분의 경우 우리는 프로그램 실행 도중 발생할 수 있는 오류들을 처리해야 합니다. 간단한 나눗셈이라도 여차하면 오류가 날 수 있고(0으로 나누기), 파일을 열고 몇 줄 읽으려고 해도 다른 종류의 오류가 날 수 있습니다(파일이 없거나, 접근이 차단되었거나, 파일 끝에 다다랐거나, 심지어 인터럽트에 걸리거나…). 나루는 이들 오류를 다루기 위해 예외 처리를 지원합니다.

나루에서 예외는 Exception 클래스의 값이고, raise 문으로 예외를 던질 수 있습니다.

raise RuntimeError('외계인이 침공했다!')

던진 예외는 but 블록을 사용해서 받을 수 있습니다:

do
    raise RuntimeError('외계인이 침공했다!')
but Exception as e
    #"예외 발생: #(e message)" println()
end

좀 더 일반적으로, dofun 블록에는 예외를 처리하는 but, elsefinally 블록이 들어갈 수 있습니다. 이들은 주어진 타입(또는 타입이 주어지지 않은 경우, 모든 타입)의 예외가 발생했을 때나, 아무 예외도 발생하지 않았거나, 예외 발생 여부와는 관계 없이 코드 실행이 블록을 빠져나가기 직전에 각각 실행됩니다:

do
    -- ...
but ExceptionA as e
    -- ExceptionA 타입이나 그 서브타입의 예외가 발생했을 경우 실행됨
    -- 새 변수 e를 쓸 수 있음
but ExceptionB
    -- ExceptionB 타입이나 그 서브타입의 예외가 발생했을 경우 실행됨
    -- 다만 그 예외가 ExceptionA의 서브타입이기도 할 때는 예외
but
    -- ExceptionA나 ExceptionB의 서브타입이 아닌 예외가 발생했을 경우 실행됨
else
    -- 아무 예외도 발생하지 않았을 때 실행됨
finally
    -- `but`이나 `else` 블록이 (만약 있다면) 실행되고 나서 실행됨
    -- 대부분은 그러하나, 프로그램이 시그널을 잡았다거나 하면 안 실행될 수도 있음
end

but 블록 안에서는 아무 인자도 없는 raise 문을 써서 현재 잡은 예외를 바깥에 다시 던질 수 있습니다. (여기서 말하는 바깥은 물론 현재 블록에 딸려 있는 but 블록은 빼고 말하는 겁니다.)

큼지막한 but 블록을 간단하게 쓰기 위한 다른 문법으로, 만약 처리할 예외가 딱 한 종류일 경우 but 수식을 대신 쓸 수 있습니다. (기술적으로는 ((... but ...) but ...) but ... 식으로 여러 예외를 처리할 순 있지만, 이럴 바에는 차라리 do 블록을 쓰는 게 낫죠.)

(4/0 but ZeroDivisionError -> -1) println()                     --> -1
(4/0 but ZeroDivisionError as e -> e length sign) println()     --> 1
(4/0 but 42) println()                                          --> 42

with 블록

but이 흔히 쓰이는 한 가지 경우로 자원을 관리할 때가 있습니다. 예를 들어서 뭔가 파일을 써야 한다고 치면, 가장 간단한 코드는 다음과 같이 될 겁니다:

f = File("forty-two") openrw()
"The answer to the life, universe and everything." println(f)

파일 입출력에 대한 좀 더 자세한 내용은 나중에 나옵니다만, 여기서는 설명 없이 그대로 쓰기로 하겠습니다. 그나저나 이 코드가 올바른가요? 사실은 이렇게 생겨야 하는 게 아닌가요?

f = File("forty-two") openrw()
"The answer to the life, universe and everything." println(f)
f close()        -- 빠졌네!

일반적으로는 파일을 열고 나서는 언젠가 닫아야죠. 물론 쓰레기 수집(garbage collection)이 열심히 되고 있다면 딱히 안 닫아도 언젠가는 f가 소멸될 때 자동으로 닫히긴 하겠지만… 웬만하면 확실히 해 둡시다. 근데, 만약 파일에 뭔가를 쓰다가 인터럽트에 걸리면 어떻게 될까요?

f = File("forty-two") openrw()
do
    "The answer to the life, universe and everything." println(f)
finally
    f close()
end

이 코드는 예외가 발생할 때도 항상 파일을 제 때 닫습니다만, 여전히 좀 복잡해 보입니다. 이런 패턴이 굉장히 흔하기 때문에 나루에서는 이를 간소화시키는 with 블록을 제공합니다.

with f <- File("forty-two") openrw()
    "The answer to the life, universe and everything." println(f)
end

with 블록은 자동으로 위의 코드와 유사한 do-finally 블록을 생성합니다. 좀 더 일반적으로, 블록이 시작되기 직전에 주어진 값의 enter() 메소드가 호출되고 끝나기 전에 exit() 메소드가 호출됩니다. (File 값의 경우 exitclose와 같은 의미입니다.) 또한 but, else, 심지어 finally(!) 블록을 with 블록 안에 쓰는 것도 가능합니다:

with f <- File("forty-two") openrw()
    "The answer to the life, universe and everything." println(f)
but Exception as e
    -- 이 블록은 File(...) 호출이 실패했을 때 실행됨.
    #"exception caught while writing the file: #(e message)" println()
finally
    -- 이 블록은 exit 메소드가 호출되기 직전에 실행됨.
    "the file is about to be closed!" println()
end

with를 써서 얻을 수 있는 마지막 한 가지 장점은 주어진 변수는 블록이 끝나고 나서 자동으로 사라진다는 점입니다. 따라서 설령 변수 이름이 겹치더라도 혼란을 막을 수 있습니다.

f = 42
with f <- File("forty-two") openrw()
    "The answer to the life, universe and everything." println(f)
end
f println()                      -- 42

특수한 이름과 여러 문제들

아마도 이 시점에서 누군가는 도대체 내장된 값들을 클래스를 알지도 못 한 채 어떻게 쓸 수 있는지 궁금해할 겁니다. 사실은 내장된 값들도 클래스를 가지며, class 속성으로 해당하는 클래스 값을 얻을 수 있습니다. (class는 속성 문법에서 허용되는 몇 안 되는 키워드 중 하나입니다.)

() class println()                 --> <type ()>
true class println()               --> <type True>
false class println()              --> <type False>
42 class println()                 --> <type SmallInt>
(4/7) class println()              --> <type Ratio>
3.14 class println()               --> <type Decimal>
(3 + 4j) class println()           --> <type CDecimal>
?x class println()                 --> <type Char>
"string" class println()           --> <type String>
[1, 2, 3] class println()          --> <type Vector{Int}>
{1, 2, 3} class println()          --> <type Set{Int}>
{0=>'F', 1=>'T'} class println()   --> <type Map{Int, String}>
(3, false, 'foo') class println()  --> <type (Int, False, String)>
42 println class println()         --> <type ()->()>
abs class println()                --> <type (Int)->Int | (Rational)->Rational | (Real)->Real | (Complex)->Complex>

보통 생각하는 Int, Rational, Real, Complex 대신 SmallInt, Ratio, Decimal, CDecimal 같은 생소한 이름이 나오는데, 이는 내부 구현을 드러내는 것으로 자세한 설명은 타입 계층을 살펴 보시길 바랍니다. 한편 맨 마지막 줄에서는 오버로딩된 함수 타입을 볼 수 있는데 이 또한 당장은 설명하지 않겠습니다.

다른 질문으로는 +, *, ~ 같은 연산자가 어떻게 동작하는지에 대한 것이 있겠는데, 사실 이들 또한 보통의 함수랑 다르지 않으며 단지 조금 특별한 이름, 즉 #+#, #*#, #~# 등을 가질 뿐입니다.

Gaussian(re, im) := class <- CDecimal(re, im)
    initialize() := fun
        if re != floor(re) or im != floor(im)
            raise ValueError("가우시안 수는 정수 인자를 필요로 합니다.")
        end
        self
    end

    global #**#(self, other:Int)->Gaussian := fun
        if other < 0 then
            return 1 / self ** (-other)
        elseif other > 0 then
            -- 이진 거듭제곱 알고리즘
            half = self ** (other // 2)
            if other % 2 == 0 then
                return half * half
            else
                return half * half * self
            end
        else
            return 1
        end
    end
end

#**# 외에도 몇 가지 새로운 문법이 눈에 띕니다:

따라서 다음 수식은…

Gaussian(3, 4) ** 5

…다음과 같으며…

#**#(Gaussian(3, 4), 5)

…예상한 대로 Gaussian 클래스 안에서 선언되었던 #**# 함수를 호출하게 됩니다. 나루에서는 대부분의 연산자와 문법을 이와 비슷한 방법으로 함수만을 사용해서 나타낼 수 있습니다:

연산자 함수형
op b b op#()
a op a #op()
a op b #op#(a, b)
a op= b a = a #op=#(b)
a op1 b op2 a #op1#op2(b)
a op1 b op2 = c a #op1#op2=#(b, c)
a op1 b op2 := c a #op1#op2:=#(b, c)

예를 들어 함수 호출 a(b)a #(#)(b)와 동일합니다. op=의 경우는 항상 self를 반환해야 한다는 점에 주의해야 합니다(또는 해당 클래스가 변경 불가능하면, 새로운 값을 반환해야 합니다). 마지막으로 #{#}=#, #{#}:=#, #(#)=#, #(#):=#는 문법의 모호함을 피하기 위해 사용할 수 없습니다.

타입 계층

나루의 모든 것은, 심지어 클래스까지도 값이고, 모든 값은 class “속성”을 가지기 때문에, 나루에서 모든 것은 어떤 타입의 값이어야 합니다. (엄밀히 말하면 나루는 “타입”과 “클래스”를 명확히 구분합니다만, 이 장에서만은 “타입”을 class 속성이 반환하는 값을 나타내는 데 쓰겠습니다.) 나루에서 타입은 다음과 같은 커다란 계층을 이룹니다:

Value
|- Function
|  +- ...                (Function1{-T1,+U}, Function2{-T1,-T2,+U} 등을 포함)
|- Tuple
|  +- ...                (TODO)
+- Thing
   |- Type
   |- Ordered
   |  |- Bool
   |  |  |- False
   |  |  +- True
   |  |- Number
   |  |  +- ...          (Complex, Real, Rational, Int 등을 포함)
   |  |- Char
   |  |- Date
   |  |  +- DateTime
   |  |- Time
   |  |  +- DateTime
   |  |- Week
   |  +- Duration
   |- Enumerable{+T}
   |  +- ...             (Range{+T}, Vector{T}, String 등을 포함)
   |- Stream
   |  |- Reader
   |  |  +- Console
   |  +- Writer
   |     +- Console
   |- File
   +- BaseException
      +- Exception

기본적으로 모든 값은 Value 클래스로부터 유래하며, 함수와 순서쌍을 제외한 “일반적인” 값들은 Thing 클래스로부터 유래합니다. 모든 순서가 있는 (좀 더 정확히는, 부분 순서[partial order]를 가지는) 값은 Ordered 클래스로부터 유래합니다. 모든 예외는 BaseException 클래스의 서브클래스이지만, 언어가 내부적으로 특별하게 사용하는 예외(이를테면 SystemExit)가 있기 때문에 일반적으로는 Exception을 쓰는 게 더 좋습니다.

NumberEnumerable은 그 자체로 큰 계층을 이루기 때문에 여기서는 다루지 않고 나중에 다룹니다.

묵시적 변환

TODO TypeName from multimethod

순서쌍

나루의 순서쌍은 다른 타입을 가지는 고정된 갯수의 값을 담습니다. 순서쌍은 변경 불가능한 콜렉션과 매우 유사하지만, 기본적으로는 콜렉션은 같은 타입을 가지는 임의 갯수의 값을 담기 때문에 둘의 사용처는 서로 다릅니다. 이 사실은 순서쌍과 콜렉션의 타입을 봐도 알 수 있는데, 숫자 세 개를 담는 순서쌍의 타입은 정확히 세 개의 타입이 포함되어 있는 반면((Number,Number,Number)) 벡터는 그렇지 않습니다(Vector{Number}).

순서쌍은 1, 2, 3이나 (1, 2, 3)과 같이 쉼표 연산자로 만들어집니다. 괄호를 꼭 쓸 필요는 없지만 없으면 순서쌍이 아닌 다른 문법이 되는 경우도 있습니다(이를테면 f(3, 4, 5)는 인자 세 개로 함수를 호출하지만 f((1, 2, 3))은 순서쌍 인자 하나로 함수를 호출합니다). 대입 연산자(= 등등)나 return 문장과 같은 것들은 쉼표보다 늦게 결합하므로, a = 3, 4, 5와 같은 문장은 a = (3, 4, 5)와 동일하게 해석되며 return 3, 4, 5 또한 마찬가지로 return (3, 4, 5)와 같습니다.

값이 하나도 없는 순서쌍은 ()라 씁니다. 이 순서쌍은 함수에서 return 문으로 반환값을 지정하지 않았을 때 반환되는 기본값입니다. 파이썬 같은 다른 언어와는 달리 값이 정확히 하나 있는 순서쌍은 존재하지 않습니다.

순서쌍 부수기: 순서쌍 문법은 대입의 왼편에 위치할 수도 있는데, 이는 오른편 역시 순서쌍이어야 하며(또는 뒤에 살펴보겠지만 순서쌍 비스무레한 값이어도 됩니다) 순서쌍의 대응되는 변수와 값들끼리 대입이 일어남을 나타냅니다. 예를 들어:

a = 3, 4, 5
b, c, d = a
#"b=#b c=#c d=#d" println()      -- b=3 c=4 d=5
b2, c2 = a                       -- 오류

값이 하나도 없는 순서쌍 () 또한 이런 식으로 왼편에 나올 수 있습니다. 이 경우 대입의 오른편은 항상 ()이어야 하며, 함수가 예상치 못 한 의미 있는 값을 반환하지 않는다는 걸 확인하는 데 쓸 수 있습니다. (함수를 그냥 한 문장으로 호출할 경우 반환값은 무시되므로 이를 확인할 수 없습니다.)

() = foo()

순서쌍에서 일부 값을 무시하려면 변수 이름 대신 _로 빈 자리를 채우면 됩니다. 나루에서 _는 예약된 이름이기 때문에 변수 이름과 겹칠 일은 없습니다.

first := fun (t); a, _, _ = t; return a; end
second := fun (t); _, b, _ = t; return b; end
third := fun (t); _, _, c = t; return c; end
first((3, 4, 5)) println()       -- 3

줄임표(...)를 써서 순서쌍의 남은 부분을 다른 순서쌍으로 받아 올 수도 있는데, 이는 함수 호출과 유사합니다.

a, b, c... = 3, 4, 5, 6, 7     -- c will get (5, 6, 7)
d, e, f... = 3, 4, 5           -- f will get 5
g, h, i... = 3, 4              -- i will get ()

-- 순서쌍이 아닌 값도 값이 하나 있는 순서쌍처럼 취급되어 부술 수 있음
x, y... = 1                    -- x=1, y=()

_...를 함께 쓸 수도 있기 때문에, 위의 first, second, third를 좀 더 일반화해서 임의 갯수의 순서쌍에 대해서 쓰는 것도 가능합니다:

first := fun (t); a, _... = t; return a; end
second := fun (t); _, b, _... = t; return b; end
third := fun (t); _, _, c, _... = t; return c; end
first(3) println()             -- 3
first((3, 4)) println()        -- 3
first((3, 4, 5)) println()     -- 3
third((1, 2)) println()        -- (당연하게도) 오류

순서쌍의 각 원소 접근하기: 순서쌍은 #0부터 #n(n은 순서쌍의 값 갯수 빼기 1)까지 이름이 붙은 특수한 속성들을 가지고 있는데, 이는 앞에서 언급한 first, second, third 등의 함수와 동일한 역할을 합니다.

-- 이 코드는...
c, b, a = t

-- ...다음과 같습니다(t의 값 갯수가 고정되지 않는다는 것만 빼고):
c = t #0
b = t #1
a = t #2

일관성을 위해 #0은 순서쌍이 아닌 값에서도 쓸 수 있으며, 이 경우 vv #0은 정확히 동일합니다. 또한 순서쌍 안의 값 갯수를 나타내는 tuplelength 속성도 존재하는데, 이 역시 순서쌍이 아닌 값에서는 일관성을 위해 1을 반환합니다.

순서쌍에 대한 다른 연산들: 덧붙이기 연산자 ~는 순서쌍에서도 쓸 수 있습니다:

((1, 2, 3) ~ (4, 5, 6)) println()      -- (1, 2, 3, 4, 5, 6)

함수 인자를 펴는 데 쓰는 \ 역시 순서쌍을 만들 때도 사용할 수 있습니다:

a = (2, 3)
(1, \a, 4) println()       -- (1, 2, 3, 4)

그러나 이들은 다소 타입에 민감하기 때문에, 정적 타입을 사용할 때는 주의할 필요가 있습니다. 예를 들어 다음 코드는 실행 전에 오류가 납니다:

f(t, u) := fun
    use static         -- 이 함수 안에서는 타입 유추를 쓰도록 함

    a, b, c... = t
    -- t는 (Ta, Tb, \Tc) 타입이어야 함
    d, e = u
    -- u는 (Td, Te) 타입이어야 함

    -- u ~ t == (d, e, a, b, \c)이고, (Td, Te, Ta, Tb, \Tc) 타입을 가짐
    (u ~ t) println()
    -- t ~ u == (a, b, \c, d, e)이지만 c의 길이가 고정되어 있지 않으므로 \c는 쓸 수 없음
    (t ~ u) println()              -- 오류
end

진리값

나루에는 두 종류의 진리값, 즉 truefalse가 있습니다(진리값은 흔히 “부울 값”[Boolean]이라고도 부릅니다). “아마도”나 “알 수 없음”을 나타내는 세 번째 진리값이 필요하다면 Interval{Bool}이나 Option{Bool} 타입으로 흉내낼 수 있습니다. 나루에서 조건을 평가하기 위해 진리값이 필요할 경우, 주어진 값은 자동으로 Bool from 변환 메소드를 통해 진리값으로 변환됩니다. (물론 그렇다고 해서 함수 인자에 진리값 타입이 있다고 무조건 들어 온 인자가 진리값으로 변환된다는 건 아닙니다. 조건을 평가할 때만 변환됩니다.)

나루에는 두 종류의 진리값 연산이 있는데, 하나는 성급하고(short-circuiting) 하나는 아니니다. 전자는 andor로 쓰며 후자는 &&||로 쓰고, 당연하지만 두 경우 모두 진리값을 뒤집는 연산자는 그냥 not입니다. 전자는 인자로 진리값 뿐만이 아닌 모든 종류의 값을 받으며, 첫 인자를 진리값으로 변환한 다음에 다음 인자를 평가할지 말지를 결정합니다. 따라서 and는 처음으로 나오는 참으로 변환되는 값(만약 아무 것도 참이 아니라면, 마지막 값)을 반환하고, or는 처음으로 나오는 거짓으로 변환되는 값(없다면 마지막 값)을 반환하게 됩니다. 한편 후자 역시 모든 종류의 값을 받지만, 항상 모든 인자들을 평가한 뒤 진리값으로 변환하기 때문에 그 결과는 항상 진리값이 됩니다.

(3 and 0 and false) println()            -- 0
(3 and false and 0) println()            -- false
(3 and 4 and "!!!") println()            -- !!!

(() or "hello" or "world") println()     -- hello
(() or "" or 0.0) println()              -- 0.0

(0 && "foo") println()                   -- false
(0 || "foo") println()                   -- true

나루에 내장된 대부분의 값은 true로 변환됩니다만, 일부는 false로 변환되기도 합니다:

비교와 Ordered 타입

나루에는 네 종류의 비교 연산이 있는데, 각각 값이 같은지 비교하는 연산자(==!=), 같은 참조인지 비교하는 연산자(isis not), 순서 비교 연산자(<, <=, >, >=) 및 세 방향 비교 연산자(<=>)로 나뉩니다.

값이 같은지 비교하기: 이 연산자들은 보통 생각하는 대로 두 숫자가 같은지 비교하는데, 따라서 3 == 3이고 3 != 4입니다. 이 연산자는 숫자 뿐만 아니라 모든 값에서 쓸 수 있으며, 숫자가 아닌 값에 대해서는 참조 비교와 똑같이 동작합니다. 따라서 a == a는 몇몇 예외를 제외하면 대부분의 a에 대해서 성립합니다. (대표적인 예외로는 부동소숫점 실수값의 “숫자가 아닌 값”[Not-a-Number, NaN]이 있는데, NaN은 모든 NaN과 다르게 비교됩니다.)

이들 연산자는 대부분의 경우 상식과 일치하는 결과를 내지만, 같은 값 ab가 같지 않다고 나오는 경우도 있을 수는 있습니다. 이런 상황은 값이 변경 가능하거나(열린 파일 같이), 정확한 비교가 매우 어려워서 대략적인 비교만이 구현되어 있거나(수학 수식 같은 경우) 할 때 발생할 수 있습니다.

같은 참조인지 비교하기: 이 연산자들은 주어진 값을 제자리에서 바꿀 때 다른 값이 직접적으로 함께 바뀔 때만 참을 반환합니다. 예를 들어 [1, 2, 3] == [1, 2, 3]은 두 벡터가 같은 값을 가지고 있으므로 참이지만, [1, 2, 3] is [1, 2, 3]은 전자를 바꾼다고 후자가 함께 바뀌지는 않으므로 거짓입니다. 숫자의 경우 딱 한 가지 경우(같은 비트 패턴을 가진 NaN은 같은 참조로 비교됩니다)를 빼면 값이 같은지 비교하는 것과 같은 참조인지 비교하는 것이 동일합니다.

순서 비교: 이 쪽은 좀 어렵습니다. 일단 Ordered 클래스의 모든 값에 대해서 순서 비교가 가능하며, 일반적으로는 a <= b(a < b) || (a == b)와 동일하고(반대 방향도 마찬가지) a < bb > a가 동일합니다만(반대 방향도 마찬가지) 항상 이래야 하는 건 아닙니다.

실수의 경우 상황이 복잡한 것이, 하나 이상의 인자가 NaN이면 비교는 항상 거짓을 반환합니다. (덤: IEEE 754의 용어를 빌리면, 나루의 실수 비교는 시끄럽지 않습니다[non-signaling].) NaN인 경우를 확인하려면 :strict 인자를 써야 하며, 따라서 #<#(3, Decimal NaN, :strict)는 예외를 던질 것입니다. (실제 수식에서 어떻게 쓰는지는 with 연산자를 참고하세요.) 순서가 있는 콜렉션의 경우(이를테면 Vector, SortedSet 같은 것은 순서가 있지만 Set은 아닙니다) 비교 연산은 사전순으로 이루어지며, 따라서 [3, 4] < [3, 4, 5] < [3, 5] < [4]가 성립합니다. 물론 콜렉션에 들어 있는 값들 또한 순서 비교가 가능해야 겠지요.

앞에서 설명했지만 a <= b < c(a <= b) < ca <= (b < c)와 같지 않고, 실제로는 b가 한 번만 평가된다는 것만 빼면 (a <= b) && (b < c)와 같이 계산됩니다. 또한 여기서 볼 수 있듯 #<#, #<=#, #>#, #>=# 함수는 어떤 식으로 쓰여 있어도 내부적으로는 인자를 항상 두 개만 받습니다.

세 방향 비교: #<#, #<=#, #>#, #>=#를 하나 하나 직접 구현하는 것은 귀찮기도 하고 실수하기도 쉽기 때문에, 나루는 이들을 통합한 <=> 비교 연산자를 지원합니다. 이 세 방향 비교 연산자는 op가 여섯 가지 비교 연산자 중 하나일 때 a op b(a <=> b) op 0과 같다는 특징을 가지고 있습니다. 기본적으로 순서 비교와 값이 같은지 비교하는 연산자들은 모두 세 방향 비교 연산자를 가지고 구현되어 있으므로, 보통은 세 방향 비교 연산자 하나만 구현하면 나머지는 자동으로 구현됩니다.

보통 <=>는 그 결과에 따라 0, -1, +1 중 하나를 반환하지만, 꼭 이들 중 하나만 반환해야 하는 건 아닙니다. TODO no, <=> may have to behave as like IEEE 754’s totalOrder function (except for -0 and +0 distinction?)

문자형

나루의 문자형 Char은 보통 생각하는 텍스트를 추상화한 것입니다. 나루는 대부분의 언어와 문자를 동일하게 지원하기 위해 유니코드를 텍스트 표현의 기반으로 사용하며, 따라서 하나의 Char 값은 하나의 유니코드 코드 포인트를 부호화합니다. 여기서 말하는 코드 포인트(code point)는 추상화된 문자입니다만, 꼭 실제로 생각하는 문자(이는 문자소[grapheme]라고 따로 구분해서 부릅니다)와 같을 이유는 없습니다. 한 문자가 실제로 여러 개의 코드 포인트를 사용해서 나타나는 경우는 생각보다 흔한데, 이를테면 J 위에 발음 구별 기호의 일종인 캐론(caron)을 덧붙인 J̌는 두 개의 코드 포인트 U+004A와 U+030C를 써서 나타냅니다. 하지만 코드 포인트 단위로 문자열을 처리하는 것이 더 다루기도 쉽고 구현하기도 쉽기 때문에 나루는 여전히 코드 포인트를 텍스트의 기본 단위로 사용하되, 문자소 단위로 문자열을 다루는 연산들도 일부 지원하고 있습니다.

Char 리터럴은 보통 ? 뒤에 하나의 코드 포인트가 붙어서 ?a?ㅋ 같이 씁니다. ? 뒤에 두 개 이상의 문자나 따옴표로 묶인 문자열이 올 수도 있는데, 이 경우 해당하는 이름을 가진 문자를 가리킵니다. 이를테면 ?space?에 대응되며(? 뒤에 공백 하나가 붙은 리터럴) 사실 전자를 후자보다 많이 씁니다. ?"HANGUL SYLLABLE BAB" 역시 ?밥으로 대응되는데, 물론 한국인이라면 후자를 선호하겠지만 한국어를 모른다면 전자가 더 알아 보기 편할 것입니다. 문자의 이름은 유니코드 문자 데이터베이스(UCD)의 NameName_Alias 속성으로부터 가져 왔으며, U+0000..001F와 U+007F..009F 영역의 문자는 Uniode_1_Name 속성도 씁니다. (덤: 이 제어 문자들은 유니코드 2.0 이래로 공식 이름이 없습니다.) 이름은 대소문자를 구분하지 않으며, 두 문자 사이의 공백, 밑줄 및 하이픈은 UAX #44의 Loose Matching 규칙 2(LM2)에 따라 대부분 무시됩니다. (덤: 단 한 가지 예외로는 U+1180 HANGUL JUNGSEONG O-E가 있는데, 이는 U+116C HANGUL JUNGSEONG OE와 혼동되기 때문입니다.) 모든 문자는 그 자신의 이름이기도 하므로, ?"a"?a와 동일합니다.

Char 리터럴은 ? 뒤에 \로 시작하는 하나의 탈출열(escape sequence)을 붙여서 만들 수도 있습니다. (탈출열은 문자열 안에도 들어 있을 수 있으나 기술적으로는 문자 리터럴과는 무관합니다.) 탈출열은 다음 중 하나여야 합니다:

이 탈출열들은 문자열 리터럴 안에서도 쓸 수 있기 때문에 마음만 먹는다면 다음과 같이 쓸 수도 있긴 합니다.

?a println()                  -- a
?: println()                  -- :
?sp println()                 --   (U+0020)
?AmperSand println()          -- &
?\\ println()                 -- \
?\42 println()                -- * (U+002A)
?\uD7A3 println()             -- 힣 (U+D7A3)
?\U12345678 println()         -- 오류 (U+10FFFF를 넘는 코드 포인트)
?'number sign' println()      -- #
?"  \78\85\77\66\69\82\83\73\71\78   " println()
                              -- # (이름은 "  NUMBERSIGN   "로 해석됨)

Char 클래스는 따로 설정하지 않는 한 유니코드 문자 데이터베이스의 일부에 접근할 수 있습니다. 이를테면 ?# name"NUMBER SIGN"을 반환하고, ?# category:Po(문장 부호[Punctuation], 기타[others])를 반환합니다. Char는 (별로 쓸모는 없습니다만) 코드 포인트 순으로 정렬되므로, String from(?a..?z)는 로마자 소문자 26글자를 반환합니다.

서로게이트 쌍으로 쓰이는 코드 포인트 U+D800..DFFF는 Char로는 표현할 수 있으나 문자열에서는 나타날 수 없습니다. TODO really? there are lots of systems that (accidently) accept the lone surrogate pair character, like Twitter, and they have to be preserved for the interoperability.

숫자, 자세하게

다음은 나루의 숫자 계층을 완전히 쓴 것입니다.

Number
+- Complex
   |- Real
   |  |- Rational
   |  |  |- Int
   |  |  |  |- BigInt
   |  |  |  |- SmallInt
   |  |  |  +- NativeInt
   |  |  |     |- UIntNN
   |  |  |     +- IntNN
   |  |  +- Ratio
   |  |- FloatNN
   |  |- Decimal
   |  +- Interval{+T}
   |- CFloatNN
   +- CDecimal

간략히 말하자면, 나루에는 네 개의 추상 숫자 클래스(도메인)가 있는데 각각 정수(Int), 유리수(Rational), 실수(Real) 및 복소수(Complex)를 나타냅니다. 나머지는 해당 숫자 도메인을 구현하는 구체적 클래스로, 이를테면 BigIntSmallInt는 정수를 구현하는 구체적 클래스며 정수 리터럴의 “실제” 클래스는 크기에 따라 둘 중 하나가 선택됩니다. UIntNN, IntNN, FloatNN, CFloatNN은 현재 기계에서 기본적으로 지원하는 기계 친화적 숫자형을 나타내며 해당 숫자형의 비트 크기가 이름 뒤에 따라 붙습니다(UInt64, Float80 등). 복소수의 경우 이 비트 크기는 실수부와 허수부를 모두 포함합니다(즉 CFloat128Float64 두 개로 이루어집니다).

도메인 외에도 모든 숫자는 정확(exact)하거나 부정확(inexact)할 수 있습니다(스킴과 비슷합니다). 어떤 숫자가 정확한지 아닌지는 exact 속성으로 알 수 있습니다. 둘 사이의 구분은 실수형에서 가장 극명하게 드러나는데, 이를테면 Decimal은 해당 값이 정확한지 아닌지를 계속 기억하고 있습니다. (정수는 정의에 따라 항상 정확하며, FloatNNCFloatNN은 부동 소숫점 표현의 특성상 항상 부정확합니다.)

숫자 리터럴

아마 다들 잘 알고 있으리라 생각하지만, 여기서는 숫자 리터럴이 정확히 어떻게 생겼는지 살펴 보겠습니다.

정수 리터럴

0, 1, 2, 42, 1000000 등등. 이들은 크기에 따라 SmallInt 또는 BigInt 클래스의 값입니다만, 거의 대부분의 경우 이 차이는 무시하고 Int라는 하나의 클래스가 존재한다고 생각해도 좋습니다. 음의 정수 리터럴은 존재하지 않으며, 음수(그리고 정수)를 만드는 과정은 토큰화 시점이 아니라 파싱 도중에 일어납니다. (이는 다른 종류의 숫자 리터럴에도 마찬가지로 적용됩니다.)

정수 리터럴 앞에는 진법을 나타내는 접두어가 붙을 수 있습니다:

  • 0b는 이진수를 나타내며, 사용 가능한 자리는 01입니다.
  • 0o는 팔진수를 나타내며, 사용 가능한 자리는 01234567입니다.
  • 0x는 십육진수를 나타내며, 사용 가능한 자리는 0123456789ABCDEF입니다. (대소문자는 구분하지 않습니다.)

또한 일반화된 리터럴 2"...", 8"..."16"..."를 접두어 대신 사용할 수도 있습니다. 이 일반화된 리터럴은 2진법부터 36진법까지의 임의의 진법을 지원하며(Z는 35를 나타냅니다), 공백과 밑줄 문자는 가독성을 위해 생략됩니다.

TODO enclosed digits in generic integer literal

소수 리터럴

3.14, 314e-2 등등. 이들은 Decimal 클래스의 값입니다. 기본적으로 소숫점(.)이나 지수부(e123, e+123, E123, E+123 등)를 담는 모든 숫자는 소수입니다. 소숫점은 두 십진법 자리 사이에 나타나야 하므로, .345345.는 올바른 소수 리터럴이 아닙니다(하지만 345e0은 올바른 소수 리터럴입니다). 지수부가 나타날 경우 지수부 앞의 숫자와 10을 지수부 뒤의 숫자만큼 거듭제곱한 숫자를 곱해서 소수 값을 만듭니다.

소수 리터럴 안에서 ?는 10진법 자리 0과 동일하게 인식되지만, 해당 소수가 부정확하며 주어진 자리는 유효숫자에 포함되지 않음을 나타냅니다. (따라서 ?가 한 번 나오면 그 뒤에는 유효숫자가 나올 수 없습니다.) 이를테면 314????3.14????e+4 또는 3.14?e4와 동일합니다만, 3.14e4는 정확한 소수이기 때문에 서로 다릅니다. 모든 소수 리터럴은 (비록 그게 0이라 하더라도) 적어도 하나 이상의 유효숫자를 포함해야 하며 Char과 혼동될 수 없습니다.

부동 소숫점 실수 리터럴

3.14f, 314e-2f 등등. 소수 리터럴 뒤에 fF를 붙여서 만들며, 현재 문맥에서 사용할 수 있는 가장 큰 Float* 클래스의 값을 나타냅니다. 예를 들어 a: Float32 | Float64 = 3.14f의 경우 Float64가 사용되지만(사용 가능한 타입 두 개 중 Float64가 더 크므로) 그냥 a = 3.14f라 하면 IA-32와 x86-64 아키텍처에서는 Float80을 쓰게 됩니다. 이런 규칙은 (역시 예를 들자면) Float32 값을 십진 표현에서 바로 얻는 것과 Float64로 먼저 얻은 뒤에 Float32로 변환하는 것이 같지 않기 때문에 생겼습니다. (덤: 후자는 반올림을 가장 가까운 짝수로 하거나 양의 무한대 쪽으로 할 경우 정확도가 손실될 수 있습니다.)

나루에서 정확한 부동 소숫점 실수를 쓰려면 십육진법을 나타내는 0x를 앞에 붙일 수 있습니다. 이 경우 십진 지수 대신에 이진 지수(p123 = 2123 등등으로 해석됨)가 대신 쓰입니다. 따라서 3.14fFloat64에서는 0x1.91e851eb851fp+1f와 동일한 값이 될 것입니다.

부동 소숫점 실수 리터럴에서도 유효숫자가 아닌 0을 나타내는 ? 자리를 쓸 수 있긴 하지만, 부동 소숫점 실수는 항상 부정확하므로 ?0과 동일한 역할만 합니다. 이 이유 하나만으로 부동 소숫점 실수를 피할 이유는 충분합니다.

허수 리터럴

3.14j, 3.14fj 등등. 소수 리터럴이나 부동 소숫점 실수 리터럴 뒤에 j 또는 J를 붙여서 만듭니다. (이 표기법은 전자 공학에서 전류를 나타내는 i와 허수 단위를 구분하기 위해서 널리 쓰입니다.) 이 리터럴은 j/J 앞에 있는 리터럴의 종류에 따라 CDecimal 클래스나 CFloatNN 클래스의 값이 됩니다.

숫자 리터럴의 각 자리 안에는 가독성을 위해 _를 넣을 수 있습니다. 좀 더 정확히는 두 개의 자리 사이, 한 개의 자리와 지수 문자(ep) 또는 한 개의 자리와 소숫점(.) 사이에 최대 한 개까지 밑줄을 넣을 수 있습니다. 따라서 0x1_2_3이나 12_3._4_5?_??_e_15는 올바른 반면, _34(이건 그냥 식별자입니다), 34_, 0_x123이나 13e_+45는 틀립니다. 일반화된 리터럴에서는 따옴표만 제대로 썼다면 이런 제약이 없습니다.

Ratio와 실수부가 있는 Complex 값에 대한 별도의 리터럴은 없습니다만, 나눗셈과 덧셈 연산자를 써서 해당하는 값을 반환하는 상수 수식을 정의할 수는 있습니다. 이를테면 3/43+4j는 리터럴은 아니지만 상수가 사용될 수 있는 곳에서는 상수로 쓸 수 있습니다.

기계 친화적인 정수형

앞에서 나루의 기본 정수형인 Int에는 크기 제한이 없다고 했습니다. 하지만 많은 다른 언어에는 크기 제한이 있는 정수형이 있는데, 이들 정수형은 기계 친화적인(native) 정수형으로 불리며, 나루 외부 함수 인터페이스의 중요한 일부로서 naru ext 모듈에 들어 있습니다. 이 정수형들은 저수준 암호학적 알고리즘을 구현한다거나 해서 크기가 고정된 정수형이 필요할 때도 요긴하게 쓸 수 있습니다.

기계 친화적인 정수형의 연산은 경고 없이 값이 넘칠(overflow) 수 있습니다:

import naru ext *
a = Int32 from(66000)
(a * a) println()         --> 61032704 (= 66000^2 mod 2^32)

이 동작은 의도한 것입니다. 기계 친화적인 정수형은 사용자가 그 특성에 익숙하다는 전제를 깔고 있기 때문에 사용자가 직접 값이 넘치는 경우를 처리해 줘야 합니다. 어떤 경우에는 일부 연산의 결과가 정의되어 있지 않은 경우도 있는데, 이를테면 Int32 from(2) >> 322를 반환할 수도 있습니다(IA-32 아키텍처의 경우 시프트할 크기는 맨 아래 5비트만 씁니다). 심지어 기계 친화적인 정수형이 Int와는 달리 2의 보수를 안 쓸 수도 있습니다. (덤: Int는 암시적으로 2의 보수 표현을 가정합니다…만, 엄밀히 말하면 음수 처리 때문에 정수를 2-adic 수로 표현한 형태를 쓴다고 해야 할 것입니다.)

값이 넘치면 명시적으로 예외를 던지라고 요청할 수 있습니다. 이 경우 Int32 from(2) >> 32와 같이 정의되지 않은 연산도 잡아 냅니다:

do
    (a * a with :checked) println()
but Exception as e
    #"#a * #a overflowed." println()
end

물론 but 수식을 쓰는 게 이 경우 더 편하겠죠:

(a * a with :checked but OverflowError -> 0) println()

서로 다른 종류의 기계 친화적인 정수끼리의 변환은 손수 해 줘야 합니다. 따라서 부호 있는 정수와 부호 없는 정수를 섞어서 벌어지는 혼란이라거나 하는 것들을 방지할 수 있습니다.

a = Int32 from(1234567890)
a2 = Int64 from(a)                 -- 1234567890
a3 = Int16 from(a)                 -- 722 (= 1234567890 mod 2^16)
a4 = Int8 from(a)                  -- -46 (= 1234567890 mod 2^8 - 2^8)
a5 = Int8 from(a) with :checked    -- 오류
a6 = Int16 from(a4)                -- -46
a7 = Int8 from(a6) with :checked   -- -46 (-46은 Int8의 범위에 들어 가므로)

b = Int32 from(-987654321)           
b2: UInt32 = b unsigned               -- 3307312975
b3: Int32 = b2 signed                 -- -987654321

고급 산술 연산

앞에서 +, -, *, /, //, %**에 대해서는 설명했습니다. 이 절에서는 남아 있는 연산들을 소개합니다.

몫·나머지 연산자: 이 연산자는 /%라고 쓰고, 정의에 따라 a /% b == (a // b, a % b)입니다. 하지만 이 연산자는 //%를 따로 쓰는 것보다 더 효율적이고, 몇몇 경우 더 편하기도 합니다:

-- 혼합 진법도 간편하게!
nminutes, seconds = nseconds /% 60
nhours, minutes = nminutes /% 60
ndays, hours = nhours /% 24
years, ndaysinyear = ndays /% 365
weeks, days = ndaysinyear /% 7

비트 연산자: C 계열 언어와 유사합니다:

당연하지만 비트 연산자는 Int 및 그 서브클래스에만 적용할 수 있습니다. Int의 경우 항상 2의 보수 표현을 가정하기 때문에 ~x == -x-1 같은 등식이 항상 성립합니다만, 기계 친화적인 정수형의 경우 어떤 보장도 하지 않습니다.

특수 비트 연산: 위의 비트 연산자만큼 자주 필요한 건 아닙니다만, 정수형에는 좀 더 특화된 비트 연산이 속성 및 메소드로 들어 있습니다:

일부 연산은 임의 크기 정수에서 잘 정의되지 않아서 기계 친화적인 정수형에만 존재합니다:

TODO

정확한 계산을 향해

TODO should decimals track the significant digits? then how about interval arithmetic which will collide with significance arithmetic? or exact arithmetic based on continued fraction?

수학 함수

나루에는 수학 함수 모듈이 두 개 있는데, 하나는 실수용 naru math이고 다른 하나는 복소수용 naru math complex입니다. 전자를 후자에서 나눈 이유는 복소수를 정의역으로 갖는 함수들의 동작은 실수와는 크게 다르기 때문인데, 예를 들어 i의 로그는 π/2i이기도 하지만 -3π/2i, 5π/2i 등등이기도 합니다(엄밀하게는 함수라고 할 수도 없습니다). 따라서 복소수 함수를 쓸 때는 이런 여러 반환값을 어떻게 해석해야 할 지 미리 알고 있어야 합니다.

Number에 있는 메소드도 여러 산술 연산을 제공합니다만, 구현이 (비교적) 간단한 것을 제외하면 모두 모듈로 들어 있습니다. 이를테면 gcd는 최적화를 위해 상당한 코드가 필요합니다만(힌트: 유클리드 호제법은 느립니다!) bitlength는 대부분의 아키텍처에서 지원하는 명령입니다.

상수: pi(3.141592…)와 e(2.718281…)는 각각 원주율과 자연 상수에 대응됩니다. TODO what type of those constants should be? the automatic rounding hurts the accuracy (should be less than 0.5ulp). it is possible that those are separate singleton subtypes of Real (say Pi and E) and coerced whenever needed (Pi * Float64 -> Float64, Pi * Float32 -> Float32 etc.), but it is a bit too much.

산술: 메소드로 들어 있거나 간단하게 구현 가능한 연산 중 몇 개는 모듈에도 같이 들어 있는 경우가 있습니다. 대부분의 경우 편의를 위해서라거나 성능 등 아주 크지는 않은 이유 때문에 두 개가 함께 존재합니다.

삼각 함수와 쌍곡 함수: 24가지 가능한 함수를 모두 지원합니다:

삼각 함수의 경우 두번째 인자는 x가 어떻게 해석되는지를 결정하며, :radians (기본값) 및 :degrees를 쓸 수 있습니다. 삼각 함수의 역함수의 경우 함수의 반환값을 어떻게 해석해야 하는지를 결정합니다.

나루에는 널리 쓰이는 atan2(y, x) 함수도 있는데, 이 함수는 atan(y / x)와 같으나 결과가 -π보다 크고 (다만 y == -0.0일 경우 -π도 포함될 수 있음) +π보다 작거나 같도록 xy의 부호를 적절히 처리한 것입니다.

지수 및 로그 함수: 이 함수들은 수치 계산에 워낙 많이 쓰이는 터라 종류가 좀 많습니다:

초등적(elementary)이지 않은 함수: TODO 설명

기타 수학 함수: TODO 설명

특수한 부동 소숫점 함수: TODO 설명

복소수를 정의역으로 갖는 함수: 예상한 대로 abs(z)(엄밀히는 naru math complex에 있는 함수는 아닙니다)는 z의 절대값인 sqrt(z imag ** 2 + z real ** 2)를 반환합니다. phase(z)z의 방향(principal argument)을 반환하며, 그 결과는 -π보다 크고(arg(-1.0-0.0j)는 예외) +π보다 작거나 같습니다. 나머지 함수의 범위는 이 phase(z)의 범위를 토대로 결정됩니다.

naru math complex 모듈은 일부 산술 함수(sign, pow, sqrt)와, 모든 삼각 및 쌍곡 함수, atan2를 제외한 모든 지수 및 로그 함수, 그리고 모든 초등적이지 않은 함수들을 포함하고 있습니다. naru math에 있는 함수들과의 차이는 다음과 같습니다:

콜렉션, 자세하게

다음은 나루의 콜렉션 계층을 완전히 쓴 것입니다:

Enumerable{+T}
|- Iterator{+T}
|- Option{+T}                  (T?라고도 씀)
|- Range{+T}
|- Sorted{T}
|  +- ...
+- Immutable{+T}
   |- PureSList{+T}
   |- PureList{+T}
   |- PureVector{+T}
   |  |- Symbol (T=Char)
   |  |- PureBytes (T=Int)
   |  +- PureSortedVector{+T} <- Sorted{T}
   |- PureMultiset{+T}
   |  |- PureSet{+T}
   |  |  +- PureSortedSet{+T} <- Sorted{T}
   |  +- PureSortedMultiset{+T} <- Sorted{T}
   |- PureMultimap{+Key, +Value} (T=(Key,Value))
   |  |- PureMap{+Key, +Value}
   |  |  +- PureSortedMap{+Key, +Value} <- Sorted{(Key,Value)}
   |  +- PureSortedMultimap{+Key, +Value} <- Sorted{(Key,Value)}
   +- Mutable{T}
      |- SList{T} <- PureSList{T}
      |- List{T} <- PureList{T}
      |- Vector{T} <- PureVector{T}
      |  |- String <- Symbol (T=Char)
      |  |- Bytes <- PureBytes (T=Int)
      |  +- SortedVector{T} <- PureSortedVector{T}
      |- Multiset{T} <- PureMultiset{T}
      |  |- Set{T} <- PureSet{T}
      |  |  +- SortedSet{T} <- PureSortedSet{T}
      |  +- SortedMultiset{T} <- PureSortedMultiset{T}
      +- Multimap{+Key, Value} <- PureMultimap{Key, Value} (T=(Key,Value))
         |- Map{+Key, Value} <- PureMap{Key, Value}
         |  +- SortedMap{+Key, Value} <- PureSortedMap{Key, Value}
         +- SortedMultimap{+Key, Value} <- PureSortedMultimap{Key, Value}

콜렉션은 기본적으로 키와 값 타입 및 각 콜렉션의 제약과 보장하는 성능에 따라 나눌 수 있습니다. 가장 크게는 두 가지 분류법이 있는데, 변경 가능한가 아닌가정렬되어 있는가 아닌가입니다.

변경 가능한가 아닌가

나루에서 변경 가능한(mutable) 콜렉션과 변경 불가능한(immutable) 콜렉션은 구분됩니다. 후자는 순수 함수형 언어에 적합하기 때문에 종종 “함수형 자료 구조”라고도 하는데, 변경 불가능하기 때문에 몇 가지 가능한 최적화가 생깁니다. 이를테면 비슷한 부분을 공유하는 많은 콜렉션을 더 적은 메모리로 표현할 수 있다거나 하는 것이지요.

지금까지 본 모든 내장 콜렉션 문법은 변경 가능한 콜렉션을 반환합니다. 변경 불가능한 콜렉션을 만드려면 앞에 :를 덧붙이기만 하면 됩니다. (:=와 같이 :가 붙은 것들은 변경 불가능한 무언가를 나타낸다고 생각하세요.)

vec1 = [3, 4, 5];        vec2 = :[3, 4, 5]           -- Vector{Int}, PureVector{Int}
set1 = {3, 4, 5};        set2 = :{3, 4, 5}           -- Set{Int}, PureSet{Int}
map1 = {3 => 4, 5 => 6}; map2 = :{3 => 4, 5 => 6}    -- Map{Int,Int}, PureMap{Int,Int}
str1 = "blahblah";       str2 = :"blahblah"          -- String, Symbol

마지막 줄을 보면 문자열이 사실 변경 가능했다는 게 드러납니다. 변경 불가능한 버전은 “심볼”(symbol)이라고 불리며, 심볼에 들어 있는 문자들이 이름으로도 쓰일 수 있으면 따옴표 없이 :blahblah라고 쓸 수도 있습니다. 심볼이 언제 유용할까요? 가장 중요한 사용예로, 대응(map)에서는 키가 항상 변경 불가능해야 하기 때문에 키로는 심볼을 써야 합니다. 예를 들어:

numbers = {:one => 1, :two => 2, :"fourty-two" => 42}
numbers[:"fourty-two"] println()      --> 42
numbers[:"one hundred"] = 100

“어, 지금까지는 그냥 문자열을 썼는데 그럼 그게 틀리단 말인가요?!” 아 물론 그건 아닙니다. 심볼은 문자열의 변경 불가능한 버전이기 때문에 문자열 키를 가지는 대응은 자동으로 문자열을 심볼로 바꾸거나 반대로 심볼을 문자열로 바꿔 줍니다. 이를테면:

numbers = {"one" => 1, "two" => 2}
key = "one hundred"
numbers[key] = 100
key[0..2] = "two"
key println()                         --> two hundred
numbers println()                     --> {"one" => 1, "two" => 2, "one hundred" => 100}

이 경우 key의 복사본(여기서는 심볼입니다)이 대응에 들어 가고, 대응에서 어떤 식으로든 이 키를 얻으려 할 때는 매번 문자열인 복사본이 생깁니다. (덤: 대강 구현하면 굉장히 비효율적이겠습니다만, 정말로 쓰기가 될 때만 메모리를 할당하는[copy-on-write] 구현을 쓰면 메모리 할당을 최소화할 수 있습니다. 또한 컴파일러는 전혀 변하지 않는 문자열을 자동으로 심볼처럼 처리하는 최적화를 수행할 수도 있습니다.)

변경 불가능한(즉, “순수한”) 대응을 쓴다면 당연히 변경 불가능한 타입만을 안에 쓸 수 있습니다. 하지만 콜론을 매번 붙이는 건 매우 귀찮으니 맨 바깥에 있는 콜론만 쓰면 자동으로 나머지는 변경 불가능한 값으로 바뀝니다. 따라서 다음 세 줄은 동일한 의미이며, 모두 PureMap{Symbol,List{Int}} 형으로 평가됩니다.

:{"one" => [2, 3], "four" => [5], "six" => [7, 8, 9, 10]}
:{:one => [2, 3], :four => [5], :six => [7, 8, 9, 10]}
:{one: [2, 3], four: [5], six: [7, 8, 9, 10]}     -- 키가 심볼일 경우 더 짧게 쓸 수 있음

아무튼, 대부분의 경우 변경 가능한 콜렉션과 변경 불가능한 콜렉션은 짝을 이루며 freeze()thaw() 메소드를 써서 콜렉션을 변경 가능/불가능한 버전으로 바꿀 수 있습니다. 표준 콜렉션의 경우 이 메소드는 효율적으로 구현되어 있으며, 특히 변환이 일시적인 경우 (즉 변경 가능한 쪽이 실제로 변경은 안 되는 경우) 더더욱 효율적입니다. 타입의 경우 MutableImmutable 클래스 속성을 써서 해당하는 버전의 타입을 얻을 수 있습니다.

Enumerable 인터페이스

모든 콜렉션은 Enumerable로 추상화된 공통의 인터페이스를 제공하여 루프를 구현합니다. 실제 루프를 돌릴 때는 Iterator 인터페이스를 사용하는데 이 또한 Enumerable 인터페이스의 서브클래스입니다. Iterator, 즉 반복자는 기본적으로 콜렉션에 들어 있는 원소를 하나 하나 반환하다가 (콜렉션이 유한하다면) 더 이상 반환할 원소가 없을 때 멈춥니다. Enumerable 콜렉션은 Iteartor 타입의 새 반복자를 반환하는 iterator() 메소드를 제공해야 합니다.

이 추상화에 따라, 다음 루프는…

for el <- collection
    -- el을 가지고 뭔가 함
end

…다음과 같이 다시 쓸 수 있습니다 (Option{T} 타입은 곧 설명합니다):

with _iterator <- collection iterator()          -- 내부 변수
    while true
        _current: Option{T} := _iterator next()     -- 내부 변수
        if not _current holds then break end
        el = _current value
        -- el을 가지고 뭔가 함
    end
end

반복자는 한 번 만들면 바뀌지 않으며, 따라서 루프를 돌던 중 해당 콜렉션을 변경해도 아무 일도 일어나지 않습니다. 예를 들어서:

arr = [1, 2, 3]
for x <- arr
    x println()
    arr append(x)
end
arr println()

이 코드는 나루에서는 잘 정의되어 있으며 1, 2, 3[1, 2, 3, 1, 2, 3]을 순서대로 출력합니다. 이 동작 때문에, 콜렉션은 루프가 시작하기 직전에 암묵적으로 변경 불가능한 버전으로 변환됩니다(변경 불가능한 콜렉션의 반복자는 이런 문제는 없을테니까요). 보통 변경 가능한 콜렉션과 불가능한 콜렉션 사이의 변환은 거의 비용이 들지 않기 때문에 (컴파일러가 종종 변경 가능한 콜렉션을 변경 불가능한 콜렉션으로 변환하기도 하거니와) 보통 루프는 이런 동작 때문에 느려지지 않습니다.

정렬되어 있는가 아닌가

일부 콜렉션은 원소를 나열하는 순서가 항상 정렬(sorted)되어 있습니다. 이 경우 각 원소는 순서 비교가 가능해야 겠지요(즉, Ordered 추상 클래스의 값). 변경 가능한가 아닌가와는 다르게, 정렬되어 있는가 아닌가는 컴파일러가 별다른 체크를 하지 않습니다. 따라서 다음 코드는 올바릅니다:

a: Vector{Int} = [1, 5, 3]
b: SortedVector{Int} = SortedVector([2, 4])
a2: SortedVector{Int} = a sorted()             -- 정렬된 콜렉션을 반환함
c: Vector{Int} = b ~ a2                        --> [2, 4, 1, 3, 5]

물론 이 경우 두 정렬된 벡터를 정렬을 유지하며 합치는 건 어렵지 않습니다만(실제로 SortedVector merge 메소드가 이를 지원합니다) 기본적으로는 두 정렬된 벡터를 덧붙인다고 정렬된 벡터가 나오는 건 아니지요. 물론 한 쪽이 빈 벡터라면 그 결과는 SortedVector{Int} 클래스가 되겠습니다만, 컴파일 시간에 볼 수 있는 타입은 여전히 Vector{Int}입니다.

정렬되어 있지 않은 콜렉션은 다시 두 종류로 나눌 수 있는데, 반복 순서가 일정한가 아닌가에 따라 순서가 있는(ordered) 것과 없는(unordered) 것으로 나눌 수 있습니다. 예를 들어 같은 원소를 가지는 순서가 없는 콜렉션 두 개가 서로 다른 순서로 원소를 나열할 수도 있습니다. 물론 순서가 한 번 정해지고 나면 콜렉션이 바뀌기 전까지는 그 순서가 바뀌면 안 됩니다. (덤: 이 얘기는 데이터 접근을 할 때 자동으로 자료 구조 최적화도 함께 시도하는 자료 구조라면 반복을 시작하기 전에 그 최적화를 끝내야 한다는 얘기도 됩니다. 대표적인 예로 PartitionedVector에서 사용하는 union-find 자료 구조가 있습니다.) Vector는 정렬은 안 되어 있지만 순서는 있는 콜렉션이며, Set은 정렬될 필요도 순서가 있을 필요도 없습니다.

정렬되어 있는지 아닌지를 구분하는 큰 이유는, 보통 콜렉션을 쓸 때는 정렬 여부를 신경쓰지 않지만 일단 어떤 콜렉션이 정렬되어 있다는 걸 알고 있다면 종종 최적화가 가능하기 때문입니다(그리고 단순히 정렬된 콜렉션이 필요한 경우도 분명 있습니다). 물론 정렬된 콜렉션은 몇 가지 제약이 따르는데, 예를 들어 Map은 해시 테이블을 쓸 수 있지만 SortedMap에서는 못 씁니다.

콜렉션 일람

다음은 나루가 기본적으로 지원하는 모든 표준 콜렉션의 목록입니다. 이름은 변경 가능한 버전만 썼는데, 변경 불가능한 버전의 이름은 항상 변경 가능한 버전 앞에 Pure를 붙여서 지었습니다.

이름 정렬됨 주 사용처 기반 자료 구조
SList X 임의 접근이 거의 없고 순차 접근만 하는 경우, 복사 없이 O(1) 잘라내기를 할 수 있음 단방향 연결 리스트
List X 임의 접근이 거의 없고 순차 접근 또는 역순 접근만 하는 경우, 복사 없이 O(1) 잘라내기를 할 수 있음 양방향 연결 리스트
Vector X 일반적인 임의 접근이 필요한 경우, 덧붙이기를 뺀 삽입 연산은 비쌈 크기 조정 가능한 배열이나 데크(deque)
String X 일반적인 문자열, 유니코드를 지원하는 다양한 문자열 연산을 지원 크기 조정 가능한 배열 (아니면 로프[rope]?)
Bytes X 바이트열, ASCII를 가정하는 몇 가지 문자열 연산을 지원 크기 조정 가능한 배열이나 데크
SortedVector O 일반적인 임의 접근이 필요한 경우, 임의의 원소 검색은 O(log n), 덧붙이기를 뺀 삽입 및 갱신 연산은 비쌈 크기 조정 가능한 배열이나 데크
Set X 임의의 원소 검색이 사실상 O(1), 중복된 원소는 자동으로 삭제됨, 빠른 집합 연산을 지원함 개방 주소법(open addressing)을 쓰는 해시 테이블, PureSet의 경우 (컴파일러가 지원한다면) 어쩌면 완전 해싱
SortedSet O 임의의 원소 검색 및 상한·하한 연산이 O(log n), 중복된 원소는 자동으로 삭제됨, 빠른 집합 연산을 지원함 스스로 균형을 잡는(self-balancing) 이진 트리
Multiset X 임의의 원소 검색이 사실상 O(1), 중복된 원소를 유지함, 빠른 집합 연산을 지원함 분리 연결법(separate chaining)을 쓰는 해시 테이블
SortedMultiset O 임의의 원소 검색 및 상한·하한 연산이 O(log n), 중복된 원소를 유지함, 빠른 집합 연산을 지원함 스스로 균형을 잡는 이진 트리
Map X 임의의 키 검색이 사실상 O(1), 중복된 키는 자동으로 삭제됨 개방 주소법을 쓰는 해시 테이블, PureMap의 경우 (컴파일러가 지원한다면) 어쩌면 완전 해싱
SortedMap O 임의의 키 검색 및 상한·하한 연산이 O(log n), 중복된 키는 자동으로 삭제됨 스스로 균형을 잡는 이진 트리
Multimap X 임의의 키 검색이 사실상 O(1), 중복된 키-값 쌍을 유지함 분리 연결법을 쓰는 해시 테이블
SortedMultimap O 임의의 키 검색 및 상한·하한 연산이 O(log n), 중복된 키-값 쌍을 유지함 스스로 균형을 잡는 이진 트리

naru collection 모듈은 좀 더 특수한 목적으로 쓰는 콜렉션들을 제공합니다:

이름 정렬됨 주 사용처 기반 자료 구조
OrderedSet X 임의의 원소 검색이 사실상 O(1), 중복된 원소는 자동으로 삭제됨, 삽입 순서를 보존함 양방향 연결 리스트가 깔려 있는, 개방 주소법을 쓰는 해시 테이블
OrderedMultiset X 임의의 원소 검색이 사실상 O(1), 중복된 원소를 유지함, 삽입 순서를 보존함 양방향 연결 리스트가 깔려 있는, 분리 연결법을 쓰는 해시 테이블
OrderedMap X 임의의 키 검색이 사실상 O(1), 중복된 키 자동으로 삭제됨, 삽입 순서를 보존함 양방향 연결 리스트가 깔려 있는, 개방 주소법을 쓰는 해시 테이블
OrderedMultimap X 임의의 키 검색이 사실상 O(1), 중복된 키-값 쌍을 유지함, 삽입 순서를 보존함 양방향 연결 리스트가 깔려 있는, 분리 연결법을 쓰는 해시 테이블
Bimap X 임의의 키 및 값 검색이 사실상 O(1) 해시 테이블 두 개
PriorityQueue X 최소 또는 최대값 계산이나 삭제가 O(1), O(log n) 삽입, 빠른 병합 연산 피보나치 힙(Fibonacci heap)
PartitionedVector X 각 집합의 접근이 O(1), 두 집합을 합치는 연산이 사실상 O(1) 서로 소 집합(disjoint-set) 자료 구조
PartitionedMap X 각 집합의 접근이 사실상 O(1), 두 집합을 합치는 연산이 사실상 O(1) 서로 소 집합 자료 구조와 해시 테이블

TODO

콜렉션 문법

앞에서도 언급했지만, 나루에는 여덟 개의 콜렉션 문법이 있습니다:

마지막 문법은 다른 문법들과 비슷하게 생기긴 했지만 실제로는 하나의 리터럴로 해석되며, 이들에 대한 자세한 설명은 나중에 설명합니다. 참고로 "..." 문법은 사용자가 정한 접두어를 붙여서 확장할 수도 있습니다.

문자열과 심볼을 제외한 나머지 문법들에는 두 가지 사용법이 있는데, 하나는 나열이고 다른 하나는 조건 제시법입니다.

나열 문법. 이를테면 [1, 2, 3], {1, 2, 3}, {1 => 2, 3 => 4}와 같이 콜렉션 안의 원소들을 하나 하나 나열해서 콜렉션을 만들 수 있습니다. 마지막 쉼표는 무시됩니다(즉, [1, 2,] == [1, 2]). 빈 콜렉션은 각각 [], {/}{}로 나타내며, {/}는 집합에, {}는 대응에 쓰입니다. (참고로 {/}가 곧 하나의 토큰이기 때문에 {/ }, { /}{ / }는 모두 올바르지 않습니다. 하지만 {}{ }는 같습니다.) 만약 콜렉션을 시작하는 토큰 앞에 :가 붙으면 이 문법은 변경 불가능한 콜렉션을 가리킵니다: 예를 들어 :[1, 2, 3], :{1, 2, 3}, :{1 => 2, 3 => 4} 같이 씁니다. (따라서 : {/}는 올바른 문법이기는 하지만, 별로 쓰고 싶진 않군요.)

함수 인자를 펴는데 쓰이는 접두어 \는 나열 문법 안에서도 쓰일 수도 있습니다:

a = [3, 4, 5]
b = [1, 2, \a, 6, 7]      -- 이제 b는 [1, 2, 3, 4, 5, 6, 7]

a = {3, 4, 5}
b = [1, 2, \a, 6, 7]      -- b는 1부터 7까지 일곱 개의 숫자를 포함하지만,
                          -- 3, 4, 5의 순서는 알 수 없다.

a = {"foo" => "bar"}
b = {"a" => "x", \a}      -- 이제 b는 {"a" => "x", "foo" => "bar"}

a = {3 => 4}
b = [(1,2), \a, (5,6)]    -- 이제 b는 [(1,2), (3,4), (5,6)]

예제에서 볼 수 있듯, 정렬되지 않은 콜렉션을 순서가 있는 콜렉션 안에서 펴면 원소의 순서는 반복 순서에 따르며, 정의에 따라 이 반복 순서는 임의적입니다. 또한 한 콜렉션을 다른 콜렉션 안에서 펴려면 원소의 타입이 맞아야 한다는 점도 알 수 있습니다. 이 경우 Map{Int,Int}Enumerable{(Int,Int)}이기 때문에 마지막 예제가 성립하였습니다.

벡터, 집합, 대응을 제외한 다른 콜렉션에 대한 나열 문법은 단순히 콜렉션 클래스를 Enumerable 인터페이스를 가진 값으로 호출한 것과 같습니다:

a = SortedMultiset([5, 4, 3, 2, 1, 2, 3, 4])
a println()               -- SortedMultiset([1, 2, 2, 3, 3, 4, 4, 5])

물론 순서가 없는 콜렉션의 반복 순서는 임의적이니까, 순서가 없는 콜렉션을 초기화한다 하더라도 원소를 넣는 순서에 따라 그 결과가 바뀔 수 있는 콜렉션이라면 주의할 필요가 있습니다.

조건 제시 문법. 다른 콜렉션으로부터 새로운 콜렉션을 만들어 낼 수 있습니다:

a = [1, 2, 3]
b = [x**2 for x <- a]             -- b는 [1, 4, 9]
c = [5-x for x <- b if x<=5]      -- c는 [4, 1]
d = :{x + y for x <- a, y <- a}   -- d는 :{2, 3, 4, 5, 6}
e = {x => x**3 for x <- a}        -- e는 {1 => 1, 2 => 8, 3 => 27}

변경 가능한 콜렉션을 가정하면, 벡터에 대한 조건 제시 문법 [f(x1,...,xn) for x1 <- L1 if g1(x1) ... for xn <- Ln if gn(x1,...,xn)]는 다음 코드와 동일해집니다:

L = []
for x1 <- L1
    if g1(x1)
        ...
            for xn <- Ln
                if gn(x1,...,xn)
                    L append(f(x1,...,xn))
                end
            end
        ...
    end
end

집합이나 대응에 대한 조건 제시 문법도 유사하게 정의되며, for x1 <- L1, x2 <- L2와 같은 축약형은 for x1 <- L1 for x2 <- L2와 동등하게 해석됩니다. 따라서, x1부터 xn까지의 변수들은 조건 제시 문법 안에서만 쓸 수 있으며, xkfor xk <- ... 뒷쪽에서만 쓸 수 있습니다(다만, f(x1,...,xn) 부분은 당연히 예외).

내장 문법이 없다는 다른 콜렉션의 경우, 조건 제시 문법과 꽤나 유사한 단순 코루틴 문법을 사용하면 비슷한 효과를 볼 수 있습니다:

a = {1 => 2, 3 => 4}
b = Bimap((y, x) for x, y <- a)
-- ...는 Bimap( ((y, x) for x, y <- a) )와 동일함

TODO flattening comprehension: [\(0..k) for k <- 0..3] == [0, 0, 1, 0, 1, 2, 0, 1, 2, 3] for example.

옵션

나루에 () 값이 있긴 하지만, 이 값의 주된 사용처는 return 명령이 없는 함수의 반환값을 나타내기 위한 것입니다. (많은 함수형 언어에서 unit이라고 하는 것과 비슷한데, 이 이름은 이 타입에 대해서 가능한 값이 하나 밖에 없어서 붙여진 이름입니다.) 종종 어떤 조건이 만족될 때만 변수에 값을 넣고, 만족되지 않으면 아무 것도 넣지 않고 싶을 경우 ()를 쓸 수는 없습니다. 왜냐하면 ()는 다른 값과 크게 다르고 제대로 처리하지 않으면 종종 눈 앞에서 죽어 버릴 수도 있기 때문이지요.

더 좋은 방법은, 명시적으로 조건이 만족될 경우와 만족되지 않을 경우에 대해서 처리하겠다고 선언하는 겁니다. 따라서 나루는 Option 클래스를 가지고 있습니다(타입 명세에서는 T?라고 쓸 수도 있습니다):

lexicon = {
    "hello" => "Hallo",
    "dog" => "Hund",
    "wing" => "Flügel",
}
lexicon get('dog') println()             --> Just("Hund")
lexicon get('cat') println()             --> None

Map get() 메소드는 Option{T} 값을 반환하며, 이는 T 타입의 값을 담고 있는 Just{T} 값이거나, 아무 것도 담고 있지 않는 None 값 중 하나가 될 수 있습니다. 반대로 just(value) 또는 none이라고 쓰면 새 Option 값을 만들 수 있으며, 이 과정에서 타입 인자를 쓸 필요는 없습니다(타입 추론을 할 수 있으니까). 어떤 값이 None을 담고 있는지는 hold 속성을 쓰면 됩니다:

word2 = lexicon get(word)
if word2 hold
    #"#word -> #(word2 value)" println()
else
    "없음" println()
end

하지만 좀 복잡해 보이죠. 이제 좀 더 근본적인 질문을 해 봅시다: 도대체 왜 Option이 콜렉션을 얘기하는데 튀어 나왔을까요? 왜냐하면 Option은 원소가 0개거나 1개인 매우 단순한 콜렉션으로 생각할 수도 있기 때문입니다. 사실 Option 값으로 반복도 가능합니다:

for w <- word2
    #"#word -> #w" println()
    break
else
    "없음" println()
end

여기서 for 블록의 else 부분은 반복문이 break이나 return 같은 것으로 강제로 종료된 게 아니라 원소가 더 이상 없어서 종료될 때 실행됩니다. 하지만 이 또한 복잡하기 때문에, 나루는 다음 문법을 추가로 지원합니다:

if w <- word2
    #"#word -> #w" println()
else
    "없음" println()
end

이 문법은 Option 클래스와 그 자식 클래스에만 쓸 수 있습니다. 다른 콜렉션이라면 그냥 for 루프를 쓰세요.

(덤: noneNone{Bottom} 클래스의 값으로 정의되며, 여기서 Bottom은 아무 값도 속하지 않는 타입이며 다른 모든 타입들의 서브타입입니다. Option은 공변적이므로—변경할 수 없으니까요!—, noneT가 무엇이든간에 None{T}의 값이 됩니다. 그럴듯하죠.)

문자열

TODO also syntax

범위

TODO also syntax

콜렉션 연산

TODO

또 다른 종류의 타입

어, 사실 제목이 좀 모호한데요. 우리가 지금까지 “타입”과 “클래스”라는 말을 아무 의심 없이 써 왔지만 이제는 고백할 때가 되었습니다: 그대로 쓰기에 두 말은 굉장히 문제가 있습니다. 나루에는 이 말들에 해당할 만한 세 가지 대상이 있습니다:

  1. class 속성이 반환하는 무언가
  2. 함수(와 클래스 생성자)가 전달받을 수 있는 값의 종류
  3. 함수(와 클래스 생성자)가 실제로 전달받은 값의 종류

2번과 3번이 다른 이유는 (Rational)->() 타입의 함수에 42라는 값을 집어 넣어도 탈이 안 난다는 걸 생각하면 분명해집니다. Guy L. Steele이 이 문제에 대해서 아주 장문의 글을 썼는데, 우리는 (다소의 문제를 감수하고) 간단한 답을 택하려고 합니다: 실행 시간에 쉽게 알아 낼 수 있는 것을 “클래스”라고 부르고, 그렇지 않은 건 “타입”이라고 부르겠습니다. 따라서 우리는 1번을 “메타클래스”, 2번을 “타입”, 그리고 3번을 “클래스”라고 부를 겁니다.

이렇게 정리하고 나니 문제가 분명한데, 지금까지 말한 것은 클래스에 대한 얘기였지 타입에 대한 얘기가 아니었습니다. 따라서 여기서는 타입에 대해서 말하기로 합니다. 우선, 나루는 동적으로 타입된 언어도 아니요, 정적으로 타입된 언어도 아닙니다. 나루는 둘 중 중간 어딘가에 걸쳐 있습니다. 만약 다음과 같은 코드를 쓰면:

a = 42

실제로는 이런 의미가 됩니다:

a: * = 42

여기서 *는 이 값이 “동적임”을 나타내는 특수한 타입입니다. 실행 시간에 어느 때라도 오류가 나더라도 이상하지 않지요. 하지만 다음과 같이 대신 썼다면:

a: String = 42

그럼 컴파일러는 42(Int 클래스의 값)가 String 타입에 (당연히!) 속하지 않는다는 걸 알고 있기 때문에 바로 오류를 냅니다. 동적인 타입과 정적인 타입을 섞어 쓸 수도 있습니다:

a: * = 42
b: Int = 54
(a / b) println()

a가 동적이었으므로 a / b도 동적이 됩니다(a가 무슨 클래스의 값이냐에 따라서 Real이나 Rational이 나올 수 있습니다). TODO we are not certain about a mix of dynamic and static types; we may adopt some other works here

곧 다른 종류의 타입에 대해서 살펴 보겠습니다만, 우선 가장 중요한 거 하나만 먼저 언급하겠습니다. Tn들이 인자 타입들이고 U가 반환 타입인 함수 타입은 (T1,T2,...)->U으로 씁니다.

zero: ()->Int := fun () -> 0

-- 이름 붙은 인자는 함수 타입의 일부임
double: (Int)->Int := fun (x:Int) -> x * 2
triple: (x:Int)->Int := fun (x:Int) -> x * 3
double(42)         --> 84
triple(42)         --> 126
triple(x:42)       --> 126

-- 하지만 타입 시스템에 영향을 끼치지는 않음
pow: (Int,Int)->Int := \
    fun (x:Int,y:Int) -> base == 0 and exponent == 0 then 1 else x ** y
pow1: (x:Int, y:Int)->Int := pow
pow2: (y:Int, x:Int)->Int := pow1
pow1(x:3, y:4)     --> 81
pow2(x:3, y:4)     --> 64

즉, 인자 이름은 함수를 호출할 때만 사용되는 일종의 장식에 불과합니다. (프로그래밍 언어 용어를 빌리자면: 인자 이름은 서브타이핑에 영향을 주지 않습니다. 즉, (x:A,y:B)->C <: (y:A,x:B)->C이고 (y:A,x:B)->C <: (x:A,y:B)->C입니다.) 하지만 이런 오묘함은 실제로 보기 힘들텐데, 왜냐하면 보통 함수를 선언할 때 다음과 같이 쓰면:

add(x:Int, y:Int) := fun
    ...
end

그러면 그 타입에도 자동으로 정확한 인자 이름이 들어 가기 때문입니다. 사실 위 코드는 다음과 동일합니다:

add: (x:Int, y:Int)->_ := fun (x:Int, y:Int):_
    ...
end

또한 함수 문법에서 함수 인자 뒤에 결과 타입이 붙을 수도 있다는 점을 주목합시다. 이 문법은 :->로 바뀌는 것만 빼면 일관된 선언에서도 add(x:Int, y:Int)->Int := ...와 같이 사용할 수 있습니다. (빈 칸 _는 이 장 뒤에서 설명합니다.)

타입 명세

나루의 타입 명세는 그 자체로 하나의 작은 언어를 이룹니다:

최상위 타입

Int, String 같은 것들입니다. 이들 타입은 보통은 현재 스코프에서 사용할 수 있는 클래스를 반영하지만, 필요하다면 다른 모듈에 있는 타입도 쓸 수 있습니다:

import somemodule
some: somemodule SomeClass = somemodule foo()

import anymodule *             -- 이 문장이 AnyClass를 들여 오면...
any: AnyClass = bar()          -- 여기서도 사용할 수 있음

타입 명세의 모든 최상위 타입은 선언(:=)을 통해 정의되어야 합니다. TODO is this restriction necessary? can’t we have the runtime type checker?

인자가 있는 타입

다른 말로 하면 정적 인자가 있는 타입. 선언과 동일한 형태로, 이를테면 Vector{Int}와 같이 씁니다.

다른 종류의 타입이라도 정적 인자 이름들을 앞에 붙여서 인자가 있는 타입을 만들 수 있습니다. 이를테면 {T,U} ((T)->U,T)->U는 어떤 TU에 대해서라도 ((T)->U,T)->U 형태로 나타낼 수 있는 타입을 나타냅니다. 이 정적 인자 이름들은 바깥에서는 사용할 수 없습니다: ({T} (T)->Int, {T} (T)->String)->()은 올바른 타입이고 (Char)->Int 타입의 값과 (String)->String 타입의 값을 인자로 받을 수 있습니다.

Vector 같이 인자가 있는 클래스를 이름 그대로 쓰면 {T} Vector{T}와 같은 의미가 됩니다(즉, 정적 인자에 상관 없이 해당 타입의 값을 받음).

클래스 속성

타입 명세에서 선언(:=)을 통해 정의된 클래스 속성을 접근할 수 있습니다. 대표적인 예제로는 Collection 클래스의 MutableImmutable 타입 속성이 있고, 이를테면 Vector{Int} Immutable 같이 씁니다. (콜렉션 목록에서 알 수 있지만 이 명세는 PureVector{Int}와 동일합니다.)

순서쌍 타입

(), (Int, Real), (Int, Real, String) 같은 것들입니다.

순서쌍 타입에서 하나 이상의 타입 앞에 \를 붙일 수 있으며, 이 경우 해당 타입은 길이가 고정된 순서쌍 타입과 동일해야 합니다. (이를테면 T(Int,Real)이었다면 (\T,String,\T)(Int,Real,String,Int,Real)과 동일합니다.) 순서쌍 타입 맨 뒤에는 ...이 올 수 있으며, 이 경우 해당 순서쌍 타입은 주어진 갯수나 그 이상의 타입을 받게 됩니다. 사실 이는 \_와 완전히 동일한 의미입니다.

TODO exact encoding is not decided

함수 타입

()->(), (Int,Int)->Int, (fmt:String,args:Value...)->() 등등. 이미 기본적인 문법은 설명했고, 안에 들어갈 수 있는 인자 명세는 다음과 같습니다:

  • 보통 인자는 그냥 타입 이름을 쓰면 됩니다. (String 등)
  • 이름이 붙은 인자는 앞에 인자 이름을 붙여 나타냅니다. (fmt:String 등)
  • 가변 인자는 타입 이름 뒤에 ...을 붙입니다. (args:Value... 등) 이 인자의 실제 타입은 주어진 타입의 벡터가 됩니다(앞의 예제의 경우 Vector{Value}). 이 인자가 나타날 경우 이 인자는 항상 이름이 붙은(keyword-only) 인자를 제외하고 맨 마지막에 나타나야 합니다.
  • 선택 인자는 타입 이름 뒤에 ?를 붙입니다. (String? 등) 이 인자의 실제 타입은 주어진 타입의 Option이 됩니다(앞의 예제의 경우 Option{String}).

앞에서 언급했듯 인자 이름은 타입 시스템에 어떤 영향도 끼치지 않습니다. 다만 항상 이름이 붙은 인자의 이름은 바꿀 수 없으며, 이 인자가 선택 인자가 아닌 이상 그대로 놔둬야 합니다.

합집합 및 교집합 타입

Int|Real, {T} (Reader{T} & Writer{T}) 등등. 이들은 주어진 타입들의 합집합 또는 교집합을 나타내며, 문법상으로는 정적 인자나 속성보다 우선순위가 낮습니다.

동적 타입

*로 표현된 타입은 동적 타입 체크를 활성화시킵니다. 이 타입은 타입 명세의 아무 위치에나 나타날 수 있으며, 이를테면 (*,*,*)->*fun (x,y,z) -> x의 기본 타입이 됩니다. (덤: 물론 이 값은 {T} (T,*,*)->T 타입도 가질 수 있긴 하지만, 구현을 간단하게 하기 위해 이런 종류의 최적화는 행하지 않습니다.)

동적 타입은 모든 정적 타입의 수퍼타입이며, 따라서 정적 타입은 동적 타입으로 자동으로 변환됩니다. 반대 방향으로 변환하려면 x as SomeType과 같은 타입 캐스팅을 사용해야 합니다. 정적 타입과 동적 타입을 섞는 방법에 대해서는 뒤에 서술합니다.

추론된 타입

_로 표현된 타입은 엄밀히 말하면 타입이 아니고, 컴파일러가 그 자리를 적절한 타입으로 채워야 하는 빈 칸을 나타냅니다. 예를 들어 x: _ := 42x: Int := 42와 거의 동일합니다. (실제로는 SmallInt 같이 구현체 별로 다른 Int의 적절한 서브타입이 선택됩니다.) 동적 타입과 마찬가지로 _은 타입 명세의 어느 위치에라도 나타날 수 있으며(이를테면 Vector{_}_{Int} 같은 게 가능함), _... 앞에 나타나면 임의 갯수의 함수 인자의 타입에 대응됩니다(이를테면 (Int,_...)->()).

이 타입은 함수의 기본 반환 타입이며, 사실은 함수 수식의 반환값은 항상 이 타입으로 고정되어 있습니다. 예를 들어 fun (x,y) -> x+yfun (x,y)->_; return x+y; end와 동일하며 그 타입은 (x:*,y:*)->*입니다. 이 타입은 순서쌍을 부술 때 사용하지 않는 원소의 타입으로도 쓰이며, use static이 활성화되었을 경우 타입이 정해져 있지 않은 대입이나 선언의 타입으로도 쓰입니다.

self 타입

self로 표현된 타입은 타입 명세를 포함하는 클래스의 타입을 나타냅니다. 클래스 메소드 바깥에서 이 타입은 오류입니다.

이 타입 명세는 보통의 수식 문법에 비하면 많이 제약되어 있지만, 이는 컴파일 시간에 효율적으로 처리할 수 있도록 의도적으로 설계된 것입니다.

(덤: 기술적으로 말하자면, 나루의 타입 시스템은 (일단은) 1차(rank-1) predicative Hindley-Milner 타입 시스템을 이름 기준 서브타이핑으로 확장한 것입니다. 이 타입 시스템에서 타입 추론은 결정 가능한 걸로 알려져 있지만 아직 증명하지는 않았습니다. 앞으로는 System F의 다차 imprecative 부분집합을 쓸 가능성도 있으며, 이 경우 적절한 제약을 통해서 결정 가능한 타입 추론을 얻게 될 것입니다.)

TODO describe syntactic sugars like T? for type expression

정적 타입과 동적 타입을 섞기

나루의 정적 타이핑은 선택사항입니다만, 일부 경우에서는 정적 타이핑을 쓸 수 밖에 없는 경우가 존재합니다. 대표적인 사례로 다중 디스패치가 있으며, 이 경우 어떤 함수가 호출되어야 하는지 알려면 인자들의 타입을 알아야만 합니다:

#+#(a: Int, b: Real) := fun -> Real from(a) + b
#+#(a: Real, b: Int) := fun -> a + Real from(b)

만약 어떤 수식이 정적 타입을 가진다면, 그 수식에 대한 속성 및 메소드 접근은 컴파일 시간에 정적으로 체크됩니다. 따라서 만약 위에서 첫번째 함수의 내용을 fun -> a addToReal(b)로 바꿨다면, Int에는 addToReal이라는 메소드가 없으니 실행되기 훨씬 이전에 오류를 발생시킬 것입니다.

정적 타입에서 동적 타입으로. 이 동작은 오타로 인한 많은 버그들을 잡는데 한 몫을 하지만 귀찮은 것도 사실입니다. 정적 타입을 동적 타입으로 바꾸는, 즉 컴파일러가 타입 정보를 잊게 하는 방법은 여러 가지가 있는데, 쉬운 방법은 정적 타입을 가지는 값을 변수에 대입하는 것입니다. 변수는 기본적으로 동적 타입을 가지므로 타입 정보는 대입 이후 사라집니다.

#+#(a: Int, b: Real) := fun
    a0 = a                  -- a0의 타입은 *
    return a addToReal(b)   -- 실행시간에 오류를 발생
end

이와 유사한 방법으로 아예 as 수식을 사용해서 명시적으로 동적 타입으로 변환하는 방법이 있습니다. 나루는 주어진 수식을 명시된 타입으로 처리할 것이며, 필요할 경우 그 타입이 맞는지 체크하는 코드도 생성할 것입니다.

#+#(a: Int, b: Real) := fun -> (a as *) addToReal(b)

마지막 방법은 인자가 동적 타입인 함수를 사용하는 것입니다. 이렇게 하면 코드를 크게 변경하지 않고도 정적 타입을 가진 인자를 안에서는 동적 타입을 사용하는 함수에 전달할 수 있게 됩니다. 물론 이 과정은 실행 시간에 체크됩니다.

addHelper(a, b) := fun -> a addToReal(b)
#+#(a: Int, b: Real) := fun -> addHelper(a, b)

개인적으로는 첫번째 및 두번째 방법을 그렇게 좋게 생각하지는 않는 것이 이들은 버그를 빨리 잡는다는 정적 타이핑의 장점을 정면으로 위배합니다. 나루가 정적 타입과 동적 타입을 모두 가지고 있는 이유는 점진적인 변화를 가능케 하려는 것입니다: 정적인 함수도 있을 수 있고 동적인 함수도 있을 수 있는데, 실행 시간 오류는 언제나 동적인 함수에서만 발생하게 하려는 것입니다. 만약 특정 함수를 정적으로 만들고 싶다면 함수가 실행되는 내내 정적으로 유지하는 게 좋겠습니다.

동적 타입에서 정적 타입으로. 반대로 생각하면 좀 더 안전한 처리를 위해 동적 타입을 정적 타입으로 한정시킬 수도 있습니다. 첫번째 방법은 명시적인 타입 캐스팅을 사용하는 것으로, 캐스팅이 실패하면 CastError 예외가 발생합니다.

double(x) := fun
    do
        return (x as Int) * 2
    but CastError
        do
            return (x as String) ~~ 2
        but CastError
            raise ValueError("x is not an integer nor a string")
        end
    end
end

중첩된 do~but 블록 대신에 Value isa 메소드를 쓰는 것도 가능합니다.

double(x) := fun
    if x isa(Int) then
        return (x as Int) * 2
    elseif x isa(String) then
        return (x as String) ~~ 2
    else
        raise ValueError("x is not an integer nor a string")
    end
end

물론 이 경우에도 캐스팅을 명시적으로 해서 컴파일러에게 x의 타입을 알려 줄 필요는 없습니다. 컴파일러는 Value isa 메소드에 대해서 아무 것도 모릅니다! 하지만 이렇게 하면 실수하기 매우 쉽기 때문에(만약 x as SomeType이 특정한 경우에 대해 빠져 있다면?), 나루에서는 다음과 같은 축약형을 제공합니다:

double(x) := fun
    given x
    case Int as x then          -- 기존의 x 선언에 우선함
        return x * 2
    case String as y then       -- 이 경우 x 자체는 여전히 동적 타입
        return y ~~ 2
    case * then
        -- 다른 모든 타입은 동적 타입으로 변환할 수 있으므로,
        -- x가 Int나 String이 아닌 모든 경우를 커버함.
        raise ValueError("x is not an integer nor a string")
    else
        -- 이 경우는 불가능하며 사실은 아예 생략해도 괜찮음.
    end
end

given~case 구조는 일반적으로 패턴 매칭에 쓰입니다만, 여기서 볼 수 있듯 좀 더 일반적인 타입 매칭용으로도 사용할 수 있습니다. 패턴 및 타입 매칭에 대한 자세한 규칙은 뒤에서 서술합니다.

동적 타입을 정적 타입으로 변환하는 또 다른 방법은 (어쩌면 다중 디스패치를 사용하는) 정적 타입 인자를 가지는 함수를 쓰는 것입니다. 이게 가능한 이유는 정적인 함수에 동적 타입을 가지는 잘못된 인자를 넣어도 오류는 실행 시간에 나기 때문입니다. 사실 as 연산자는 정확히 이 방법으로 정의되어 있습니다: x as SomeType(fun (_arg:SomeType) -> _arg)(x)와 동일합니다.

정적 타이핑을 강제하기. 이미 변수들의 타입이 기본적으로는 동적 타입(*)이라는 얘기를 했습니다. 이 동작을 바꾸려면 use staticuse dynamic을 사용할 수 있습니다.

-- 타입 체커가 "p length"가 오류임을 체크해 줌
do
    use static
    p = 4
    q = "Hello"
    "This line should not be printed" println()
    (p length + q length) println()
end

-- 프로그램을 실행하기 전까지 타입 체커는 "p length"가 오류인지
-- 아닌지를 체크하지 않음
do
    use dynamic
    p = 4
    q = "Hello"
    "This line will be printed though" println()
    (p length + q length) println()
end

참고로 여기서 맨 뒤에서 두번째 줄을 (4 length + "Hello" length) println()으로 바꾸면 use dynamic에도 불구하고 여전히 컴파일 시간 오류가 난다는 점에 유의해야 합니따. 이는 왜냐하면 4는 언제나 Int(나 그 서브타입)을 가지기 때문입니다. use staticuse dynamic은 오로지 대입이나 선언이 사용되었을 때만 그 동작을 바꿉니다.

use static은 또한 인자 타입에 영향을 주지 않으며, 인자 타입이 주어지지 않았을 경우 그 기본값은 항상 동적 타입입니다. 따라서 정적 타이핑을 기본으로 사용할 경우 모든 인자에 타입을 주어야 합니다. (덤: 함수 내용물에서 인자 타입을 추론해 내는 게 안 되는 일은 아니지만 매우 제약되어 있습니다. 예를 들어 함수 succ(x:_)->_ := func -> x + 1을 생각하면, #+#가 확장이 가능하다고 칠 때 인자 타입이 무슨 타입으로 추론되어야 할까요? 답은 다음 장에 있습니다.)

타입 추론

나루에서 타입 추론은 _ 타입을 사용할 때 일어납니다.

x: Int := 42
y: _ := x + x         -- y의 타입은 Int
z: _ := x ** y        -- z의 타입은 Real (y가 음수일 수도 있으므로)

이 타입은 또한 다음 경우에서 자동으로 사용됩니다:

이 타입은 또한 새 타입을 선언하는 일관된 선언, 이를테면 X(a:Int) := class ... end에서도 중요한 역할을 합니다. 이 경우, 이 선언의 반환 타입은 실제 타입 선언에서 나올 그 타입과 일치해야 하기 때문에 (따라서 X(a:Int)->Y := class ... end는 오류입니다) 반환 타입은 선언 이름과 중복될 수 밖에 없고, 따라서 타입 추론을 쓰는 게 더 편리합니다.

나루의 타입 추론은 Hindley-Milner 알고리즘에 기반해 있습니다. 이 알고리즘은 _로 표시된 빈 칸에 실제 타입을 끼워넣는데 필요한 제약 조건들의 목록을 생성하고 풀어 냅니다. 알고리즘은 항상 종료하지만(“결정 가능하다”고 합니다) 의도적으로 짠 코드의 경우 알고리즘을 끝내는 데 얼마든지 많은 시간이 걸릴 수 있도록 할 수 있습니다. 나루 컴파일러는 타입 추론을 어디까지 시도할지 제어할 수 있는 옵션을 가지고 있습니다.

TODO boundary of type inference (maybe a module boundary) and type inference for open types

패턴 매칭

나루 타입 시스템은 주로 이름이 붙은 타입(“nominal”하다고 합니다)을 다룹니다만, 종종 구조적으로 정의된 타입을 다루는 게 편할 때도 있습니다. 예를 들어 이진 트리는 잎(leaf)이거나 가지(branch)일 수 있으며, 가지에는 하나의 값과 두 개의 더 작은 트리가 들어 있습니다. 나루에서는 이를 이렇게 표현합니다:

Tree{+T} := trait
    size()->Int := _
end

Leaf{+T}() := class <- Tree{T}
    size() := fun -> 0
end

Branch{+T}(left:Tree{T}, value:T, right:Tree{T}) := class <- Tree{T}
    size() := fun -> left size() + right size() + 1
end

자료 구조와 알고리즘에 대해 익숙한 분들은 이진 트리에 어떻게 값을 넣고 빼는지 바로 기억해 내실 겁니다. 그러나 이런 연산들은 제대로 구현하기 매우 어려울 수 있는데, 예를 들어 간단한 트리 회전(레드-블랙 트리가 이 연산을 매우 많이 사용하지요)조차 귀찮아질 수 있습니다:

rotateRight{T}(node:Tree{T}) := fun
    use static
    if node isa(Leaf) then raise ValueError("the top node is not a branch") end
    top = node as Tree{T}
    if top left isa(Leaf) then raise ValueError("the left node is not a branch") end
    left = top left as Tree{T}
    return Branch(left left, left value, Branch(left right, top value, top right))
end

rotateLeft{T}(node:Tree{T}) := fun
    use static
    if node isa(Leaf) then raise ValueError("the top node is not a branch") end
    top = node as Tree{T}
    if top right isa(Leaf) then raise ValueError("the right node is not a branch") end
    right = top right as Tree{T}
    return Branch(Branch(top left, top value, right left), right value, right right)
end

이 코드는 어떤 트리가 우리가 원하는 특정한 형태인지 알아 내고 트리의 컴파일 타입을 필요한 만큼 좁히는데 너무 많은 코드를 사용합니다. 패턴 매칭의 주된 목표는 이런 코드를 구조화된 방법으로 표현하고 뻔한 코드를 최소화하기 위함입니다:

rotateRight{T}(node:Tree{T}) := fun
    given node
    case Branch(Branch(child1, value1, child2), value2, child2) then
        return Branch(child1, value1, Branch(child2, value2, child2))
    else
        raise ValueError("cannot perform the tree rotation on this tree")
    end
end

rotateLeft{T}(node:Tree{T}) := fun
    given node
    case Branch(child1, value1, Branch(child2, value2, child2))
        -- 경우 명세 뒤에 있는 "then"은 생략할 수 있음
        return Branch(Branch(child1, value1, child2), value2, child2)
    else
        raise ValueError("cannot perform the tree rotation on this tree")
    end
end

이제 뭐가 어떻게 돌아가는지 명확하지요. 게다가 이렇게 놓고 보니 트리 회전이 중순위 순회(in-order traversal)에 전혀 영향을 주지 않는다는 점이 명확해졌습니다. 각 경우에 사용된 childNvalueN 변수들은 해당하는 case 블록 안에서만 유효합니다.

case가 들어 있는 클래스. 나루는 given에 정적 타입이 주어졌을 경우 빠진 case 블록에 대해서 오류를 냅니다. 예를 들어서 앞에서 else 블록을 빼먹었다면 node가 단순히 Leaf일 경우를 놓칠테니 오류가 나겠지요. 정확히 같은 이유로, 다음 코드는 컴파일 오류입니다:

TreeReduction{T,U} := trait
    reduce(node:Tree{T})->U := _
end

TreeSum() := class <- TreeReduction{Int,Int}
    reduce(leaf:Leaf{Int}) := fun -> 0
    reduce(branch:Branch{Int}) := fun ->
        self reduce(branch left) + branch value + self reduce(branch right)
end

당장 봐서는 왜 이 코드가 문제가 있는지 알기 어렵습니다만, Tree{T}Trident{T}라는 서브클래스를 새로 선언하면(아마도 2,3-트리 같은데 쓰겠군요) 왜 문제가 있는지 알 수 있습니다. TreeSum reduceTrident에 대한 경우를 전혀 처리하지 않습니다! 이 코드가 오류를 내지 않게 하려면 Tree{T}의 서브클래스가 더 추가될 수 없다는 걸 알려 줄 방법이 필요한데, 이는 클래스에 case를 추가해서 가능합니다.

Tree{+T} := trait
    size()->Int := _

    case Leaf{+T}() := class <- Tree{T}
        size() := fun -> 0
    end

    case Branch{+T}(left:Tree{T}, value:T, right:Tree{T}) := class <- Tree{T}
        size() := fun -> left size() + right size() + 1
    end
end

트레이트나 클래스 선언에 들어 있는 caseglobal과 유사하게 해당 식별자가 현재 스코프 바깥에 선언됨을 나타냅니다만, 그 밖에도 주어진 case 말고 해당 트레이트나 클래스의 다른 서브타입이 더 이상 없음을 함께 나타냅니다. 따라서 앞에서 언급한 Trident{T}는 이 코드에서는 아예 선언이 불가능합니다. case 트레이트나 클래스는 중첩되는 것이 가능합니다.

경우 명세. case 블록에 들어 갈 수 있는 경우 명세는 타입 명세를 확장한 것이며, 따라서 일반적인 트레이트나 보통 타입들은 case 뒤에 곧바로 쓰일 수 있습니다. 심지어 동적 타입 *를 사용하는 것도 가능하며 이 경우 모든 남아 있는 타입에 대응하게 됩니다. (이 의미에서 빈 칸 _는 경우 명세에서만은 동적 타입과 같은 의미를 가집니다.)

경우 명세는 타입 명세에 다음과 같은 수식을 덧붙인 것입니다:

클래스 해체

Branch(l,v,r)이나 Leaf() 같은 것입니다. 빈 생성자 인자 목록은 주어진 클래스가 생성자 인자를 가지지 않는다는 걸 명시할 때만 쓰입니다.

이렇게 해체된 이름들은 주어진 case 블록 안에서만 유효합니다. 이 이름들은 함수 인자와 매우 비슷하게 동작합니다: 인자 순서를 바꾸기 위해 이름 있는 인자 문법(Branch(left:l, right:r, value:v))을 쓸 수도 있고, 빈 칸(Branch(_,v,_))을 써서 불필요한 인자들을 무시할 수도 있습니다.

클래스 해체는 중첩될 수 있으나, 그렇다고 해서 일반적인 타입 명세가 해체된 이름 위치에 마음대로 나올 수 있는 건 아닙니다. 예를 들어 Branch(Branch(l2,v2,r2),v,r)은 가능합니다만 Branch(Branch,v,r)은 안 됩니다. (가지의 왼쪽 부트리의 이름을 Branch로 정하려는 경우는 예외입니다.)

별명

Branch(l,v,r) as bInt as n 같은 것들입니다.

해체된 클래스 값이나 타입 명세에는 원래 별도의 이름이 붙지 않지만 이 문법을 사용해서 별명을 붙일 수 있습니다. use static 모드가 켜져 있을 경우, given-case 구조를 쓴다고 해서 자동으로 given에 들어 있는 수식의 타입이 case 블록 안에서 바뀌는 것은 아니기 때문에 별명을 직접 붙일 필요가 있습니다. (이 별명은 given에 있는 변수 이름과 정확히 같을 수도 있습니다. case 안에서는 바깥쪽 이름이 무시됩니다.) 별명은 중첩된 클래스 해체에도 사용할 수 있습니다.

명시적인 타입 변수

Enumerable{T} for T((Dest,Src)->Dest,Dest,Enumerable{Src})->Dest for Src,Dest 같은 것들입니다.

명시적인 타입 변수는 타입에 case 블록 안에서 쓸 수 있는 추가적인 이름을 주는데 쓰며, 경우 명세의 맨 바깥쪽에서만 쓰일 수 있습니다. (따라서 Enumerable{T} for T라고 했을 경우, 만약 이 타입이 String에 대응되었으면 case 안에서는 TChar로 설정되게 됩니다.) 이걸 제외하면 이렇게 선언된 타입 변수는 빈 칸 _과 동일한 역할을 합니다: Enumerable{T} for TEnumerable{_}와 동일하게 될 것입니다.

리터럴 값

RedBlackBranch(l,v,r,red:true) 같이 씁니다. 리터럴 값은 정확히 그 값에만 매칭됩니다(정확히는 ==로 비교했을 때 true가 나올 때). 모호함을 피하기 위해 이 문법은 Bool, Int, Char, String 값에만 적용할 수 있습니다.

또 다른 종류의 타입

혹시 기억하시나 모르겠지만, 나루에서 “type”이라는 말은 키워드로도 사용되고 있습니다. type 속성을 써서 컴파일 시간 타입을 얻어 낼 수 있다는 점은 이미 살펴 보았는데, type을 다른 타입을 선언하는데 사용하는 것 또한 가능합니다:

MapFromInt{Value} := type <- Map{Int,Value}

좀 더 정확히 말해서, 이렇게 선언된 타입은 사실 기존 타입의 별명이고 새 타입이 아닙니다. 이를테면 MapFromInt{String}Map{Int,String}과 완벽하게 똑같습니다. 이 선언은 일반적인 선언만 가지고는 MapFromInt{Value} := Map{Int,Value}와 같이 정적 인자를 복사해야 하는 경우를 선언할 수 없기 때문에 고안되었습니다.

type 선언은 구조적인 타입을 선언하는 데도 쓸 수 있습니다:

List{+T} := type (Nil() | Cons(head:T, tail:List{T}))
Weekday := type (Mon() | Tue() | Wed() | Thu() | Fri() | Sat() | Sun())

참고로 생성자 인자는 하나도 없다고 하더라도 생략할 수 없습니다. 이는 경우 명세에서 생길 수 있는 모호성을 피하기 위함입니다. (예를 들어 WeekDate(year,weeknum,Mon)에서 Mon이 생성자 인자 없는 타입일까요 아니면 단순히 이름일까요?) 다만 괄호를 제거하기 위한 복잡한 방법이 존재하긴 하며, 이 방법은 truefalse를 구현하는데 사용되었습니다. (이 둘은 단순히 Bool := type (true | false)와 같이 정의됩니다.)

함수, 자세하게

타입을 설명했으니 이제 나루의 함수에 있는 기능들을 좀 더 자세히 알아볼 시간입니다.

다중 디스패치

TODO

열린 메소드

TODO

with 연산자

with 블록은 자동으로 자원들을 해제하기 위해 쓰였습니다만, with가 수식 안에서 쓰이면 다른 의미가 됩니다: 이 문법은 앞에 있는 수식들에 의미를 더하기 위해 사용됩니다.

"A(2,4) = ...#((2 ** 65536 with modulo: 1e20) - 3)" println()
         --> A(2.4) = ...45587895905719156733

with 연산자는 주어진 인자들을 가장 바깥쪽의 함수 호출이나 연산자에 붙입니다. 이 경우에는 거듭제곱 연산자 **가 가장 바깥에 있고 2 ** 65536은 실은 #**#(2, 65536)과 동일하기 때문에 (특수한 이름과 여러 문제들을 보세요) 실제로 호출되는 것은 #**#(2, 65536, modulo: 1e30)이 됩니다. with 연산자는 수식의 아주 맨 바깥에서 쓰이거나 다른 함수 호출의 유일한 인자로 쓰이지 않는 한 바깥을 괄호로 감싸야 합니다. 이름 없는 인자들도 (2 ** 65536 with 1e20)처럼 쓸 수 있으며, 여러 개의 인자들을 (sin(30) with :degrees) with accuracy: 0.5(--ulp--)처럼 쓸 수도 있습니다만 항상 권장되는 건 아닙니다. 중위 연산자의 경우 with는 같은 우선순위를 가진 여러 개의 연산자에 대해 동시에 적용되므로, a + b * c - d with e#-#(#+#(a, #*#(b, c), e), d, e)와 동일합니다(b * c를 제외한 모든 연산자에 with가 적용되었습니다). 다만 괄호가 쳐진 경우에는 그 안쪽까지 파고 들진 않습니다.

이 문법은 주로 연산자에 옵션을 지정하고자 하는 때 유용합니다(물론 함수에도 쓸 수 있긴 합니다만). 앞에서 언급한 a ** b with modulo: c가 한 예로, a ** b % c와 동일하지만 b가 클 경우 훨씬 효율적입니다. 많은 부동소숫점 연산에는 명시적인 rounding 인자가 있어서 a + b with :trunc처럼 쓸 수 있습니다(:trunc는 소숫점 아래 절삭을 의미합니다). 기본 증감값을 가지지 않은 범위a..b with step: c처럼 만들 수 있습니다…등등.

with는 아무 연산자도 없는 하나의 항에 적용될 수도 있으며, 이 경우 해당하는 값의 #with 메소드가 호출됩니다. 예를 들어 함수 값의 #with 메소드가 이름 있는 인자들을 받은 뒤 해당 함수에서 주어진 인자들을 채운 뒤 남은 인자들을 다음 호출에서 얻는 새로운 함수를 만드는 데 쓰일 수 있습니다: 이 경우 log with base: 3fun (x) -> log(x, base: 3)과 같을 것입니다. 어떻게 쓰이느냐에 따라서 이 문법은 나루를 확장하는 매우 강력한 방법으로 쓰일 수 있습니다.

몇몇 연산자들은 이 방법으로 확장할 수 없습니다. 연산자가 함수 호출로 아예 표현할 수 없는 경우(but 같이) 당연히 확장이 불가능하며, 대입이나 연산 뒤 대입도 확장할 수 없습니다. with 자신은 확장이 가능합니다.

자동 함수

지금까지 우리는 함수를 만드는 두 가지 방법을 봤습니다: 함수 선언(name := fun ... end)과 익명 함수(fun (args) -> ...)가 그것입니다. 그리고 세번째 방법이 있으니 자동 함수로, 이는 익명 함수와 별반 다를 게 없지만 매우 뻔한 경우를 더 짧게 쓸 수 있게 해 줍니다:

[3, 4, 5] map(_ + 1) println()       --> [4, 5, 6]

이렇게 줄일 수 있는 문법들은 다음과 같습니다: (아래에서 인자 이름은 모두 설명을 위해서 만들어진 것이고, 실제 타입에는 이런 인자 이름이 전혀 들어 있지 않음을 유의하십시오)

자동 함수 안에 있는 모든 인자들은 함수가 만들어질 적에 함께 계산됩니다. 따라서 엄밀히 말하면 (_ + 3 println())fun (x) -> x + 3 println()은 동일하지 않습니다.

자동 함수는 with 연산자와 마찬가지로 수식의 맨 바깥쪽에 있거나, 함수 호출의 유일한 인자이거나, (별 쓸모는 없지만) 그 자체로 하나의 문장일 경우를 제외하면 항상 괄호로 묶여야 합니다. 따라서 아까 전에 본 map(_ + 1) 같은 경우 별도의 괄호가 필요하지 않았지만 (_ + 1)(3)에서는 필요합니다.

타입 적응

TODO

코루틴

TODO

꼬리 호출

꼬리 호출(tail call) “최적화”(TCO)는 (보통은 재귀적인) 함수 호출이 호출 스택을 추가로 쓰지 않도록 하는 방법입니다. 예를 들어 다음 range 함수는:

range(low, high, result=:[]) := fun
    if low >= high then return result end
    return range(low+1, high, result ~ :[low])
end

…다음과 같이 루프로 효율적으로 구현될 수 있습니다:

range(low, high, result=:[]) := fun
    while not low >= high
        result = result ~ :[low]
        low += 1
    end
    return result
end

하지만 일부 경우에는 컴파일러가 전자를 후자로 자동으로 변환할 수 있는데, 이는 원래 버전에 비해 훨씬 효율적인 것입니다. 요약하자면 return somefunc(...) 꼴의 꼬리 호출은 모두 이런 식으로 변환이 가능합니다. 여러 알고리즘들은 그 자체로 재귀적이기 때문에 전자를 모두 후자로 바꾸는 것은 종종 직관적이지 않을 때가 있고, 이런 알고리즘을 많이 쓴다면 이런 최적화가 유용할 수는 있겠습니다.

문제는 꼬리 호출 최적화가 사실은 최적화가 아니다는 것입니다. 이 작업은 코드의 공간 복잡도를 바꿈으로써 코드의 의미를 변화시킵니다. 예를 들어 만약 꼬리 호출이 최적화되어 사라진 상태에서 실수로 이걸 꼬리 호출이 아닌 것으로 바꿔 버렸다면 공간 복잡도는 갑자기 확 올라갈 것입니다. 이 이유로 저는 TCO를 깨지기 쉬운 최적화로 간주해 왔습니다. (물론 함수형 프로그래밍 하는 사람들은 여기에 반대할지 모르겠습니다만, 그런 분들께서는 프로그래밍 언어가 단순히 도구나 추론 규칙[inference rule]이 아닌 기계와 프로그래머 사이의 인터페이스임을 다시금 상기해 주시길 바랍니다.)

따라서 모든 꼬리 호출을 최적화하는 대신, 나루는 명시적으로 최적화할 수 있다고 표시된 꼬리 호출만을 최적화합니다. 이 표시는 그냥 return 대신에 \return이라고 쓰는 것에 불과합니다:

range(low, high, result=:[]) := fun
    if low >= high then return result end
    \return range(low+1, high, result ~ :[low])
end

(\를 스택을 평평하게 만든다는 의미로 생각해 주세요.) \return 문장이 꼬리 호출이 아닐 경우(예를 들어 \return func(42) + 1 같은 경우) 바로 오류로 처리될 것이며, 따라서 컴파일이 잘 되었다면 나타나 있는 꼬리 호출들은 정말로 꼬리 호출이라서 최적화됨을 보장할 수 있습니다. 참고로 꼬리 호출로 호출되는 함수가 아무 것도 반환하지 않는다 하더라도(즉, ()를 암시적으로 반환한다 하더라도) \return을 써야만 최적화가 일어납니다.

모듈화 프로그래밍

간단한 프로그램이라면 한 파일 안에 끝나겠지만, 대부분의 프로그램은 그렇지 않기 때문에 어떤 식으로든 조직화되어야 합니다. 나루에서 모듈은 프로그램의 단위이고 import 문장으로 들여(import)올 수 있습니다:

-- constants.n
PI := 3.1415926535897932
E := 2.718281828

-- area.n
import constants *
radius = 42
#"area = #(PI * radius**2)" println()

일반적으로 다음과 같은 import 문법이 허용됩니다:

import constants                     -- 모듈을 값으로 들여옴
import constants PI                  -- 모듈에서 한 값을 들여옴
import constants (PI, E)             -- 모듈에서 여러 값을 들여옴
import constants PI as PI_16DIGITS   -- 모듈에서 한 값을 이름을 바꿔서 들여옴
import constants (PI as PI_16DIGITS,
                  E as E_9DIGITS)    -- 모듈에서 여러 값을 이름을 바꿔서 들여옴
import constants *                   -- 모듈에서 모든 값을 들여옴
import constants, math               -- 여러 모듈을 들여옴
import (constants, math)             -- 상동
import collection SkipList{Int} as IntSkipList
                                     -- 모듈에서 타입을 특정 정적 인수를 가지고
                                     -- 이름을 바꿔서 들여옴
import collection SkipList{Int}      -- 오류
import collection SkipList           -- 대신 이렇게 들여온 뒤 `SkipList{Int}`라고
                                     -- 할 수는 있음

모듈은 패키지에 포함될 수 있으며, 패키지는 또 다른 패키지에 포함될 수 있습니다. 예를 들어 다음과 같은 계층을 생각하면:

image/
    _.n
    readers/
        bmp.n
        png.n
        apng.n
        gif.n
    writers/
        bmp.n
        png.n
    pixmap.n
    color.n
    colormodel.n

여기서 _.n은 특수한 파일명으로 image 패키지 안에 있는 어느 모듈이든 들여 오기 전에 항상 실행됩니다. (_은 일반적으로 심볼명으로 쓸 수 없으므로, 이 모듈의 이름은 그냥 image입니다. 또한 _.n은 없어도 되며, 없을 경우 패키지 안에 들어 있는 다른 모듈이나 패키지는 여전히 들여올 수 있긴 하지만 import image와 같이 패키지명으로 들여 오는 것은 불가능해집니다.) image 패키지 바깥에서 import image readers bmpimport image colormodel이라고 하면 bmpcolormodel 모듈에 대응하는 값이 들어옵니다. 일반적으로 import는 절대 경로를 사용하며, 따라서 import image readers bmpimage pixmap 모듈 안에서 실행되어도 완전히 같은 의미를 가집니다. 상대 경로를 쓰려면 import self readers bmp(./readers/bmp.n을 가리킴)와 같이 self를 쓰거나, 상위 패키지의 경우 import super readers bmp(../readers/bmp.n을 가리킴) 같이 super를 쓸 수 있으며, 심지어 import super super image readers bmp(../../image/readers/bmp.n을 가리킴) 같은 것도 허용됩니다만 별로 쓰고 싶진 않네요.

상호 import를 쓰려면 관여하는 모듈 중 동적인(즉, 최상위 레벨에 대입문이 존재하는) 모듈이 많아도 하나여야 합니다. 이런 제약 때문에 라이브러리 제작자들은 정말 좋은 이유가 없으면 웬만하면 동적인 모듈을 피해야 합니다. TODO mutability of modules

마지막으로, 나루에는 언어에서 미리 제공하는 naru 패키지가 존재합니다. 처음에 Int, String 같은 것들을 초기화하는 기본 런타임 모듈은 naru core라는 이름을 가지고 있으며, 모든 나루 코드는 첫 줄 앞에 항상 이 모듈을 들여 옵니다(즉, import naru core *).

오 성스러운 유니코드여, 아 그리고 입출력도

한국에 살면서 로마자나 한글 (그리고 어쩌면 한자) 말고 다른 종류의 글자를 보고 살 일은 흔치 않을 겁니다. 만약 모든 프로그램이 이들 문자만 사용한다면 유니코드는 존재할 필요가 없었겠죠. 하지만 오호 통재라, 세상에는 온갖 종류들의 사람이 살고 온갖 종류의 글자들이 존재하니 유니코드는 이제 선택이 아닌 필수입니다.

대세를 따라서 나루는 유니코드를 언어 차원에서 지원합니다. 지금까지 제가 사용자 입력을 어떻게 하는지 한 번도 얘기하지 않은 걸 눈치채셨을 겁니다. 이 부분을 설명하려면 유니코드를 사용해야 하기 때문에 지금껏 이 얘기를 하지 않고 있었습니다. 다음은 나루에서 사용자 입력을 받는 (올바른) 방법입니다:

do
    ">>> " print()
    text = String readln(replace: "")
    if text == "" then retry end
but EOFError
    "You pressed Ctrl-D or Ctrl-Z!" println()
end

네, 좀 귀찮죠. 하지만 어차피 사용자 입력 처리라는 게 다 그렇고 그런 겁니다. String readln은 표준 입력에서 한 줄을 읽습니다. replace: ""라고 쓰면 한 줄을 읽을 때 유니코드 문자열로 변환할 수 없는 문자를 주어진 문자열(이 경우, 빈 문자열)로 치환하겠다는 의미입니다. 만약 이게 none이었다면 그런 문자가 나타났을 때 UnicodeDecodeError 예외가 발생하며, 예외를 직접 잡아서 처리해야 합니다. 이 인자의 기본값은 none입니다.

파일에서 줄들을 읽는 것도 별반 다르지 않습니다:

with f <- File("data.txt") open()
    text = String readln(f, replace: "?")
but EOFError
    text = ""
end

또는 for 루프를 쓸 수도 있습니다:

with f <- File("data.txt") open()
    for line <- f lines
        line println()
    end
end

여기서 볼 수 있듯 FileStream 값(File open 메소드의 반환값)은 반복 가능한(즉, Enumerable{String}의 서브타입인) lines 속성을 지원합니다. 하지만 이진 파일이라면 어떻게 할까요? 당장 생각나는 인터페이스는 lines 대신에 bytes를 쓰는 것일테죠:

with f <- File("data.bin") open()
    for byte <- f bytes
        byte println()
    end
end

이 경우 byte는 (1바이트가 8비트인 대부분의 컴퓨터에서) 0부터 255까지의 범위를 가지는 정수가 됩니다. 하지만 이 인터페이스는 한 번에 한 바이트만 처리할 수 있습니다. 만약 예를 들어 널 문자로 끝나는 문자열을 읽는다면 어떻게 할까요?

with f <- File("data.bin") open()
    bytes = Bytes readz(f)     -- 엄밀히는, Bytes readuntil(f, 0)과 동일
end

새로운 클래스 Bytes(바이트열[byte sequence]의 준말입니다)가 나왔습니다. 이 클래스는 Vector{Int}와 유사하나 모든 원소가 바이트 범위 안에 있다는 점이 다릅니다. 또한 여기에는 바이트열을 다루기 위한 각종 메소드들, 예를 들어 readuntil이나 asciilower/asciiupper 같은 것들이 존재합니다.

정리하자면, 나루에는 두 종류의 문자열 타입이 있습니다: 보통의 유니코드 문자열은 String, 그리고 바이트열은 Bytes입니다. 둘 사이의 변환은 Bytes decodeString encode 메소드로 이루어집니다:

with f <- File("data.bin") openrw()
    current = f position
    bytes = Bytes readz(f)
    str = bytes decode("utf-8", replace: "?")
    f position = current
    str encode("iso-8859-1") printz(f)    -- 그 자리에 덮어 씌움
end

나루는 바이트열을 위한 별도의 리터럴 문법을 지원합니다. b"abc"와 같이 ASCII 안에 있는 글자 한정으로 바이트 값을 그대로 쓸 수 있는 문법이 하나고, x"61 62 63" 같이 십육진수 두 자리로 쓴 바이트 값을 사용하는 문법이 다른 하나입니다.

스트림 구조

나루에서 입출력은 스트림, 즉 수퍼클래스 Stream을 통해 추상화됩니다. 이 클래스는 입력 원천(Reader)과 출력 배출구(Writer)를 바이트의 스트림으로 취급하며, 이 사실은 저수준 인터페이스에서 잘 드러납니다:

with f <- File("input") open()
    bytes = f read(512)
    if bytes length != 512 or bytes[510..511] != x"aa 55" then
        "Valid MBR" println()
    end
end

with f <- File("output") openwrite()
    f position = 0
    f write(bytes)
end

나루 스트림은 C의 fopen과는 다르게, 텍스트 모드와 이진 모드를 전혀 구분하지 않는다는 결정적인 차이가 있습니다. 다른 말로 하면, 나루에서 모든 스트림은 암시적으로 이진 모드로 열리고, 스트림에서 나오는 바이트를 해석하고 디코딩하는 과정은 StringBytes의 책임입니다. 이들은 바이트들을 문자열이나 바이트열로 처리해야 할지 정확한 과정을 알고 있습니다.

TODO

문법 확장

TODO

문법 확장은 컴파일러 지시로 선언됩니다. 컴파일러 지시는 use 예약어로 시작하며, 다음 장에 지시에 대한 내용을 더 자세히 서술합니다.

새로운 토큰

만약 이미 존재하는 연산자를 확장하는 게 아니라 (이건 #+# 같은 걸 오버로딩하는 걸로 쉽게 가능합니다) 완전히 새로운 연산자를 만들고자 한다면 새 토큰을 정의할 필요가 있습니다. 토큰은 문장부호들로 이루어지거나 예약어가 될 수 있으며, use token으로 정의합니다:

use token "~="           -- 부호로 이루어진 토큰을 선언함
use token "unless"       -- 예약어를 선언함
use token "[\\ \\]"      -- 서로 짝이 맞는 두 개의 부호로 이루어진 토큰을 선언함
use token "` `"          -- 자기 자신이 자신의 짝인 하나의 부호로 이루어진 토큰을 선언함
use token "if"           -- 오류: 해당 토큰이 이미 존재함

[\\]의 정의에서 볼 수 있듯 토큰은 아무 유니코드 문자나 포함할 수 있는 문자열로 주어집니다. 이를테면 연산자를 정의하는데 use token "\u2264"를 사용할 수 있습니다. 만약 토큰 문자열이 공백으로 구분된 두 개의 토큰을 포함한다면, 두 토큰 모두 각각 선언되며(다만 두 토큰이 동일한 문자열이면 하나만 선언됨) 각각 여는 토큰과 닫는 토큰으로 기록되게 됩니다.

사용 가능한 토큰에는 몇 가지 제약이 있습니다:

TODO token remapping (and its use in the collision-safe syntax extension); removal (use no token ...); local token namespace??? OH NOES

연산자 정의

TODO moving towards general syntax augmentation.

일반화된 리터럴

종종 임의의 리터럴이 필요할 때가 있습니다. 예를 들어 나루는 정규식(regular expression)을 직접적으로 지원하지 않습니다만 /asdf/g와 같은 사용자 정의 문법은 분명 편하지요. 하지만 이런 임의의 리터럴을 정의하려면 사실상 토큰 문법을 서술할 방법이 필요한데 매우 복잡한 일입니다. 따라서 나루는 임의의 리터럴을 지원하지 않는 대신에, 고정되어 있지만 확장 가능한 일반화된 리터럴 문법을 제공합니다.

나루에서 일반화된 리터럴은 둘 중 한 형태입니다:

형태와 접두어는 주어진 스코프 안에서 유일하게 일반화된 리터럴을 표시합니다. 접두어는 올바른 식별자거나 음수가 아닌 정수(다만 04242는 같은 걸로 취급합니다), 또는 빈 문자열이 될 수 있습니다. 접두어가 식별자일 경우 대소문자는 구분됩니다.

TODO generic literal contents, generic literal definition syntax (use literal ... maybe?)

나루는 몇몇 일반화된 리터럴을 예약해 놓고 있습니다. 다음 표에서 naru core 모듈에서 유래했다고 되어 있는 리터럴은 내장되어 있는 것이고, 나머지는 지정된 모듈을 import해야만 쓸 수 있습니다.

문법 모듈 리터럴의 평가 타입 설명
"..." naru core String 기본적인 따옴표로 묶인 문자열.
r"...", R"..." naru core String 보이는 대로의 문자열(raw string). \"를 제외한 모든 탈출열은 해석되지 않습니다. \"도 문자열이 끝나는 걸 막을 뿐 문자열 자체에는 있는 그대로 들어갑니다.
b"...", B"..." naru core Bytes 기본적인 따옴표로 묶인 바이트열. ASCII 문자와 탈출열만을 포함할 수 있습니다.
br"...", BR"..." naru core Bytes 보이는 대로의 바이트열.
x"...", X"...", bx"...", BX"..." naru core Bytes 십육진수로 인코딩된 바이트열. 십육진법 자리가 아닌 모든 글자는 오류이며, 모든 공백은 제거됩니다. 공백을 제거한 뒤에 남은 자릿수는 짝수여야 합니다.
2"...", …, 36"..." naru core Int 확장된 정수 리터럴.
%(...), %q(...), %Q(...) naru core String 따옴표로 묶인 문자열. (대안 문법)
%w(...) naru core PureVector{String} 문자열의 벡터. 각 문자열은 하나 이상의 공백으로 구분됩니다.
%r/.../ naru text pattern Pattern 패턴 값. 꼭 그래야 하는 건 아닙니다만 보통 /로 구분합니다.

TODO

내부 깊숙히

TODO

나루는 컴파일러의 행동을 제어하는 컴파일러 지시를 지원합니다. 컴파일러 지시는 use로 시작하는 문장이며, use 뒤에는 부정적인 의미를 나타내는 no가 따라올 수 있습니다. 실은 이 문서에서 맨 처음으로 나온 문장, use naru 또한 컴파일러 지시의 일종입니다. C/C++의 #pragma와 비슷하게 컴파일러가 알지 못 하는 컴파일러 지시는 무시됩니다. (그러나 알기는 하지만 잘못된 지시는 무시되지 않습니다.)

use naru 지시

모든 나루 프로그램은 비어있지 않고 주석이 아닌 첫 문장으로 use naru 문장을 포함할 수 있습니다. 이 문장은 나루 프로그램을 구분하는 권장되는 방법입니다(이를테면 -- vim: syn=naru 같은 편집기가 별도로 인식하는 줄로만 구분하진 말란 소리입니다). 물론 원한다면 쓰지 않아도 됩니다. use naru 뒤에 나오는 토큰들은 마치 컴파일러가 알지 못 하는 다른 지시처럼 완전히 무시되며, 이는 나중에 확장을 위해 사용될 수도 있습니다.

예제:

use naru                     -- 올바름
use naru revision 4          -- 올바름, 토큰 두 개를 무시함
use naru (revision 4)        -- 올바름, 토큰 네 개를 무시함
use naru ]revision 4[        -- 오류: 대괄호는 여전히 짝이 맞아야 함
use naru for good, not evil  -- 올바름, 토큰 다섯 개를 무시함 (쉼표도 문장의 일부라서)
use naru """what
            the
            hell?"""         -- 올바름, 토큰 한 개를 무시함

use naru는 만약 사용될 경우 맨 첫 문장이어야 하며, 그 뒤에 나오는 경우에는 오류로 처리됩니다.

use encoding 지시

use encoding 지시는 토큰 해석기가 나루 코드(즉, 유니코드 문자열)를 주어진 문자 인코딩을 사용해서 읽기 시작하도록 합니다. 이 지시는 해당 지시 뒤의 문장 종료자가 나온 직후부터 수행됩니다. (즉, 첫 세미콜론이나 개행 문자열 직후이며, 개행 문자열의 경우 문자 두 개까지 포함될 수 있습니다.) 문자 인코딩은 ASCII와 호환되는 인코딩이어야 하며, 따라서 0x20부터 0x7e까지의 바이트는 U+0020부터 U+007E까지의 유니코드 문자로 디코딩되어야 합니다. 예를 들어 UTF-16은 이 조건을 만족하지 않기 때문에 사용할 수 없습니다. 반대되는 지시 use no encoding은 문자 인코딩을 기본값으로 되돌려 놓습니다.

이 지시는 프로그램의 최상위 문장으로만 사용할 수 있으며, 이 지시를 여러 번 쓰는 것은 매우 권장되지 않습니다. 사실 이 지시를 쓰는 것 자체도 그다지 권장되진 않습니다만 (어쩔 수 없는 이유로) 사용할 필요성이 존재하긴 합니다.

use encoding "cp949"            -- 이 주석까지도 기본 인코딩으로 해석됨
use encoding "shift-jis";       -- 이 주석은 Shift_JIS로 해석됨
do
    use encoding "koi8-r"   -- 오류: 최상위 문장이어야만 함
    "blahblah" println()
end
use no encoding
-- 이제 이 주석은 기본 인코딩으로 해석됨

기본 인코딩은 UTF-8입니다(따라서 쓸모 없는 UTF-8 바이트 순서 마커[BOM]는 안전하게 무시됩니다). UTF-8을 지원하지 않는 쓰레기같은 편집기를 쓰고 있더라도 use encoding 지시를 앞에 붙여서 인코딩 문제를 해결할 수 있습니다. 이 지시의 또 다른 용도로는 국제적인 개발을 위해 ASCII 바깥의 문자를 허용하지 않는 것인데, 이 경우 use encoding "ascii"를 쓰면 됩니다.

use token, use syntaxuse literal 지시

TODO

use staticuse dynamic 지시

use staticuse dynamic 지시는 타입 추론이 어떻게 이루어져야 하는지를 제어합니다. 짧게 말하면, 명시적인 타입이 대입이나 선언에서 빠져 있으면 use dynamic은 이 타입을 동적 타입(*)으로 가정하라고 지시하고, use static은 빈 칸(_)으로 가정하라고 지시합니다. 그러나 use dynamic이 켜져 있어도 정적 타입을 쓰는 게 금지되지는 않고, 마찬가지로 use static이 켜져 있어도 동적 타입을 쓰는 게 금지되지 않습니다. 이 지시와 상관 없이 인자 타입이 빠져 있을 경우 항상 동적 타입으로 가정되며, 반환 타입이 빠져 있을 경우 빈 칸으로 가정되어 컴파일러가 추론합니다.

이 지시들은 현재 블록에만 영향을 주며, 해당 블록의 맨 첫 문장으로 나타나야 합니다. 기본값은 use dynamic입니다만, 지시들이 중첩되어 있으면 현재 문장에서 가장 가까운 위치의 지시가 적용되므로 use dynamic은 여전히 사용할 이유가 존재합니다(use static으로 선언된 블록 안에서).

TODO putting as * in every subexpression may enable more dynamic behavior, but not sure that it IS really usable; if used, the directive name should be use more dynamic.

use runtime 지시

TODO

use extern 지시

TODO runs in the function scope:

#+#(lhs:SmallInt, rhs:SmallInt)->Int := fun
    lhsv: Int64 = lhs _value >> 1
    rhsv: Int64 = rhs _value >> 1
    result: Int64 = _
    use extern 'narurt.dll,__nr_SIplusSI'(lhsv, rhsv) as result
    return SmallInt(result)
end

부록: 이것 저것

이 부록에 있는 메모들은 언젠가는 적절한 곳으로 옮겨갈 것입니다.

할 일 목록

문서 전체에 영향을 주는 이슈만 나열했음. 지엽적인 이슈는 TODO라고 쓴 부분을 확인할 것.

빠진 문서화. 어딘가에 다음 내용을 써 놓아야 하는데…

import 대안 문법. 일관성을 생각하면 사실 다음 문법이 더 낫긴 한데:

-- "package module"이 "symbol1"과 "symbol2" 심볼을 가지고 있다 치면...
mod := import package module         -- mod symbol1, mod symbol2를 정의함

근데 아무리 봐도 자연스럽게 확장이 안 된다:

* := import package module           -- symbol1, symbol2를 정의함 (?)
_ := import package module           -- module symbol1, module symbol2를 정의함 (?)
sym1, sym2 := import package module (symbol1, symbol2)
                                     -- sym1, sym2를 정의함 (???)

특히 맨 마지막 예제는 원래의 import package module (symbol1 as sym1, symbol2 as sym2)에 비하면 훨씬 알아보기 힘든데, 의미가 있는진 잘 모르겠음.

문법 확장을 들여 오기 위한 문법. 두 모듈에서 같은 문법을 정의할 경우 발생할 충돌을 피하려면 문법을 선택적으로 들여 오는 방법이 필요함. 예를 들어:

import somethingawful "%r"                  -- 원래의 %r/.../
import naru text pattern "%r" as "%re"      -- %r/.../를 %re/.../로 이름을 바꿈

아니면 문법 정의에 대한 별도의 문법이 필요한가? 연산자의 경우 비슷한 게 이미 있는데(#+# 따위) 문법 확장은 적절한 게 없는 것 같음.

인자가 있는 모듈. 언젠가는…

좀 더 선택적인 모듈 들여오기. 예를 들어 import naru core >= 3.1import naru core * but String. 문법은 추후 확정.

명시적인 메모리 레이아웃. naru ext Struct은 메모리 레이아웃을 고정해야 하는 모든 클래스의 기반 클래스여야 함. 예:

Struct_tm(tm_sec, tm_min, tm_hour, tm_mday, tm_mon, tm_year,
          tm_wday, tm_yday, tm_isdst) := class <- Struct
    var tm_sec: Int32
    var tm_min: Int32
    var tm_hour: Int32
    var tm_mday: Int32
    var tm_mon: Int32
    var tm_year: Int32
    var tm_wday: Int32
    var tm_yday: Int32
    var tm_isdst: Int32
end

고로 반영된(reflected) 클래스는 멤버 순서를 유지해야 함. 구조체 패딩은 수동으로 정의함(예를 들어 _: Int16 식으로). 그리고 모든 메모리에 없는 값들은 속성 문법을 통해 정의되어야 함. (뭐 읽기 전용 속성 정의하는 게 어렵지 않으니까 이건 큰 문제 없을 듯.)


ikiwiki를 씁니다.
마지막 수정