« 문서 목록
이 문서는 강 성훈이 설계한 일반 목적 프로그래밍 언어인 나루 프로그래밍 언어의 소개글이자 설계 문서입니다. 이 문서는 나루 프로그래밍 언어의 개발 버전인 제 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
elseif나 else 부분은 꼭 필요한 건 아니고 생략할 수 있습니다. 마찬가지로 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
모든 클래스에는 다음과 같은 요소들이 들어 있습니다:
이 인자들은 class 키워드 뒤나, 일관된 선언 문법을 쓸 경우 클래스 이름 뒤에 옵니다. 위 코드에서는 Person은 두 개의 생성자 인자, name과 age를 가집니다. 클래스 이름을 함수처럼 써서 호출하면 주어진 인자로부터 해당 클래스의 값이 만들어져 반환됩니다. 생성자 인자는 클래스 안쪽에서도 사용 가능하고, 바깥에서도 보통의 속성 문법(value attr)을 써서 접근할 수 있습니다.
기반 클래스의 목록은 class 키워드 및 (만약 있다면) 생성자 인자 뒤에 따라 옵니다. 기반 클래스는 구체적(concrete; class로 선언된 클래스)이거나 추상적(abstract; trait으로 선언된 클래스)일 수 있으며, 기반 클래스가 구체적이라면 클래스 이름 뒤에 대응되는 생성자 인자가 들어 와야 합니다.
변경 가능한 속성(attribute)은 var 키워드로 선언됩니다. (클래스 바깥에서도 이 문법을 쓸 수 있긴 한데, 좀 의미가 다릅니다.) 생성자 인자는 기본적으로 변경이 불가능하지만, 변경 가능한 속성들은 속성 대입 문법(value attr = newvalue)을 써서 변경할 수 있습니다. 만약 변경 가능한 속성과 생성자 이름이 똑같다면, 해당 이름은 변경 가능하며 두 용도 모두로 쓸 수 있습니다.
이들은 변경 가능한 속성과 비슷하지만 var이 앞에 붙지 않습니다. 한 번 정의되면 이들 속성은 변경할 수 없습니다. TODO is there a difference between x = ... and x := ... here?
초기값은 생성자 인자와 이름이 같은 변경 가능한 속성에 대해서는 꼭 쓸 필요는 없으나, 나머지 경우에는 항상 써야 합니다. 이들은 처음으로 생성자가 호출될 때 단 한 번 평가되는데, 이 때는 아직 self가 존재하지 않기 때문에 self age와 같은 표현은 불가능하며 age라고만 쓸 수 있습니다. (몇 가지 분명히 해 두자면, 그냥 age를 메소드 안에서 쓰는 것도 가능합니다만 이 경우 그 의미는 self age가 가리키듯 self 안의 “현재” 속성값이 아니라, self가 처음 만들어질 적에 생성자 인자로 들어 온 값이 됩니다. 그냥 age를 함수 인자와 같은 의미로 생각하는 게 이해에 도움이 될 것입니다.)
초기값을 정하지 않을 수도 있는데, 이 경우 빈 자리를 나타내는 _를 초기값 대신에 씁니다. 이 경우 이 클래스의 자식 클래스는 어떻게든 해당 초기값을 정해야 하며, 모든 필요한 초기값을 (_가 아닌 값으로) 정하지 않은 구체적 클래스는 만들 수 없습니다. 또한 _는 부속성(subattribute)으로 정하는 것도 가능합니다.
메소드는 보통의 일관된 선언 문법을 사용하는 함수 선언과 유사하지만, self 인자가 자동으로 메소드 인자에서 사용 가능하다는 것이 다릅니다. (self는 실제로는 키워드이므로 인자 이름으로 쓰고 싶어도 쓸 수는 없습니다.) 그냥 선언 문법을 써서 메소드를 정의하는 것도 안 되는 건 아닙니다만, 이 경우 self는 사용할 수 없게 되며 오로지 생성자 인자만 쓸 수 있게 됩니다.
속성과 마찬가지로 _를 서서 함수 몸체를 생략할 수 있으며, 이 경우 자식 클래스나 부속성을 사용해서 나중에 함수 몸체를 정해야 한다는 의미가 됩니다.
부속성(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 클래스가 있다면 아무래도 Square는 Rectangle의 자식 클래스이겠습니다만, Circle과 Rectangle 또한 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 f와 C f가 A f의 타입보다 더 좁게 선언되었다면, D f의 타입 또한 적어도 B f와 C 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} 타입의 값을 넣어도 된다는 말입니다. Int는 Rational이 들어 갈 수 있는 모든 곳에 들어 갈 수 있으므로 LinkedList{Int}와 LinkedList{Rational}이 동등하게 작동해야 한다는 건 일리가 있습니다. 마찬가지로 -를 쓰면, 순서가 바뀌어 LinkedList{Int} 타입을 쓸 자리에 LInkedList{Rational}을 쓸 수 있다는 의미가 됩니다. +나 - 같은 게 없다면 둘은 서로 호환되지 않습니다. 프로그래밍 언어 이론의 용어로 말하자면, +는 타입 인자가 공변적(covariant)이고 -는 반변적(contravariant)임을 나타내며, 아무 것도 없을 때는 불변적(invariant)임을 나타냅니다.
대부분의 경우 +나 -를 직접 쓰려고 머리를 썩일 필요는 없습니다(웬만해서는 안 쓰는 게 맞습니다)만, 아주 일반적인, 이를테면 콜렉션 클래스 같은 걸 만들 때는 이 부분에 있어 주의를 기울일 필요가 있습니다. Rational의 목록이 쓰일 수 있는 곳에서는 웬만하면 Int의 목록이 쓰일 수 있으면 참 좋겠죠, 안 그래요? 하지만 여기에는 몇 가지 문제가 있는데, 변경 가능한 콜렉션의 경우 공변적일 수 없습니다. (간단하게 설명하면, 콜렉션의 원소를 읽을 때는 공변적인 타입 인자가 필요하고 변경할 때는 반변적인 타입 인자가 필요합니다. 둘 다 만족하려면 불변적이어야 하죠.)
타입 인자를 가진 클래스를 사용하는 건 정의하는 것보다는 훨씬 쉽습니다. 위 코드의 맨 마지막 줄은 사실 다음과 같습니다:
LinkedList{Int}(3, LinkedList{Int}(4, LinkedList{Int}(5))) print()
하지만 우리는 이미 head가 Int를 받으므로 모든 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의 경우에도 비슷한 대응이 성립합니다. 따라서 타입 유추는 타입 인자가 공변적·반변적·불변적이냐에 따라 영향을 받습니다.
클래스 선언의 타입 인자는 생성자 인자와 동일하게 취급되어, T나 self 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
좀 더 일반적으로, do 및 fun 블록에는 예외를 처리하는 but, else 및 finally 블록이 들어갈 수 있습니다. 이들은 주어진 타입(또는 타입이 주어지지 않은 경우, 모든 타입)의 예외가 발생했을 때나, 아무 예외도 발생하지 않았거나, 예외 발생 여부와는 관계 없이 코드 실행이 블록을 빠져나가기 직전에 각각 실행됩니다:
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 값의 경우 exit는 close와 같은 의미입니다.) 또한 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
#**# 외에도 몇 가지 새로운 문법이 눈에 띕니다:
global로 설정할 수 있습니다. 많은 다른 언어와는 달리, 여기서 global은 해당 변수(네, 문법은 같지만 실제로는 속성이나 메소드가 아닙니다)가 현재 블록의 바깥에 있는 블록에 선언되어야 함을 나타냅니다. 여기서 말하는 “현재 블록”은 class로 선언된 블록도 포함됩니다(사족이지만 이 블록은 변경 불가능합니다). 뭐 당연한 얘기지만 함수 안에서 global을 써서 바깥 블록의 변수를 변경하거나 할 수도 있습니다. (덤: 그런데 지금 코드에서 global을 쓰는 대신에 #**#(lhs:Gaussian, rhs:Int)->Gaussian 함수를 그냥 클래스 바깥에 만들어도 되지 않느냐는 질문을 할 수 있겠습니다. 그래도 되긴 합니다만, 이 함수를 클래스 안에 넣는 이유는 이 함수가 Gaussian 클래스의 특성 중 일부기 때문입니다. 만약 이 메소드의 몸체가 _로 비어 있다면 자식 클래스는 이 global로 선언된 메소드 또한 제대로 선언해 줘야 합니다.)#**#은 Int, Rational, Real 등의 다른 클래스도 사용하고 있기 때문에 정확히 언제 이 함수가 쓰이는지를 표시해 줄 필요가 있습니다.self를 넣을 수 있습니다. 이는 해당 함수는 보통 함수랑 똑같은 방법으로 불리지만, 함수 안에서는 self로 지정했던 위치에 들어 갔던 인자가 self로 쓰인다는 의미를 가집니다.따라서 다음 수식은…
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을 쓰는 게 더 좋습니다.
Number와 Enumerable은 그 자체로 큰 계층을 이루기 때문에 여기서는 다루지 않고 나중에 다룹니다.
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은 순서쌍이 아닌 값에서도 쓸 수 있으며, 이 경우 v와 v #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
나루에는 두 종류의 진리값, 즉 true와 false가 있습니다(진리값은 흔히 “부울 값”[Boolean]이라고도 부릅니다). “아마도”나 “알 수 없음”을 나타내는 세 번째 진리값이 필요하다면 Interval{Bool}이나 Option{Bool} 타입으로 흉내낼 수 있습니다. 나루에서 조건을 평가하기 위해 진리값이 필요할 경우, 주어진 값은 자동으로 Bool from 변환 메소드를 통해 진리값으로 변환됩니다. (물론 그렇다고 해서 함수 인자에 진리값 타입이 있다고 무조건 들어 온 인자가 진리값으로 변환된다는 건 아닙니다. 조건을 평가할 때만 변환됩니다.)
나루에는 두 종류의 진리값 연산이 있는데, 하나는 성급하고(short-circuiting) 하나는 아니니다. 전자는 and와 or로 쓰며 후자는 &&와 ||로 쓰고, 당연하지만 두 경우 모두 진리값을 뒤집는 연산자는 그냥 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로 변환되기도 합니다:
() (즉, ())0, 0.0, -0.0, 0j 등)empty 속성이 참인 모든 콜렉션 ("", [], {}, {/} 등)Ordered 타입나루에는 네 종류의 비교 연산이 있는데, 각각 값이 같은지 비교하는 연산자(==와 !=), 같은 참조인지 비교하는 연산자(is와 is not), 순서 비교 연산자(<, <=, >, >=) 및 세 방향 비교 연산자(<=>)로 나뉩니다.
값이 같은지 비교하기: 이 연산자들은 보통 생각하는 대로 두 숫자가 같은지 비교하는데, 따라서 3 == 3이고 3 != 4입니다. 이 연산자는 숫자 뿐만 아니라 모든 값에서 쓸 수 있으며, 숫자가 아닌 값에 대해서는 참조 비교와 똑같이 동작합니다. 따라서 a == a는 몇몇 예외를 제외하면 대부분의 a에 대해서 성립합니다. (대표적인 예외로는 부동소숫점 실수값의 “숫자가 아닌 값”[Not-a-Number, NaN]이 있는데, NaN은 모든 NaN과 다르게 비교됩니다.)
이들 연산자는 대부분의 경우 상식과 일치하는 결과를 내지만, 같은 값 a와 b가 같지 않다고 나오는 경우도 있을 수는 있습니다. 이런 상황은 값이 변경 가능하거나(열린 파일 같이), 정확한 비교가 매우 어려워서 대략적인 비교만이 구현되어 있거나(수학 수식 같은 경우) 할 때 발생할 수 있습니다.
같은 참조인지 비교하기: 이 연산자들은 주어진 값을 제자리에서 바꿀 때 다른 값이 직접적으로 함께 바뀔 때만 참을 반환합니다. 예를 들어 [1, 2, 3] == [1, 2, 3]은 두 벡터가 같은 값을 가지고 있으므로 참이지만, [1, 2, 3] is [1, 2, 3]은 전자를 바꾼다고 후자가 함께 바뀌지는 않으므로 거짓입니다. 숫자의 경우 딱 한 가지 경우(같은 비트 패턴을 가진 NaN은 같은 참조로 비교됩니다)를 빼면 값이 같은지 비교하는 것과 같은 참조인지 비교하는 것이 동일합니다.
순서 비교: 이 쪽은 좀 어렵습니다. 일단 Ordered 클래스의 모든 값에 대해서 순서 비교가 가능하며, 일반적으로는 a <= b가 (a < b) || (a == b)와 동일하고(반대 방향도 마찬가지) a < b와 b > 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) < c나 a <= (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)의 Name 및 Name_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)을 붙여서 만들 수도 있습니다. (탈출열은 문자열 안에도 들어 있을 수 있으나 기술적으로는 문자 리터럴과는 무관합니다.) 탈출열은 다음 중 하나여야 합니다:
\ 뒤에 1자리에서 7자리까지의 십진수, 이를테면 \44032 (U+AC00). 1114111(즉, 0x10FFFF)보다 큰 수는 쓸 수 없습니다.\x 뒤에 정확히 두 자리 16진수, 이를테면 \xA0 (U+00A0).\u 뒤에 정확히 네 자리 16진수, 이를테면 \uac00 (U+AC00).\U 뒤에 정확히 여덟(!) 자리 16진수, 이를테면 \U00012345 (U+12345). 당연히 1114111를 넘는 코드 포인트는 쓸 수 없습니다.\x, \u 또는 \U 뒤에 {}로 둘러 싸인 한 자리 이상의 16진수, 이를테면 \U{a0} (U+00A0), \u{306} (U+0306) 및 \x{12345} (U+12345) 같은 것들. 탈출열이 어디서 끝나는지 헷갈릴 때 대신 쓸 수 있습니다.\a (U+0007), \b (U+0008), \e (U+001B), \f (U+000C), \n (U+000A), \s (U+0020), \r (U+000D) 및 \t (U+0009).\\, \" 및 \'은 각각 \, " 및 '에 대응됩니다(당연한 이유로).이 탈출열들은 문자열 리터럴 안에서도 쓸 수 있기 때문에 마음만 먹는다면 다음과 같이 쓸 수도 있긴 합니다.
?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)를 나타냅니다. 나머지는 해당 숫자 도메인을 구현하는 구체적 클래스로, 이를테면 BigInt와 SmallInt는 정수를 구현하는 구체적 클래스며 정수 리터럴의 “실제” 클래스는 크기에 따라 둘 중 하나가 선택됩니다. UIntNN, IntNN, FloatNN, CFloatNN은 현재 기계에서 기본적으로 지원하는 기계 친화적 숫자형을 나타내며 해당 숫자형의 비트 크기가 이름 뒤에 따라 붙습니다(UInt64, Float80 등). 복소수의 경우 이 비트 크기는 실수부와 허수부를 모두 포함합니다(즉 CFloat128은 Float64 두 개로 이루어집니다).
도메인 외에도 모든 숫자는 정확(exact)하거나 부정확(inexact)할 수 있습니다(스킴과 비슷합니다). 어떤 숫자가 정확한지 아닌지는 exact 속성으로 알 수 있습니다. 둘 사이의 구분은 실수형에서 가장 극명하게 드러나는데, 이를테면 Decimal은 해당 값이 정확한지 아닌지를 계속 기억하고 있습니다. (정수는 정의에 따라 항상 정확하며, FloatNN과 CFloatNN은 부동 소숫점 표현의 특성상 항상 부정확합니다.)
아마 다들 잘 알고 있으리라 생각하지만, 여기서는 숫자 리터럴이 정확히 어떻게 생겼는지 살펴 보겠습니다.
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 등)를 담는 모든 숫자는 소수입니다. 소숫점은 두 십진법 자리 사이에 나타나야 하므로, .345나 345.는 올바른 소수 리터럴이 아닙니다(하지만 345e0은 올바른 소수 리터럴입니다). 지수부가 나타날 경우 지수부 앞의 숫자와 10을 지수부 뒤의 숫자만큼 거듭제곱한 숫자를 곱해서 소수 값을 만듭니다.
소수 리터럴 안에서 ?는 10진법 자리 0과 동일하게 인식되지만, 해당 소수가 부정확하며 주어진 자리는 유효숫자에 포함되지 않음을 나타냅니다. (따라서 ?가 한 번 나오면 그 뒤에는 유효숫자가 나올 수 없습니다.) 이를테면 314????는 3.14????e+4 또는 3.14?e4와 동일합니다만, 3.14e4는 정확한 소수이기 때문에 서로 다릅니다. 모든 소수 리터럴은 (비록 그게 0이라 하더라도) 적어도 하나 이상의 유효숫자를 포함해야 하며 Char과 혼동될 수 없습니다.
3.14f, 314e-2f 등등. 소수 리터럴 뒤에 f나 F를 붙여서 만들며, 현재 문맥에서 사용할 수 있는 가장 큰 Float* 클래스의 값을 나타냅니다. 예를 들어 a: Float32 | Float64 = 3.14f의 경우 Float64가 사용되지만(사용 가능한 타입 두 개 중 Float64가 더 크므로) 그냥 a = 3.14f라 하면 IA–32와 x86–64 아키텍처에서는 Float80을 쓰게 됩니다. 이런 규칙은 (역시 예를 들자면) Float32 값을 십진 표현에서 바로 얻는 것과 Float64로 먼저 얻은 뒤에 Float32로 변환하는 것이 같지 않기 때문에 생겼습니다. (덤: 후자는 반올림을 가장 가까운 짝수로 하거나 양의 무한대 쪽으로 할 경우 정확도가 손실될 수 있습니다.)
나루에서 정확한 부동 소숫점 실수를 쓰려면 십육진법을 나타내는 0x를 앞에 붙일 수 있습니다. 이 경우 십진 지수 대신에 이진 지수(p123 = 2123 등등으로 해석됨)가 대신 쓰입니다. 따라서 3.14f는 Float64에서는 0x1.91e851eb851fp+1f와 동일한 값이 될 것입니다.
부동 소숫점 실수 리터럴에서도 유효숫자가 아닌 0을 나타내는 ? 자리를 쓸 수 있긴 하지만, 부동 소숫점 실수는 항상 부정확하므로 ?는 0과 동일한 역할만 합니다. 이 이유 하나만으로 부동 소숫점 실수를 피할 이유는 충분합니다.
3.14j, 3.14fj 등등. 소수 리터럴이나 부동 소숫점 실수 리터럴 뒤에 j 또는 J를 붙여서 만듭니다. (이 표기법은 전자 공학에서 전류를 나타내는 i와 허수 단위를 구분하기 위해서 널리 쓰입니다.) 이 리터럴은 j/J 앞에 있는 리터럴의 종류에 따라 CDecimal 클래스나 CFloatNN 클래스의 값이 됩니다.
숫자 리터럴의 각 자리 안에는 가독성을 위해 _를 넣을 수 있습니다. 좀 더 정확히는 두 개의 자리 사이, 한 개의 자리와 지수 문자(e나 p) 또는 한 개의 자리와 소숫점(.) 사이에 최대 한 개까지 밑줄을 넣을 수 있습니다. 따라서 0x1_2_3이나 12_3._4_5?_??_e_15는 올바른 반면, _34(이건 그냥 식별자입니다), 34_, 0_x123이나 13e_+45는 틀립니다. 일반화된 리터럴에서는 따옴표만 제대로 썼다면 이런 제약이 없습니다.
Ratio와 실수부가 있는 Complex 값에 대한 별도의 리터럴은 없습니다만, 나눗셈과 덧셈 연산자를 써서 해당하는 값을 반환하는 상수 수식을 정의할 수는 있습니다. 이를테면 3/4와 3+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) >> 32가 2를 반환할 수도 있습니다(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 계열 언어와 유사합니다:
~a는 비트 NOT을 계산합니다.a & b는 비트 AND를 계산합니다.a | b는 비트 OR을 계산합니다.a ^ b는 비트 XOR을 계산합니다.a << b는 a를 b비트만큼 왼쪽으로 시프트합니다.a >> b는 a를 b비트만큼 오른쪽으로 시프트합니다. (왼쪽에는 부호 비트가 찹니다만[sign-extending], 예외적으로 부호 없는 기계 친화적 정수일 경우에만 0을 대신 채웁니다[zero-extending].)당연하지만 비트 연산자는 Int 및 그 서브클래스에만 적용할 수 있습니다. Int의 경우 항상 2의 보수 표현을 가정하기 때문에 ~x == -x-1 같은 등식이 항상 성립합니다만, 기계 친화적인 정수형의 경우 어떤 보장도 하지 않습니다.
특수 비트 연산: 위의 비트 연산자만큼 자주 필요한 건 아닙니다만, 정수형에는 좀 더 특화된 비트 연산이 속성 및 메소드로 들어 있습니다:
a bitlength는 a를 표현하는 데 필요한 비트 수를 반환합니다. (예: 127 bitlength == 7, 128 bitlength == 8, 0 bitlength == 0 등) 이 속성은 a가 음수가 아닐 때만 유효하며, a의 밑이 2인 로그와 깊게 연관되어 있습니다.a popcount는 a의 이진 표현에서 1인 비트 수를 반환합니다. (예: 42 popcount == 3, 128 popcount == 1, UInt64 from(-1) popcount == 64 등) 이 속성은 a가 음수가 아닐 때만 유효합니다.일부 연산은 임의 크기 정수에서 잘 정의되지 않아서 기계 친화적인 정수형에만 존재합니다:
a bitreversed는 a의 비트 순서를 뒤집은 값을 반환합니다. (예: UInt16 from(0x1234) bitreversed == UInt16 from(0x2c48)) 이 속성은 고속 푸리에 변환(FFT) 등을 구현하는데 매우 편리합니다.a leftrotate(b)와 a rightrotate(b)는 a를 b비트만큼 왼쪽 또는 오른쪽으로 시프트하고, 넘치는 비트를 반대쪽에 채웁니다(bit rotation).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.
산술: 메소드로 들어 있거나 간단하게 구현 가능한 연산 중 몇 개는 모듈에도 같이 들어 있는 경우가 있습니다. 대부분의 경우 편의를 위해서라거나 성능 등 아주 크지는 않은 이유 때문에 두 개가 함께 존재합니다.
sign(x)는 x class from(x sign)와 거의 같습니다. 예를 들면 sign(-8)은 -1, sign(3/2)은 1/1, sign(0.0f)는 0.0f 등등이 성립합니다.pow(x, y)는 x ** y와 같습니다.pow(x, y, z)는 x ** y % z와 같으며, 성능을 따진다면 (y가 큰 경우) (x ** y) with modulo: z에 좀 더 가깝습니다.sqrt(x)는 x ** (1/2)와 같으나 좀 더 빠릅니다.isqrt(x)는 Int from(x ** (1/2))와 같으나 그냥 제곱근을 구하고 소숫점 아래를 버리는 것보다 빠릅니다. (이 연산을 실수 자료형에 쓸 수도 있으나 정확도 손실을 감수해야 할 수 있습니다.)hypot(x, y)는 sqrt(x**2 + y**2)와 같습니다.삼각 함수와 쌍곡 함수: 24가지 가능한 함수를 모두 지원합니다:
sin(x), cos(x), tan(x), csc(x), sec(x), cot(x).asin(x), acos(x), atan(x), acsc(x), asec(x), acot(x). 이들 함수의 공역은 모든 경우에 대해서 반환값이 하나만 존재하고 공역 안에 (끝점에서라도) 0이 포함되도록 정해집니다.sinh(x), cosh(x), tanh(x), csch(x), sech(x), coth(x).asinh(x), acosh(x), atanh(x), acsch(x), asech(x), acoth(x).삼각 함수의 경우 두번째 인자는 x가 어떻게 해석되는지를 결정하며, :radians (기본값) 및 :degrees를 쓸 수 있습니다. 삼각 함수의 역함수의 경우 함수의 반환값을 어떻게 해석해야 하는지를 결정합니다.
나루에는 널리 쓰이는 atan2(y, x) 함수도 있는데, 이 함수는 atan(y / x)와 같으나 결과가 -π보다 크고 (다만 y == -0.0일 경우 -π도 포함될 수 있음) +π보다 작거나 같도록 x와 y의 부호를 적절히 처리한 것입니다.
지수 및 로그 함수: 이 함수들은 수치 계산에 워낙 많이 쓰이는 터라 종류가 좀 많습니다:
exp(x)와 log(x)는 밑수가 e인 지수 및 로그 함수를 구현합니다.log(x, b)는 밑수가 b인 로그 함수를 구현하며, log(x) / log(b)와 동일합니다.log2(x)와 log10(x)는 각각 log(x, 2)와 log(x, 10)와 같지만, 더 빠릅니다.expm1(x)와 log1p(x)는 exp(x) - 1와 log(x + 1)와 같지만, x가 0에 가까울 때 특히 정밀도가 더 높습니다. 이는 exp와 log를 구현할 때 흔히 쓰는 알고리즘의 특성 때문에 나타나는 특징입니다.초등적(elementary)이지 않은 함수: TODO 설명
erf(x)는 오차 함수(error function)를 구현합니다. 이 함수는 표준 정규 분포의 누적 분포 함수(cumulative distribution function, c.d.f.) Φ를 다음과 같이 구현하는 데 씁니다: Φ(x) = (1 - erf(-x / sqrt(2))) / 2.erfc(x)는 1 - erf(x)와 같습니다.gamma(x)는 감마 함수 Γ(x)를 구현합니다. 이 함수는 계승(factorial)을 실수 범위로 확장한 것입니다(아래의 factorial을 참고하세요).loggamma(x)는 log(gamma(x))와 같으나 큰 x에 대해서도 값이 넘치지 않습니다. 사실 여기서 말하는 “큰 x”는 생각보다 작은 것이, Float64의 경우 gamma(171)만 해도 값이 넘칩니다.기타 수학 함수: TODO 설명
factorial(x)은 1부터 정수 x까지를 곱한 결과(계승[factorial])입니다. Int from(gamma(x + 1))와 동일하지만 큰 x에 대해서도 값이 넘치지 않습니다.gcd(args...)는 주어진 정수 또는 유리수들의 최대공약수를 반환합니다. 결과는 항상 음이 아닌 정수이며(따라서 gcd(-8,6) == 2), 모든 인자가 0이 아닌 이상 0은 인자에서 무시됩니다(따라서 gcd(-8, 6, 0) == gcd(2, 0) == 2이지만 gcd(0, 0) == 0입니다).lcm(args...)는 주어진 정수 또는 유리수들의 최소공배수를 반환합니다.gcdex(args...)는 확장 유클리드 알고리즘을 구현하며, args[0] * c[0] + ... + args[n-1] * c[n-1] == g를 만족하는 최대공약수 g와 정수 벡터 c로 이루어진 순서쌍을 반환합니다. c는 유일하지 않으며 원하는 결과를 얻으려면 추가 작업이 필요할 수 있습니다.특수한 부동 소숫점 함수: TODO 설명
minnan(args...)와 maxnum(args...)는 주어진 숫자들의 최소·최대값을 구합니다. 다만 NaN이 하나라도 있다면 NaN을 반환합니다.minnum(args...)와 maxnum(args...)는 주어진 숫자들의 최소·최대값을 구합니다. 모든 인자가 NaN이 아니라면 NaN은 무시됩니다.minnummag(args...)와 maxnummag(args...)는 주어진 숫자들의 절대값의 최소·최대값을 구합니다. 모든 인자가 NaN이 아니라면 NaN은 무시됩니다.복소수를 정의역으로 갖는 함수: 예상한 대로 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에 있는 함수들과의 차이는 다음과 같습니다:
sign(z)은 절대값이 1이고 방향이 z와 동일한 복소수를 반환합니다(다만 sign(0j) == 0j). 즉, sign은 해당 복소수의 “방향”을 반환하며, z = 0j일 때를 빼면 z / abs(z)와 동일합니다.log(z)는 log(abs(z)) + phase(z) * 1j로 정의됩니다.asin(z)는 log(z*1j + sqrt(1 - z**2)) * -1j로 정의됩니다.acos(z)는 pi/2 - asin(z)로 정의됩니다.atan(z)는 (log(1 + z*1j) - log(1 - z*1j)) / 2j로 정의됩니다.다음은 나루의 콜렉션 계층을 완전히 쓴 것입니다:
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() 메소드를 써서 콜렉션을 변경 가능/불가능한 버전으로 바꿀 수 있습니다. 표준 콜렉션의 경우 이 메소드는 효율적으로 구현되어 있으며, 특히 변환이 일시적인 경우 (즉 변경 가능한 쪽이 실제로 변경은 안 되는 경우) 더더욱 효율적입니다. 타입의 경우 Mutable 및 Immutable 클래스 속성을 써서 해당하는 버전의 타입을 얻을 수 있습니다.
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
(변경 불가능한 버전: Symbol) |
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
앞에서도 언급했지만, 나루에는 여덟 개의 콜렉션 문법이 있습니다:
[...]와 :[...]는 각각 Vector와 PureVector 값을,{...}와 :{...}는 : 및 => 토큰이 중괄호 안에 없을 때 각각 Set와 PureSet 값을,{...}와 :{...}는 : 및 => 토큰이 중괄호 안에 있을 때 각각 Map와 PureMap 값을,"..."와 :"..."는 각각 String와 Symbol 값을 반환합니다.마지막 문법은 다른 문법들과 비슷하게 생기긴 했지만 실제로는 하나의 리터럴로 해석되며, 이들에 대한 자세한 설명은 나중에 설명합니다. 참고로 "..." 문법은 사용자가 정한 접두어를 붙여서 확장할 수도 있습니다.
문자열과 심볼을 제외한 나머지 문법들에는 두 가지 사용법이 있는데, 하나는 나열이고 다른 하나는 조건 제시법입니다.
나열 문법. 이를테면 [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까지의 변수들은 조건 제시 문법 안에서만 쓸 수 있으며, xk는 for 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 루프를 쓰세요.
(덤: none은 None{Bottom} 클래스의 값으로 정의되며, 여기서 Bottom은 아무 값도 속하지 않는 타입이며 다른 모든 타입들의 서브타입입니다. Option은 공변적이므로—변경할 수 없으니까요!—, none은 T가 무엇이든간에 None{T}의 값이 됩니다. 그럴듯하죠.)
TODO also syntax
TODO also syntax
TODO
어, 사실 제목이 좀 모호한데요. 우리가 지금까지 “타입”과 “클래스”라는 말을 아무 의심 없이 써 왔지만 이제는 고백할 때가 되었습니다: 그대로 쓰기에 두 말은 굉장히 문제가 있습니다. 나루에는 이 말들에 해당할 만한 세 가지 대상이 있습니다:
class 속성이 반환하는 무언가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 and so on. These types normally reflect classes available in the current scope, but you can also use the types from other modules:
import somemodule
some: somemodule SomeClass = somemodule foo()
import anymodule * -- if this imports AnyClass...
any: AnyClass = bar() -- then this is also valid.
Every top-level types in the type specification should be declared by the binding (:=). TODO is this restriction necessary? can’t we have the runtime type checker?
In the other words, types with static parameters. These take the same form with the definition: Vector{Int}, for example.
Other types can be prefixed by the names of static parameters to denote the parametric types: for example, {T,U} ((T)->U,T)->U. These static parameters are local to the following type specification: so ({T} (T)->Int, {T} (T)->String)->() is a correct type and can take values of (Char)->Int and (String)->String as arguments.
For parametric classes like Vector you can shorten {T} Vector{T} (i.e. all values of that type regardless of static parameters) to simply Vector.
You can access class attributes that are defined by binding (:=) from the type specification: the prime example would be Mutable and Immutable type attributes in Collection class, like Vector{Int} Immutable (which will result in PureVector{Int}; see the collection list below).
(), (Int, Real), (Int, Real, String) and so on.
One or more types within a tuple type can be prefixed by \, which requires corresponding types are a tuple type with fixed size. (For example, if T is (Int,Real) then (\T,String,\T) is equivalent to (Int,Real,String,Int,Real).) Tuple types can also have an ellipsis (...) at the end of the list, meaning the type accepts given number of values and zero or more other types. Therefore it is in fact a syntactic sugar to \_.
TODO exact encoding is not decided
()->(), (Int,Int)->Int, (fmt:String,args:Value...)->() and so on. The generic syntax is explained already; there can be several kinds of argument specifications inside it:
String).fmt:String).... (e.g. args:Value...), where its actual type is a Vector of specified type (e.g. Vector{Value} in this example). This argument should be at the end of argument list if present, except for keyword-only arguments.? (e.g. String?), where its actual type is an Option of specified type (e.g. Option{String} in this example).As stated earlier the name of arguments if given does not affect the type system. As an exception, you cannot ignore the name of keyword-only arguments: you have to keep them as is if they are non-optional.
Int|Real, {T} (Reader{T} & Writer{T}) and so on. These represent the disjunction and conjunction of given types respectively, and syntactically bound later than static parameters and attributes.
Denoted as *, it enables the full dynamic type checking. It can appear in any position in the type specification, e.g. (*,*,*)->* (which would be the default type of fun (x,y,z) -> x). (For paranoids: It may be possible that fun (x,y,z) -> x would have a type of {T} (T,*,*)->T, but we don’t do so in order to simplify the implementation.)
Dynamic type is a supertype of all static types, and thus the conversion from static type to dynamic type is automatic. The reversal needs the type casting such as x as SomeType. We will discuss about how to mix static and dynamic types later.
Denoted as _, it is not a really type but specifies that the compiler has to fill it with the appropriate type. For example, x: _ := 42 will be almost equivalent to x: Int := 42. (In reality, it can be filled with other implementation-dependent subtypes of Int, e.g. SmallInt.) Like the dynamic type it can appear in any position in the type specification, e.g. Vector{_} or _{Int}, and placed before ... it will infer the types of the arbitrary number of function arguments, e.g. (Int,_...)->().
It is the default type for the return type in the function, and in fact the only possible return type for the inline function syntax—for example, fun (x,y) -> x+y is equivalent to fun (x,y)->_; return x+y; end (which type is (x:*,y:*)->*). It also arises from the use of placeholder in the tuple unpacking, and also from the assignment or binding without a type when use static is enabled.
self typeDenoted as self, it denotes the type of enclosing class. It is invalid outside the class or in the class method.
It is quite limited compared to the normal expression syntax, which is intentional and allows an efficient compile-time implementation.
(For paranoids: Technically speaking, naru’s type system is (for now) a rank–1, predicative Hindley-Milner type system extended with nominal subtyping. The type inference on this type system should be decidable, though it hasn’t been proved yet. We plan to extend the type system to the higher-order and impredicative subset of System F, which restrictions allow the decidable type inference with some annotations.)
TODO describe syntactic sugars like T? for type expression
While the static typing in naru is optional, naru does force one to use the static typing in some cases. The major cause is the multiple dispatch, since we should know the arguments’ types in order to determine what function is to be called:
#+#(a: Int, b: Real) := fun -> Real from(a) + b
#+#(a: Real, b: Int) := fun -> a + Real from(b)
If some expression is typed statically, any access to attributes and methods of the expression is checked in the compile time. So if we wrote the first function body as fun -> a addToReal(b), it will be caught earlier given that addtoReal method does not exist in Int.
Static to Dynamic. While this behavior detects many bugs due to the typo, one may find it inconvenient. There are several ways to convert the static type into the dynamic type (i.e. force the compiler to forget the type information): the easy way is to assign the statically-typed value to the variable, since the variable is typed dynamically by default.
#+#(a: Int, b: Real) := fun
a0 = a -- a0 is typed as *
return a addToReal(b) -- this will raise the runtime error
end
The variation of this way is to use an explicit type casting via as expression. naru will treat given expression as given type, and if needed will add a check for ensuring the assertion.
#+#(a: Int, b: Real) := fun -> (a as *) addToReal(b)
The last way is to use a function which arguments are dynamically typed. This allows the dynamically-typed function to accept the statically-typed values without any modification, with an appropriate runtime check.
addHelper(a, b) := fun -> a addToReal(b)
#+#(a: Int, b: Real) := fun -> addHelper(a, b)
Personally, I am against the first and second way because it will prevent the early detection of bugs. The very reason for naru to allow mixing static and dynamic types is to help the gradual transition: one may keep some functions static and some functions dynamic, and will only receive the runtime errors from the latter only. If you want the function to be static then it is better keeping it throughout the function.
Dynamic to Static. In the reverse direction, one may convert the dynamic type into the static type for better safety. The first way is to use the explicit type casting: if the cast fails the CastError exception will be thrown.
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
Note that one can use the Value isa method instead of the nested do~but blocks:
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
Of course one still have to cast x in order to notify the compiler; the compiler does not recognize the Value isa method at all! Since this is very error-prone (what if one misses x as SomeType in one case?), naru provides a shortcut:
double(x) := fun
given x
case Int as x then -- overrides the prior binding for x
return x * 2
case String as y then -- in this case x is still dynamically typed
return y ~~ 2
case * then
-- this case will catch all other types, since any type can be
-- converted to the dynamic type.
raise ValueError("x is not an integer nor a string")
else
-- this case is impossible, and in fact, can be omitted.
end
end
This given-case construct is generally used for pattern matching, but can also be used for general type matching as shown here. Detailed rules for pattern and type matching are given in the following sections.
The other way to convert the dynamic type to the static type is using the statically-typed function, possibly with the multiple dispatch. This is possible because calling the statically-typed function with a mismatching dynamic type is not a compile-time error but a runtime error. In fact, this is how as operator is internally implemented: x as SomeType is equivalent to (fun (_arg:SomeType) -> _arg)(x).
Forcing the static typing. We have already seen that the type of variables default to the dynamic type(*). One may use use static and use dynamic in order to override this behavior:
-- the type checker will catch "p length" to be invalid.
do
use static
p = 4
q = "Hello"
"This line should not be printed" println()
(p length + q length) println()
end
-- before running the program the type checker does not try to
-- check "p length" to be invalid or not.
do
use dynamic
p = 4
q = "Hello"
"This line will be printed though" println()
(p length + q length) println()
end
Note that if we wrote the second-to-last line as (4 length + "Hello" length) println() instead, then it will cause the type error in compile time, despite use dynamic is enabled. This is because 4 itself always has a type of Int (or its subtype); use static and use dynamic will make a difference only when the assignment or binding is used.
use static also does not affect the argument types, which always default to the dynamic type. Therefore one should at least annotate the argument types when using static typing as a default. (For paranoids: While it is not impossible to infer the argument types from the function body, such types are very limited. For example, imagine the function succ(x:_)->_ := fun -> x + 1; given #+# is open to extension, what argument types should be inferred here? The solution is provided in the next section.)
The type inference in naru occurs when one uses a placeholder type _:
x: Int := 42
y: _ := x + x -- the type of y is Int
z: _ := x ** y -- the type of z is Real (since y can be negative)
This type is also implicitly assumed for the following cases:
use static is in active, the typeless assignment a = ... is equivalent to a: _ = .... (Same for binding.)X(...) := ... without the return type is equivalent to X(...)->_ := ....fun (...); ...; end and fun (...) -> ... without the return type is equivalent to fun (...):_; ...; end and fun (...):_ -> ..., respectively.type attribute, which is a compile-time equivalent to class attribute, is also internally inferred as like placeholders.This placeholder type has an important role for type-defining uniform declarations like X(a:Int) := class ... end. In this case, the resulting return type should match with the type defined in this very declaration (therefore X(a:Int)->Y := class ... end is rejected) and the inferred type is handy.
The type inference in naru is based on Hindley-Milner algorithm, which constructs a list of constraints in order to determine the actual type for placeholders. This algorithm always terminates (“decidable”), although it may take lots of time to finish the algorithm for pathological code. The compiler supports an option in order to limit the degree of type inference.
TODO boundary of type inference (maybe a module boundary) and type inference for open types
The naru type system primarily concerns with named types (i.e. nominal type system), but it is often convenient to use structurally defined types. For example the binary tree is either a leaf or a branch, where the branch contains two smaller trees and one value. It would be implemented in naru as follows:
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
Readers familiar to data structures and algorithms could immediately figure out how the insertion or deletion is implemented with this structure. Unfortunately such operations become very complex to implement correctly. For example, even the tree rotation (red-black trees rely on this operation extensively) becomes cumbersome:
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
This code uses too much code to check if given tree is of a particular form and to narrow the compile-time type of the tree accordingly. The primary goal of pattern matching is to specify this using a structural way and thus to remove such boilerplate codes:
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" after the case speficiation is optional.
return Branch(Branch(child1, value1, child2), value2, child2)
else
raise ValueError("cannot perform the tree rotation on this tree")
end
end
It is much easier to spot what’s going on, and in particular it is now clear that the tree rotation does not affect the in-order traversal. Locally defined variables childN and valueN are only available within the corresponding case block.
case-ed Classes. naru issues an error for missing case clauses against the statically given type: if we omitted the else branch in the earlier code, we will miss the case that node is just a Leaf (for instance). For the same reason, the following code raises a compile-time error:
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
It is not immediately obvious what’s wrong with this code, but the problem becomes clear if we add a subclass Trident{T} (maybe for 2,3-tree) to Tree{T}: TreeSum reduce does not have an implementation for Trident! Therefore we need some way to say that Tree{T} is not open for direct subclassing, which is achieved by case-ed classes.
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
case specifier in the trait or class definition is similar to global in such that the identifier is declared outside of the current scope, but also signifies the enumerated cases are all possible direct subtypes of this trait or class. Therefore aforementioned Trident{T} subclass is no longer possible with this definition. It is also possible to nest case-ed traits or classes.
case Specification for Clauses. The case specification is a superset of the type specification, which means that the trait or ordinary types can also be used for cases. It is even possible to use the dynamic type *, which will catch every uncaught types. (In this regard the placeholder _ is exactly same to the dynamic type in the case specification.)
The case specification extends the type specification by adding the following expressions:
Branch(l,v,r), Leaf() and so on. The empty constructor argument list is only used for verifying the given class has no constructor arguments.
Deconstructed names are available only within the following case block. Deconstructed names are very similar to function arguments: a named argument syntax (Branch(left:l, right:r, value:v)) can be used to reorder the argument order, and a placeholder (Branch(_,v,_)) can be used to ignore some irrevalent arguments.
Class deconstructions can be nested, but this does not mean that ordinary type specifications can also be used in the position of deconstructed names. For example, Branch(Branch(l2,v2,r2),v,r) is possible but Branch(Branch,v,r) is not (unless you want to bind Branch to the right subtree of the branch).
Branch(l,v,r) as b, Int as n and so on.
This binding gives an additional name for the non-leaf node of class deconstruction or type specification, which is normally not available. It is useful for use static mode, since given-case construct does not automatically make the compiler to treat the given expression to the matched type. The binding may also be used for nested class deconstructions.
Enumerable{T} for T, ((Dest,Src)->Dest,Dest,Enumerable{Src})->Dest for Src,Dest and so on.
The explicit type variables can be used to give an additional name for types, and can only be used in the top-level of case specification. For other reasons, these type variables are treated as a placeholder _: Enumerable{T} for T will be treated as Enumerable{_} during the inference, for example.
RedBlackBranch(l,v,r,red:true) and so on. Literal values match for exactly same (i.e. == returns true) values. Such syntax is only allowed for Bool, Int, Char and String values for the clarity.
Yet another use of the word “type” in naru is, if you still recall, as a keyword. We have already seen that type attribute gives a compile-time type, but type can also be used for declaring another type:
MapFromInt{Value} := type <- Map{Int,Value}
To be exact, this does not declare another type but an alias to it. MapFromInt{String} is exactly same as Map{Int,String}, for example. This declaration is needed because one cannot put the static parameter for normal declarations like MapFromInt{Value} := Map{Int,Value}.
type declarations can also be used for declaring a structural type:
List{+T} := type (Nil() | Cons(head:T, tail:List{T}))
Weekday := type (Mon() | Tue() | Wed() | Thu() | Fri() | Sat() | Sun())
Note that the constructor argument is always required even if it is empty, which resolves the ambiguity in the case specification. (For example, is Mon in WeekDate(year,weeknum,Mon) a reference to the singleton Mon value or just another name binding?) There is an advanced way to eliminate these parentheses, which is used for true and false values—which is defined just as Bool := type (true | false).
Having explained about types, we can now put interest to advanced features of functions in naru.
TODO
TODO
with OperatorWhile with blocks are used to finalize resources automatically, with within an expression works differently: it augments the meaning of a preceding expression.
"A(2,4) = ...#((2 ** 65536 with modulo: 1e20) - 3)" println()
--> A(2.4) = ...45587895905719156733
with operator appends given arguments to the outermost function call or operator of given expression; in this case, exponentiation operator ** is the outermost and since 2 ** 65536 corresponds to #**#(2, 65536) (see Special Names and Complication) the actual function call would be #**#(2, 65536, modulo: 1e30). with operator has to be parenthesized unless it is the outermost operator for function arguments or the sole statement; you can also use non-keyword arguments like (2 ** 65536 with 1e20), or multiple arguments like (sin(30) with :degrees) with accuracy: 0.5(--ulp--), but they are not always recommended. For the infix operators, with can spread to multiple arguments of same precedence (e.g. a + b * c - d with e is same as #-#(#+#(a, #*#(b, c), e), d, e)) unless the term of same precedence is parenthesized.
It is mostly useful for options of functions (although normal function call can be augmented as well); the aforementioned a ** b with modulo: c is one example, which is same as a ** b % c but much more efficient when b is very large; many floating point operations have an explicit rounding argument, like a + b with :trunc (for rounding towards 0); the range with non-default step can be constructed by a..b with step: c; and so on.
with can be applied to a single term as well; in this case, it calls #with method of given value. For example, #with method of functions receive a named argument and returns a new function that calls the original function with given argument: thus log with base: 3 is equivalent to fun (x) -> log(x, base: 3). Depending on the use, it can be very powerful way to extend naru.
There are a few operators that cannot be augmented in this way. Operators that do not desugar into a function call (e.g. but operator) are obvious exceptions, and assignments and operation-assignments cannot be augmented as well. with itself can be augmented.
So far, we have seen two ways for making function: function declaration (name := fun ... end) and anonymous function (fun (args) -> ...). There is the third way, an automatic function, that is an obvious but useful shortcut for an anonymous function:
[3, 4, 5] map(_ + 1) println() --> [4, 5, 6]
The full list of shortcuts is as follows (the argument name is only provisional, and actual type does not reflect it):
(_ attr) (where attr is an identifier) is equivalent to fun (x) -> x attr.(_ meth(args...)) is equivalent to fun (x) -> x meth(args...).(op _) is equivalent to (_ op#()), that is, fun (x) -> op x.(_ op) is equivalent to (_ #op()), that is, fun (x) -> x op.(_[i]) is equivalent to (_ #[#](i)), that is, fun (x) -> x[i].(_(args...)) is equivalent to (_ #(#)(args...)), that is, fun (x) -> x(args...).(_ op _) is equivalent to fun (x, y) -> x op y, or more non-intuitively, #op#.(_ op y) is equivalent to fun (y) -> x op y.(x op _) is equivalent to fun (x) -> x op y.(_ op _ with ...) (and others) is equivalent to fun (x, y) -> x op y with ... (and others, respectively).Any arguments within an automatic function are evaluated at the time of function construction. So strictly speaking, (_ + 3 println()) and fun (x) -> x + 3 println() is not equivalent.
The automatic function has to be parenthesized like with operator, unless it is an outermost operator in a function argument or a sole statement (highly useless though). Therefore the previous use, map(_ + 1), is valid, but (_ + 1)(3) needs parentheses.
TODO
TODO
The tail call “optimization” (TCO) is a way to implement function call (often recursive) with no additional stack use. For example the following range function:
range(low, high, result=:[]) := fun
if low >= high then return result end
return range(low+1, high, result ~ :[low])
end
…can be efficiently implemented as a loop:
range(low, high, result=:[]) := fun
while not low >= high
result = result ~ :[low]
low += 1
end
return result
end
For some cases, however, the compiler is able to automatically convert the former into the latter, which is far more efficient than the original version. In brief, the tail call that is a form of return somefunc(...) can be applied. There are some reasons to prefer the former as well; some algorithms are inherently recursive and the transformed code is often unintuitive. Therefore this kind of transformation would be useful if we use lots of them.
The problem is that the tail call optimization is not an optimization. It changes the semantics of the code, as it will change the space complexity; for example, if we have a tail call which is optimized away and we mistakenly make it a non-tail call then we might see the unexpected space complexity increase. I consider TCO as an example of fragile optimization due to this reason. (Some FP people might disagree on this subject, but they have to realize that the programming language is not a mere tool or inference rule but an interface between the machine and programmer.)
Thus instead of making every tail call optimized, naru will only optimize the tail calls which are explicitly specified to be optimizable. This is simple, as you simply have to use \return instead of return:
range(low, high, result=:[]) := fun
if low >= high then return result end
\return range(low+1, high, result ~ :[low])
end
(Think of \ as a stack flattening.) If \return statement is not a tail call (e.g. \return func(42) + 1) then it would be an error, so you can ensure that you have written a tail call and it will be optimized always. You have to use \return even when you don’t have the return value, that is, the function returns () implicitly.
While a simple program fits in one file, most programs does not; they have to be organized somehow. In naru the module is a unit of program, and it can be imported by import statement:
-- constants.n
PI := 3.1415926535897932
E := 2.718281828
-- area.n
import constants *
radius = 42
#"area = #(PI * radius**2)" println()
In general the following kinds of import statements are allowed:
import constants -- imports a module value
import constants PI -- imports a single symbol
import constants (PI, E) -- imports several symbols from one module
import constants PI as PI_16DIGITS -- imports a symbol with an alias
import constants (PI as PI_16DIGITS,
E as E_9DIGITS) -- imports symbols with corresponding aliases
import constants * -- imports every symbols from one moduel
import constants, math -- imports multiple modules
import (constants, math) -- ditto
import collection SkipList{Int} as IntSkipList
-- imports a specialized symbol with an alias
import collection SkipList{Int} -- this is not allowed
import collection SkipList -- workaround: use this and refer `SkipList{Int}`
There is also a hierarchy of packages, which contain other modules or packages. For example, let’s assume the following hierarchy:
image/
_.n
readers/
bmp.n
png.n
apng.n
gif.n
writers/
bmp.n
png.n
pixmap.n
color.n
colormodel.n
As you can see _.n is a special file that is run before any import for modules in the image package. (_ is not allowed in the symbol name in general, so this module has the name of just image. Also _.n is optional; if it does not present, children can be still imported but the package itself cannot be imported.) Outside the image package, one can use import image readers bmp or import image colormodel to import bmp and colormodel module values respectively. Normal imports are absolute, which means import image readers bmp is equivalent in the image pixmap module as well. For relative imports one can use import self readers bmp (which finds ./readers/bmp.n); for parent packages one can also use import super readers bmp (which finds ../readers/bmp.n) or even import super super image readers bmp (which finds ../../image/readers/bmp.n), but I don’t recommend these.
Cyclic imports are allowed as long as there are at most one dynamic module (i.e. the module with assignment statements) in the cycle. So library writers are strongly encouraged to write no dynamic code in the library code unless there is a good reason. TODO mutability of modules
Finally, naru has a built-in package named naru. The initial runtime module that contains Int, String and so on is named naru core; every naru code implicitly imports naru core before the first line of it. (i.e. import naru core *.)
If you are living in the United States, where you don’t need the freaky letters, you probably don’t have to use (or even didn’t heard of) Unicode. However alas, you are not alone in this world. People around the world uses lots of scripts and languages that make Unicode does matter.
Following the trend, naru has a native support for Unicode. Until now I didn’t explain how the user input is handled; I had to postpone the explanation since it does involve Unicode. The following is how the input is (properly) handled:
do
">>> " print()
text = String readln(replace: "")
if text == "" then retry end
but EOFError
"You pressed Ctrl-D or Ctrl-Z!" println()
end
Yes, this is ugly, but all the user input is ugly. String readln reads one line from the standard input; the replace: "" means it should replace an erroneous character with given string (here, the empty one) when the line cannot be properly parsed as a Unicode string. If it is none then it raises the UnicodeDecodeError exception instead; you have to handle it at your own. The default is none.
You can read lines from the file likewise:
with f <- File("data.txt") open()
text = String readln(f, replace: "?")
but EOFError
text = ""
end
Or even with a for loop:
with f <- File("data.txt") open()
for line <- f lines
line println()
end
end
As you can see the FileStream (the return value from File open method) value has lines wrapper, which is iterable (i.e. Enumerable{String}). But how about the binary file? The natural interface would be bytes instead of lines:
with f <- File("data.bin") open()
for byte <- f bytes
byte println()
end
end
In this case, byte would be an integer ranges from 0 to 255 (for 8-bit bytes, as most computers do). But this means you cannot handle them as sequences as you can see only one byte at a time. What if we are handling zero-terminated bytes, for example?
with f <- File("data.bin") open()
bytes = Bytes readz(f) -- really, a shortcut for Bytes readuntil(f, 0)
end
You can see the new class here: Bytes (short for byte sequence). It is a lot like Vector{Int}, but it assumes all elements in it fit in the byte. Also it has several methods that deals with byte sequences, like readuntil and asciilower/asciiupper.
So naru has two string types: String for an ordinary Unicode string, and Bytes for a byte sequence. Both can be converted to each other using Bytes decode and String encode methods:
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) -- overwrite
end
naru also provides literals for byte sequences, b"..." and x"...". The former may contain a raw byte like b"abc" but limited to the letters in ASCII, and the latter may contain hexadecimally encoded bytes like x"61 62 63".
The core abstraction behind I/O in naru is the stream, represented by the superclass Stream. It treats the input source (Reader) and output sink (Writer) as a stream of bytes, as the direct interface shows:
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
There is one important fact about the naru stream: unlike C’s fopen, it doesn’t distinguish the text mode and binary mode. In the other words every stream in naru is implicitly in the binary mode. Parsing and decoding the bytes from the stream is the job of String and Bytes, as they exactly know how to treat bytes as their own value.
TODO
TODO
Syntax extensions are declared by compiler directives, statements starting with use keyword. Compiler directives themselves are further explained in the next chapter.
If you want to add a new operator rather than extending the existing operators (which can be easily done by overloading #+# or so), then you need to introduce new tokens: either punctuations or textual keywords. This can be achieved by use token directive:
use token "~=" -- declares a new punctuation.
use token "unless" -- declares a new keyword.
use token "[\\ \\]" -- declares two new punctuations which are pairs to each other.
use token "` `" -- declares one new punctuation which is a pair to itself.
use token "if" -- error: token already exists.
As shown with declarations for [\ and \], tokens are given as a string which can contain any Unicode character. It is possible to declare ≤ operator by use token "\u2264", for example. If the token string contains two tokens separated by whitespaces it declares two (not necessarily different) tokens that are opening and closing tokens respectively.
There are some restrictions on available tokens:
_ cannot be (re-)declared, and # cannot be used." and ' cannot be used, and % and \ cannot be the first character of the token unless the entire token is % (which is already declared by the compiler anyway).+=) should end with =.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.
Sometimes you need arbitrary literals; a regular expression, the feature naru does not support directly, would be one example. However naru does not support arbitrary literals, because parsing arbitrary literals requires the capability of specifying lexical syntax which can be very complex. Therefore instead of supporting arbitrary literals, naru specifies a set of fixed but extensible generic literal syntax.
There are two forms of generic literals in naru:
prefix'...', prefix"...", prefix'''...''' or prefix"""..."""—i.e. a prefix followed by bare string literal without an additional prefix.%prefix(...), where (...) is a contents delimited by matching characters. For example, (...( does not match and (...) should be used, but /.../ matches since / does not have a matching character.The form and prefix uniquely determines the kind of generic literals in the given scope. The prefix can be a valid identifier, a non-negative integer (where 042 and 42 is regarded same) or an empty string. For identifiers the prefix is case-sensitive.
TODO generic literal contents, generic literal definition syntax (use literal ... maybe?)
Some generic literals are reserved by naru itself. In the following table literals reserved by naru core module are built-in, and additional modules should be imported to use others.
| Syntax | Module | Literal Type | Description |
|---|---|---|---|
"..." |
naru core |
String |
Principal quoted string. |
r"...", R"..." |
naru core |
String |
Raw string. No escape sequence recognized except for \", which prohibits the end of string but still included in the string as is. |
b"...", B"..." |
naru core |
Bytes |
Principal quoted byte sequence. Can only contain ASCII letters and escape sequences. |
br"...", BR"..." |
naru core |
Bytes |
Raw byte sequence. |
x"...", X"...", bx"...", BX"..." |
naru core |
Bytes |
Hexadecimal-encoded byte sequence. Any non-hexadecimal character is invalid; any whitespaces are ignored, and there should be even number of digits. |
2"...", …, 36"..." |
naru core |
Int |
Extended integer literal. |
%(...), %q(...), %Q(...) |
naru core |
String |
Alternative quoted string. |
%w(...) |
naru core |
PureVector{String} |
A vector of strings, delimited by one or more whitespaces. |
%r/.../ |
naru text pattern |
Pattern |
A pattern value. Customarily delimited by /. |
TODO
TODO
naru provides compiler directives that controls the behavior of the compiler. Compiler directives are statements starting with use (which may be followed by no for negative meaning); the very first statement introduced in this document, use naru, is an example of compiler directive. They are comparable to #pragma in C/C++, in the sense that the directives that cannot be comprehended by the compiler are ignored. (Comprehensible but incorrect directives are not ignored though.)
use naru directiveEvery naru program can contain the use naru statement as a first non-comment non-empty statement. This is a recommended way to distinguish naru program (rather than editor-specific lines, for example, like -- vim: syn=naru), though it is totally optional. Every trailing tokens after use naru are ignored as like other unrecognized directives in order to allow future extension.
Examples:
use naru -- valid
use naru revision 4 -- valid, two tokens ignored
use naru (revision 4) -- valid, four tokens ignored
use naru ]revision 4[ -- syntax error; brackets still should match
use naru for good, not evil -- valid, five tokens ignored (comma is a part of the statement)
use naru """what
the
hell?""" -- valid, one token ignored
Note that use naru should be the first statement if used; later occurrences are invalid.
use encoding directiveuse encoding directive alters the current lexer to read the naru source code (a Unicode string) using given character encoding. The change applys after the statement terminator of the directive (i.e. the first semicolon or newline sequence, where the latter can be up to two characters). The character encoding must be ASCII-compatible, meaning the byte from 0x20 to 0x7e should decode into characters from U+0020 to U+007E; therefore UTF–16 cannot be used, for example. The companion, use no encoding, sets the current encoding to the default one.
These directives can only be used in the top-level of the program, and the multiple use of them is strongly discouraged. In fact their single use is also discouraged, but there are some legitimate reasons to use them (albeit due to the unfortunate reason).
use encoding "cp949" -- this comment is still encoded in the default encoding
use encoding "shift-jis"; -- this comment is encoded in Shift_JIS
do
use encoding "koi8-r" -- invalid, should appear in the top-level
"blahblah" println()
end
use no encoding
-- now this comment is encoded in the default encoding again
The default encoding is UTF–8 (so the useless UTF–8 BOM will be safely ignored). If you are using a badass editor that does not support UTF–8, you can still prepend the use encoding directive. One another use case is to avoid non-ASCII characters for the international development, using use encoding "ascii".
use token, use syntax and use literal directivesTODO
use static and use dynamic directivesuse static and use dynamic directives control how the type inference is performed. In short, when the explicit type is missing in the assignment or binding it is assumed to be the dynamic type (*) when use dynamic is enabled, or the placeholder (_) when use static is enabled. You can still use the static types when use dynamic is in effect, or the dynamic type when use static is in effect though. These do not affect the missing argument types (they are always assumed to be dynamic); only missing return types are inferred.
These directives affect the current block, and should be placed at the front of the corresponding block. The default is using use dynamic. The innermost directive is active, so use dynamic does have a use.
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 directiveTODO
use extern directiveTODO 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
The memoranda in this section will be eventually merged into the relevant sections.
_ and * as dual wildcard tokens is intentional, and there is a clear distinction between two tokens: _ should refer a single value while * may refer multiple values. For example, in the type expression, a placeholder type _ gets assigned only one possible type (inferred one) but a dynamic type * can get assigned multiple types in runtime.\ is read “flattening” in naru, and its uses outside the literal reflect the name. \return is spelled a flattening return thus.Only issues that affects the whole document are listed; grep for TODO for local issues.
Missing documentation. Should write the following items somewhere…
_, Unicode identifier, operator-wrapping identifiers etc.)"""...""" is yet to be explained?!)use token, use literal, use operator, more? use syntax would be very complex if used.)Alternative import syntax. For consistency the following syntax is more desirable:
-- assuming "package module" contains "symbol1" and "symbol2" symbols...
mod := import package module -- defines: mod symbol1, mod symbol2
But it seemingly defies the natural extension:
* := import package module -- defines: symbol1, symbol2 (?)
_ := import package module -- defines: module symbol1, module symbol2 (?)
sym1, sym2 := import package module (symbol1, symbol2)
-- defines: sym1, sym2 (???)
The last syntax is especially worse than the original import package module (symbol1 as sym1, symbol2 as sym2), but it is not clear that it does matter.
Import rules for syntax extension. To avoid the conflict between same syntax declared in two modules, we should be able to import syntaxes selectively. Example:
import somethingawful "%r" -- the original %r/.../
import naru text pattern "%r" as "%re" -- rename %r/.../ as %re/.../
Or should we have a distinct syntax for syntax declarations? We already have similar ones for operators (#+# etc.), but we don’t have a good alternative here.
Parametric modules. I hope…
More selective module imports. E.g. import naru core >= 3.1 or import naru core * but String. Syntax TBD.
Explicit memory layouts. naru ext Struct should be a base class of all memory-layout-sensitive classes. E.g.
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
Therefore the class reflection should preserve the member order. Padding should be specified manually (e.g. _: Int16), and every non-memory attributes should be specified as an attribute. (The latter would be no problem when the getter-only attribute can be easily defined.)