Skip to content

Instantly share code, notes, and snippets.

@cr0sh
Last active October 7, 2023 10:39
Show Gist options
  • Save cr0sh/495cd642f123a60884afce9894c135c5 to your computer and use it in GitHub Desktop.
Save cr0sh/495cd642f123a60884afce9894c135c5 to your computer and use it in GitHub Desktop.
The Khala Language Specification v0.3.1
키워드 용도
fn (parenless) 함수/클로저 선언
if 분기문
for 반복문
else 분기문의 별도 조건 혹은 for의 정상 종료 구문
break 반복문 탈출
return 조기 반환

Concept

  • 단순함: 최소한의 키워드, 최소한의 문법적 기능
    • 유일한 복합 자료형은 테이블
    • 대부분의 요소를 런타임 처리(struct 정의 등)
    • 문법의 부족으로 boilerplate가 어느 정도 발생하는 것을 허용
    • 가능한 한 필요한 추가 기능은 유연한 확장으로 대응 (bind, parenless fn)
  • 명료함: TOOWTDI (There's Only One Way To Do It)
  • 일급 시민 Rust: extern "Rust"와 ABI 호환성 보장
    • 문법의 느낌도 Rust와 최대한 비슷하게
  • 안전성: sandboxing 지원, 'safe-only' implementation 제공
  • 데이터와 코드의 통합: table로 모든 것을 선언
  • 컴파일러를 믿어라: 최적화 힌트를 통해 단순한 언어로부터 최대한의 성능 제공
    • struct 힌트 등

Key Concept

  • 모든 것이 테이블: 모듈, block({ /* ... */ }), ...
  • Lua에서 메타메서드 차용, 그러나 별도의 메타테이블이 없으며, "__tostring" 같은 "magic" string key 대신 atom과 유사한 전용 키 (meta.to_string)를 사용
  • 커스텀 키워드를 정의할 수 있는 parenless fn (my_keyword { /* ... */ })
  • 동적 언어이지만 ADT 스키마를 정의할 수 있는 structenum parenless fn
  • Lua의 coroutine 기반 비동기 프로그래밍이 아닌 async-await 기반 비동기 프로그래밍
    • 그러나 function coloring 없이 모든 함수가 (암시적으로) async
  • trait 기반 다형성 지원
  • Exception 없음: 대신 에러를 직접 반환하며, Rust의 ?나 Zig의 try에 대응되는 try parenless fn을 사용
    • return_twice 로 "최대 2회" unwind하는 것이 주된 컨셉트

Tour

변수 선언

keyword let

let foo = "42";

자료형은 7가지이며 type 함수로 구분 가능

  • type.boolean: 진리값
  • type.integer: 64비트 부호 있는 정수
  • type.float: 64비트 배정밀도 IEEE754 포맷
  • type.function: 호출 가능 타입
  • type.string: UTF-8 문자열. Rust &str과 ABI-compatible
  • type.table: 유일한 composite type(array / hashmap에 모두 대응)
  • type.metatype: 유일한 metatype

type.* 은 prelude에 포함되어 있기 때문에 integer 와 같이 바로 사용할 수 있음. 키워드가 아니므로 재대입되거나 쉐도잉될 수 있음. 이는 error 등 다른 값들에도 적용됨 (예외로 fn은 키워드임)

let은 쉐도잉을 함.

let x; x = 1; 과 같이 선언과 정의를 별도로 할 수 있음. 이는 재귀함수를 정의하거나 할 때 유용함.

분기

keyword if

if x { something() } else { another_thing() }

if ... else는 expression임.

반복

keyword for

// iteration
for let x = { 1, 2, 3 } {

}

// infinite loop
for {
    say();
}

// conditional loop: condition에 해당되는 표현식은 매 블록의 시작 전마다 재평가됨.
for should_loop() {

}

for 루프는 statement이지만 for ... else 루프는 expression임. ( for { /* ... */ }에는 else를 붙이지 않아도 expression임). else에 붙은 expression은 항상 마지막에 필요할 때만(루프가 break 없이 탈출할 때만) 평가됨.

let first_even = for let x = { 1, 2, 3 } {
    if x % 2 == 0 {
        break Some(x);
    }
} else None

타입 변환

기본 제공되는 이항 연산자들은 서로 다른 타입 간의 연산을 지원하지 않음. 모두 명시적으로 변환하여야 함.

integer, float, string<type>.from() 메서드를 가짐. 전술한 세 타입 간의 변환은 모두 지원하며, string.from()은 모든 타입으로부터의 변환을 지원함.

string.from() 은 해당 타입이 meta.to_string 필드를 가질 경우 이를 우선적으로 사용함.

integerstring 간의 변환을 제외하고 모든 변환은 backwards compatibility를 보장하지 않음.

integer 혹은 floatstring 간의 변환은 10진수 표현만을 지원함.

테이블

{}

let varkey = "foo"
let table1 = {
    arrvalue1,
    arrvalue2, // 마지막 콤마는 선택 사항으로, 한 줄 선언 시 없애고 여러 줄 선언 시 붙이는 것을 권장함.
}
let table2 = {
    this: "is"
    a: "table",
    ["complex-key"]: "not-a-complex-value"
    [varkey]: varvalue // varkey가 위의 키들과 겹치는 경우 아래가 우선함
}
println(table1.0)
println(table1[1])
println(table2.this)
println(table2.a)
println(table2[varkey]) // table.foo

테이블 선언 문법은 아래의 문법 설탕이라 할 수 있음. ({}는 데이터가 아닌 코드를 담고, 사실 둘은 구분되지 않는다!)

let table = {
    self.0 = arrvalue1;
    self.1 = arrvalue2;
    self.this = "is";
    self.a = "table";
    self["complex-key"] = "not-a-complex-value";
    self[varkey] = varvalue;
}

{ /* ... */ } 블록은 환경을 캡쳐함.

Lua와 동일하게, table이 우변인 대입문은 (meta.clone이 별도로 정의되지 않는 한) shallow copy를 수행함. table이 좌/우변인 동치 비교문(==)은 두 테이블이 shallow copy되지 않았던 한 false로 평가됨.

테이블의 인덱싱

존재하지 않는 테이블의 필드에 접근 시 index_error를 반환함.

테이블의 필드를 제거하고 싶은 경우 tbl.remove(key) 를 사용함.

테이블에는 tbl.len() 메서드가 존재하며 테이블의 크기를 반환함.

테이블의 크기

테이블의 크기는 테이블이 가지고 있는 필드의 수로 정의됨. Lua의 # 연산자와 다르므로 주의를 요함.

클로저 / 함수 정의

keyword fn (별도의 함수 선언 문법 없음)

let say_hello = fn() { println("hello"); };

위 정의는 호출에 괄호가 꼭 필요하므로 parened fn이라고 함.

Parened 함수 정의는 variadic arguments를 지원하지 않음. parenless 함수는 지원함.

함수에서 값을 조기 반환하고 싶으면 return 키워드를 사용할 수 있음. 함수에서 { /* ... */ } 블럭은 필수 사항이 아님.

Parenless 함수 선언

let match = fn x branches { /* ... */ }

match x {
    "a": fn { /* ... */ }
    "b": fn { /* ... */ }
}

Parened 함수와 parenless 함수는 서로 호환되지 않음. 즉 일반 함수를 정의하고 parenless invocation을 하는 것은 허용되지 않음.

모든 statement는 끝에 ;을 붙여야 하며 마지막에 붙이지 않으면 마지막 expression을 평가한 값이 됨.

함수는 여러 값을 반환하고 호출자도 여러 값을 반환받을 수 있음.

너무 많거나 적은 값을 받으면 abort함.

parenless invocation에도 괄호를 사용할 수 있으나 함수 이름과 괄호 사이에 공백이 꼭 있어야 함. 반대로 일반 함수 호출은 공백이 있으면 안 됨.

Parenless 함수 호출

모든 <expr> <expr> [<expr> ...]; 형식의 표현식은 첫 번째 expression을 parenless 함수로 가정한 parenless 함수 호출 구문으로 해석됨.

Parenless 함수 호출 구문은 그 자체로 일종의 2-stack machine으로 변환되어, 각 parenless fn을 prefix operator로 하는 구문들로 해석됨. 해당 2-stack machine은 다음의 과정을 통해 평가가 진행됨.

  • 각각 operator stack과 operand stack으로 구분하여 지칭함.
  • operator는 parenless fn을 의미하며, operator의 arity는 parenless fn이 받는 파라미터의 수를 의미함.
  • expression을 앞부터 순회하면서 다음을 반복
    • operator를 만나는 경우 operator stack에, operand를 만나는 경우 operand stack에 push
    • operator stack의 맨 위 operator의 arity보다 같거나 많은 operand stack 구성원이 있다면, operand stack의 최상위 {arity}개 만큼을 한 번에 pop하여 operator에 제공하여 연산을 수행함.
    • 반환된 값(들)을 순차적으로 순회해 operator인 경우 operator stack에, operand인 경우 operand stack에 넣음.
    • 종료 조건은 다음과 같음.
      1. operator stack이 비게 된 경우 operand stack에 남은 값이 모두 parenless 함수 호출 구문의 평가 결과가 됨.
      • 1a. 이 때, 남은 expression이 있을 경우 abort함.
      1. operator stack이 남았으나 남은 expression이 있을 경우 abort함.
      2. operand stack이 비게 되었으나 operator stack이 남아있는 경우 abort함. 이는 4보다 우선함.
      3. 남은 expression이 없는 경우 operator stack에 남아 있는 모든 operand를 넣고 부족한 경우 abort함.

예시:

impl { sayer } struct {
    name: string,
} implementation
operator operand
impl(arity: 3)
operator operand
impl(arity: 3) { sayer }
operator operand
struct
impl(arity: 3) { sayer }
operator operand
struct(arity: 2) { name: string }
impl(arity: 3) { sayer }
operator operand
implementation
struct(arity: 2) { name: string }
impl(arity: 3) { sayer }
operator operand
implementation
struct ({ name: string })
impl(arity: 3) { sayer }
operator operand
impl ({sayer}) (struct ({name: string}) (implementation)

그러므로 이 호출구문의 실행 결과는 impl ({sayer}) (struct ({name: string}) (implementation)이다.

바인딩

let hi = bind {
    hello: "hello!",
} fn() {
    println("hi! {}", hello)
}

hi() // "hi! hello!"

bindtype.function 타입에 사용할 수 있으며, Javascript의 Function.prototype.bind() 와 유사하여 context를 전달하거나 method invocation에서 self를 지정하는 데 사용될 수 있다.

값 임포트

use (또는 use <path> as)

use std.prelude.println; // 사실 이미 prelude는 임포트가 되어 있음. use std.prelude._;

println("hello world! 1 + 2 = {}", 1 + 2);

임포트할 path는 use sys.path; insert(path, "<newpath>") 로 추가 가능함. #로 시작하는 path는 빌트인으로 예약됨.

순환 임포트는 허용되지 않음.

값 익스포트

pub let

// one.kh
pub let value = 1;
// main.kh
use one.value;

println(value); // 1

// or use one; println(one.value)

주석은 C family와 동일하게 ///* ... */을 사용할 수 있음.

모듈

모든 .kl 파일은 모듈로서 작동함. 모듈울 임포트하는 쪽에서는 (익스포트된 이름과 값이 키-값 쌍으로 있는) 테이블로서 사용함.

  • 모듈 파일의 첫 두 바이트는 #! 으로 시작될 수 있으며 이 경우 다음으로 만나는 newline(\n)까지의 내용이 무시됨. (shebang)
  • 모듈 파일은 시작 시 '도입부'를 가지며, 도입부는 다음을 만족하는 구문 혹은 요소를 포함하는 가장 넓은 영역임.
    • shebang
    • use 구문
    • 주석
    • 공백
  • 도입부 밖에서는 use 구문을 사용할 수 없음.

Concurrency / Asynchrony

task.join(func1, func2, ...) / task.spawn(func1)

별도의 async fn에 대한 구분된 정의가 없고, async 함수를 호출하는 순간 암시적으로 await하는 구조. 즉 task.spawn(func1()) 은 func1() 의 결과를 성급하게 평가 한 뒤에 spawn하게 됨.

모든 Khala 실행 컨텍스트는 싱글스레드이며, 병렬화 혹은 멀티스레딩의 지원 계획은 없음. 각 스레드마다 별도의 실행 컨텍스트를 사용하는 방식을 권장함.

메타메서드

Lua와 비슷함. 아래의 키들을 테이블에 삽입함으로서 선언 가능

  • meta.call
  • meta.parenless_call
  • meta.eq / meta.lt / meta.le
  • meta.add / meta.sub / meta.mul / meta.div / meta.rem / meta.unm
  • meta.clone: 암시적 복사 생성자
  • meta.drop: 소멸자
    • Rust와 다르게 소멸시점은 정의되지 않음! GC에 의해 스코프 탈출 이후 늦게 소멸될 수도 있음.
  • meta.to_string

테이블에 메타테이블을 붙이는 작업은 따로 필요치 않으며 실제 테이블에 위의 키로 직접 설정하면 됨.

메타타입

Khala에서는 타입 자체도 first class value로서 다룰 수 있음. 타입을 생성하는 기본적인 방법은 struct, enum parenless fn을 사용하는 것임.

let vector3 = struct {
    x: integer,
    y: string,
};

let v = vector3 {
    x: 3,
    y: "foo",
}
let kind = enum {
    x: { integer, string },
    y: {},
};

let some_x = kind.x()
let some_y = kind.y()

struct / enum parenless fn으로 생성된 타입의 값은 by-value로 복사됨. (meta.clone이 자동 구현됨)

struct, enum은 모두 JIT 컴파일러에게 주어지는 최적화 힌트로서도 작용함.

let ty = struct { /* ... */ }; 에 대해, ty.is() 로 어떤 변수가 ty 구조체에 해당하는 지 알 수 있음. let ty = enum { /* ... */ }; 에 대해서도 동일함.

struct, enum 에 제공되는 타입 스키마는 테이블이여야 하며, 필드에는 struct 혹은 정수 인덱스만을 가지는 플레인 테이블을 넣을 수 있음.

패턴 매칭

let kind = enum {
    x: { integer, string },
    y: {},
}

다형성 지원

trait

let sayer = trait {
    say: method(type.string),
    whoami: static_method(),
}

method, static_method: todo!()와 유사한 trait placeholder

let human = impl { sayer } struct {
    name: string,
} {
    say: fn(to) {
        println("hello {}, my name is {}", to, self.name)
    }
    whoami: fn() {
        // 여기서는 self를 사용할 수 없음
        println("a human")
    }
}

<trait>.is() 함수로 어떤 값이 trait을 구현하는지 런타임에 검사할 수 있음.

주의: trait은 duck typing을 지원하지 않음. 다시 말해 어떤 테이블에 trait의 정의와 같은 함수 필드를 붙여도 <trait>.is() 함수는 true를 반환하지 않음. 항상 impl parenless fn을 사용해야만 함.

trait <supertrait1> <supertrait2> { /* definition */ } 으로 trait 간 상속을 표현할 수 있음.

에러 핸들링

try <expression>

try parenless fn은 전달받은 값의 필드에 error 필드가 있으면("error"가 아님에 유의) 즉시 해당 값을 반환하고 '탈출'함.

try 가 함수로서 먼저 호출되면(try(err) <expression>) 해당 값을 호출하여 에러 값을 전달하고 반환된 값에 error 필드가 있으면 즉시 해당 값을 반환함.

위 '탈출'을 구현하는 메인 primitive는 bail 빌트인 함수로 리턴값을 "caller의 caller"에게 전달함.

pub let try = {
    [meta.call] = fn(e) {
        fn result {
            if result.has(error) {
                bail {
                    [error]: e,
                    [error.source]: result[error],
                    [error.traceback]: std.debug.traceback(),
                }
            }
            result
        }
    }
    [meta.parenless_call] = fn result {
        if result.has(error) {
            bail error;
        }
        result
    }
};

일반적인 exception과 다른 점은 언와인딩의 횟수가 최대 2회로 고정되어 있다는 것임. try-catch를 하는 대신에 fn() { /* ... */ }으로 감싸서 간단하게 opt out할 수 있다는 장점이 있음.

try 가 두 번째 값을 가질 경우 원본 에러는 error.source에 전달되며 추가적으로 error.traceback 필드가 생김. try parenless fn이 오류를 전파할 때 적절히 값을 추가해 줌.

Rust 호환성

  • type.booleanbool과 호환됨
  • type.integeri64와 호환됨
  • type.floatf64와 호환됨
  • type.function은 Rust에서 적절한 shim을 proc-macro로 붙여서 부를 수 있음.
  • Rust의 fn(T1, T2, ...) -> RT<N>, R이 모두 호환된다면 Khala에서 부를 수 있음(parened 함수로서).
    • 특수화: Rust의 fn(T1, T2, ...) -> impl Future<Output=R>는 비동기 함수로서 자동적으로 await 됨.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment