5P by GN⁺ 6시간전 | ★ favorite | 댓글 1개
  • Zig는 Rust와 비슷한 중괄호 기반 문법을 기반으로 하지만, 더 단순한 언어 의미와 세련된 문법 선택으로 개선함
  • 정수 리터럴은 모든 타입이 comptime_int로 시작해 할당 시 명시적으로 변환되며, 문자열 리터럴\\ 기반의 간결한 원시 문자열 표기법을 사용함
  • .x = 1 형태의 레코드 리터럴은 필드 쓰기를 쉽게 검색 가능하게 하며, 모든 타입은 접두사 표기법으로 일관성 있게 표현됨
  • and·or를 제어 흐름 키워드로 사용하고, if·loop 구문은 선택적으로 중괄호를 생략할 수 있으며 포맷터가 안전성을 보장함
  • 네임스페이스 없이 모든 것을 표현식으로 처리해 타입·값·패턴 문법을 통합하고, 제네릭·레코드 리터럴·내장 함수(@import, @as 등)를 간결하게 활용함

개요

  • Zig는 Rust와 유사한 외형을 가지지만 더 단순한 언어 구조를 채택
  • 문법 설계에서 grep 친화성, 구문 일관성, 불필요한 시각적 잡음 감소에 집중

정수 리터럴

const an_integer = 92;  
assert(@TypeOf(an_integer) == comptime_int);  
  
const x: i32 = 92;  
const y = @as(i32, 92);  
  • 모든 정수 리터럴은 comptime_int 타입
  • 변수에 할당 시 명시적으로 타입을 지정하거나 @as를 사용해 변환
  • var x = 92; 형태는 작동하지 않으며 명시적 타입 필요

문자열 리터럴

const raw =  
    \\Roses are red  
    \\  Violets are blue,  
    \\Sugar is sweet  
    \\  And so are you.  
    \\  
;  
  • 각 행이 개별 토큰이라 들여쓰기 문제가 없음
  • \\ 자체를 이스케이프할 필요 없음

레코드 리터럴

const p: Point = .{  
    .x = 1,  
    .y = 2,  
};  
  • .x = 1 형식은 읽기/쓰기 구분에 유리
  • .{} 표기는 블록과 구분하면서 결과 타입으로 자동 변환

타입 표기법

u32        // 정수  
[3]u32     // 길이 3 배열  
?[3]u32    // null 가능 배열  
*const ?[3]u32 // 상수 포인터  
  • 모든 타입은 접두사(prefix) 표기
  • 역참조는 접미사 표기(ptr.*)

식별자

const @"a name with space" = 42;  
  • 키워드 충돌 방지 또는 특수 이름 지정 가능

함수 선언

pub fn main() void {}  
fn add(x: i32, y: i32) i32 {  
    return x + y;  
}  
  • fn 키워드와 함수명이 붙어 있어 검색이 용이
  • 반환 타입 표기에 ->를 쓰지 않음

변수 선언

const mid = lo + @divFloor(hi - lo, 2);  
var count: u32 = 0;  
  • constvar 사용
  • 타입 표기는 이름: 타입 순서

제어 흐름: and/or

while (count > 0 and ascii.isWhitespace(buffer[count - 1])) {  
    count -= 1;  
}  
  • and, or는 제어 흐름 키워드
  • 비트 연산에는 &, | 사용

if 문

.direction = if (prng.boolean()) .ascending else .descending;  
  • 괄호 필수, 중괄호 선택
  • zig fmt가 안전한 포맷 보장

반복문

for (0..10) |i| {  
    print("{d}\n", .{i});  
} else @panic("loop safety counter exceeded");  
  • for, while 모두 else 절 지원
  • 반복자와 요소명을 직관적으로 배치

네임스페이스와 이름 해석

const std = @import("std");  
const ArrayList = std.ArrayList;  
  • 변수 섀도잉 금지
  • 네임스페이스와 글롭 임포트 없음

모든 것은 표현식

const E = enum { a, b };  
const e: if (true) E else void = .a;  
  • 타입·값·패턴 구문을 통합
  • 타입 위치에 조건식을 둘 수 있음

제네릭

fn ArrayListType(comptime T: type) type {  
    return struct {  
        fn init() void {}  
    };  
}  
  
var xs: ArrayListType(u32) = .init();  
  • 제네릭은 함수 호출 구문(Type(T))으로 표현
  • 타입 인자는 항상 명시

내장 함수

const foo = @import("./foo.zig");  
const num = @as(i32, 92);  
  • @ 접두사로 컴파일러 제공 기능 호출
  • @import는 파일 경로를 명확히 표시
  • 인자는 반드시 문자열 리터럴이어야 함

결론

  • Zig 문법은 작은 선택들의 집합이 모여 읽기 좋은 언어를 만든 사례
  • 기능 수를 줄이면 필요한 문법도 줄어들고, 구문 간 충돌 가능성도 감소
  • 기존 언어의 좋은 아이디어를 차용하되, 필요할 때는 과감히 새 문법을 도입
Hacker News 의견
  • 이 글은 문법 설계에서 발생하는 여러 트레이드오프를 깊이 있게 다루고 있고, Zig의 문법이 가진 미니멀리즘과 일관성, 그리고 무자비할 정도로 가독성에 집중하는 점이 정말 인상적임을 느꼈음. 이건 추상적인 아름다움이 아니라, 산업적 용도에서 놀랄 요소가 없는 '브루탈리즘'이라는 점이 마음에 듦. 이런 균형 잡힌 문법 설계는 정말 드물고, Zig가 잘 해냈다고 생각함

    • 아티클에서 에러 핸들링에 대한 언급이 없어 아쉬움. Zig의 try/catch 방식은 굉장히 훌륭해서, 여러 언어 중 가장 좋아하는 에러 핸들링 방법임. 이 부분도 소개되었으면 더 좋았을 것 같음

    • '표면적으로 아름다운 가독성'이 아닌, 추상화를 통해 얻을 수 있는 일관된 아름다움이 Zig의 진정한 매력임. S식과 M식 비유처럼, 일반적인 케이스에서의 좋은 접근이 여러 예외적 상황을 위한 특수한 설계보다 장기적으로 더 나은 경우가 많음. C++처럼 각종 예외 케이스를 추가하면 결국 모든 규칙을 외워야 하는 부담만 커짐. 언어 설계에서는 단순성과 일관성을 추구하면, 결국 복잡함은 사용자가 감당하게 되는 'Turing tarpit'에 빠질 수 있으니, 일반적인 규칙에서 특수 케이스가 자연스럽게 해결되는 접근이 중요함. XKCD의 New Pet 코믹에서도 이런 예시를 볼 수 있음

    • 인상 깊었던 예시가 있다면 공유해 줄 수 있을지 궁금함

  • Zig가 Rust처럼 '이름:타입' 형식의 타입 명시 방식을 사용하는 부분에 대해, 오히려 타입이 먼저 나오는 전통적 방식이 더 마음에 듦. 변수의 선언을 다시 확인할 때 가장 궁금한 건 그 변수의 타입인데, 이걸 빠르게 찾지 못하면 불편함. 특히 Rust에서는 let mut처럼 불필요하게 반복되는 요소가 많아 오히려 번거롭고, C, C++처럼 타입이 먼저 오는 것도 좋음. 실제로는 타입 추론이 필요한 곳에만 최소한으로 쓰는 게 이상적이라고 생각함

    • let 키워드가 사실 선언문임을 분명히 해 주기 때문에 필요한 부분도 있음. 그렇지 않으면 C++의 애매한 구문 파싱 문제를 겪을 수 있음

    • 나 역시 항상 변수 타입을 먼저 확인하려 하기 때문에 타입이 앞에 오는 방식을 선호함. 구문 분석기 입장에서는 이름을 먼저 처리하는 게 편리하고, TypeScript는 자바스크립트와의 호환성 때문에 이런 구조를 채택했다는 점을 이해함. 결국 중요한 건 사용이 쉬운 표준 라이브러리라고 생각함. 타입 시스템을 과하게 악용하는 예시처럼, 굳이 모든 상태를 타입으로 표현하기보다 의도를 분명하게 전달하는 것이 더 중요함

    • 코드에서 변수 타입을 확인하려고 다시 올라가지만, 오히려 타입이 먼저 나오면 내가 찾으려는 변수 선언을 찾기 더 어려워짐. 타입 네임이 제일 앞에 오고, 그 길이가 가변적이기 때문에 시선을 좌우로 반복해서 움직여야 해서 비효율적으로 느껴짐

    • 대부분의 경우 에디터에서 마우스를 올리면 타입 정보를 바로 보여주기 때문에 코드에서 타입 위치가 그리 중요하지 않을 수 있음. Rust가 verbose한 이유는 파싱 애매함을 피하려는 구현적인 측면이 큼. C, C++처럼 타입이 먼저 나오면 특정 이름으로 선언된 변수를 grep으로 쉽게 찾기 어렵고, return type을 앞에 두는 스타일은 템플릿 때문에 도입된 것이지만 경우에 따라 코드를 더 쉽게 읽고 찾게 해줌

    • 나 개인적으로는 파스칼 스타일의 타입 명시 방식을 더 선호함. 타입 추론을 할 때에도 별도의 'auto' 같은 우회적 기능이 필요 없고, 파싱 관점에서도 덜 모호함. 'MyClass x'에서 MyClass가 타입인지 변수명인지 바로 알기 어렵기 때문에 이런 모호성을 줄여줌

  • Zig의 raw/multiline string(멀티라인 문자열) 문법에 대해 \를 여러 번 써야 하는 방식이 너무 혼란스럽고 극단적으로 느껴짐

    • 파이썬, C++, Rust 등에서 멀티라인 문자열을 포맷해 본 적이 있다면 그 불편함을 이해할 것임. 들여쓰기가 문자열 내용에 포함되는 문제 때문에 항상 고민이 있고, YAML처럼 들여쓰기 제거 모드를 가진 경우는 오히려 혼란을 가중함. Zig 방식은 들여쓰기에 관한 한 굉장히 명확함

    • 처음엔 이 문법이 너무 불편했는데, Zig를 쓰다 보면 점점 익숙해지고 오히려 장점이 보임. Zig는 참신하게도 처음 접할 때 불호가 있을 수 있지만, 실제 써보면 장점을 깨닫게 됨

    • 사실 미친 문법이 아니라, 이 복잡한 문제(멀티라인 문자열 안에 또 멀티라인 문자열을 안전하게 넣는 문제)를 해결하기 위한 미친 문제임. Zig에선 별도 이스케이프도 필요 없고 들여쓰기 걱정도 안 해도 되는 점이 좋음

    • Kotlin의 trimIndent, Go나 Java의 텍스트 블록, 그리고 특히 Go의 backtick raw string 방식이 나에겐 더 매끄럽게 느껴짐. Zig에선 \ 때문에 오히려 @embedFile 방식으로 우회해서 사용함

    • 비주얼적으로 \가 마음에 들지는 않지만, 멀티라인 리터럴 및 들여쓰기 문제를 깔끔하게 해결하는 방법이라고 생각함. 함수 없이 이 문제를 해결하는 언어를 딱히 알지 못함

  • Zig 문법이 산만하게 느껴짐. @TypeOf 같이 @로 시작하는 구문이나 .{.x} 같은 초기화 문법이 어색하게 다가옴. Zig 사용에 능숙하지 않아서 그런 걸 수도 있지만, 전체적으로 코드를 읽기 어렵다는 인상이 있음

    • Odin의 문법은 훨씬 미니멀하고 잘 다듬어져 있어서 선호함. Zig는 다소 산만한 느낌이 듦

    • .은 Zig에서 추론 타입을 위한 플레이스홀더 역할임. 예를 들어 다음과 같이 객체를 초기화할 수 있음

      const p = Point{ .x = 123, .y = 234 };
      

      혹은 타입 추론을 명시하고 싶다면

      const p: Point = .{ .x = 123, .y = 234 };
      

      함수 인자에서도 타입을 생략할 수 있어서 더 간결함. Rust에서는 이러한 상황에서 명시적으로 타입을 써야 함

      takePoint(Point{ x: 123, y: 234 });
      

      중첩 구조체 초기화에서도 Zig의 추론 방식이 훨씬 유용함. 모든 곳에 명시적으로 타입을 적어야 하는 Rust는 금방 코드가 산만해질 수 있음. 그럼에도 선행 dot 표기를 빼는 편이 더 편리하다고 생각하지만, 파서 구현 단순화 때문에 유지하고 있는 듯함. x: 123 혹은 .x = 123 표기법은 각각 JS, C99에서 차용한 것임. 개인적으로 둘 다 자주 써서 어색한 건 아니라 생각함

  • C# 11의 raw string literal 방식이 훨씬 선호됨. 첫 줄 들여쓰기를 기준으로 나머지 줄에서 들여쓰기를 자동으로 맞춰줌. 또한 중괄호를 문자로써 쓸 수도 있음. $가 여러 번 등장하면 중괄호를 완전히 값으로 처리함

    string json = $"""
       <h1>{title}</h1>
       <article>
         Welcome to {sitename}.
       </article>
       """;
    string json = $$"""
       <h1>{{title}}</h1>
       <article>
         Welcome to {{sitename}}, which uses the <code>{sitename}</code> syntax.
       </article>
       """;
    
    • (C# raw string literal 기능의 작성자로서) 실제로는 마지막 """ 라인의 들여쓰기가 기준이고, 첫 줄도 들여쓰기 할 수 있게 되어 있음. 이 기능을 좋아해 주어서 기쁘고, 좋은 기능이라고 자부함
  • Zig의 문법도 좋지만, Go처럼 세미콜론이나 ':' 없이도 충분히 깔끔하게 쓸 수 있다는 점에서 'lovely'까지는 아니라고 생각함. 굳이 비교한다면 Rust보단 많이 개선된 건 맞지만, Go도 충분히 우수함

    • 오히려 Go처럼 지나치게 미니멀한 문법은 읽을 때 해석하기 더 어려운 경우가 있음. 코드를 직접 쓰는 시간보다 읽는 시간이 많아서, 필요 이상의 간결함은 오히려 실수를 부르고 디버깅을 어렵게 만듦. CoffeeScript, J처럼 너무 축약된 문법이 대표적인 예임

    • 문법 요소를 뺀다고 해서 더 나은 문법이 되는 건 아니라고 생각함. 만일 그랬다면 모두가 Lisp처럼 쓸 것이고, scriptio continua(공백 없는 고대 필기 방식)처럼 글도 썼을 것임. scriptio continua 위키피디아 참조

  • Zig가 전체적으로 만족스럽지만 다음 문제들이 아쉬움

    • 블럭의 반환 값을 지정하기가 어려움. Rust처럼 마지막 표현식을 반환값으로 자동 인식하면 좋겠지만 Zig에선 label 등을 써야 해서 번거로움
    • 옵셔널 타입의 체이닝(예: a?.b?.c)이 불가능함. 모나딕 타입 지원이 있으면 더 일반적인 체이닝이 가능할 텐데, 아직 부족함
    • 람다 함수 지원이 없음. 이미 반복문이나 catch 블록 같은 곳에서 함수 블록을 쓸 수 있는데, 람다 지원까지 되면 더 유연해질 것 같음
  • 타입 명칭에서 void를 쓰는 것에 대해선, 사실 void는 타입 이론에서는 'unit'의 역할이 아니라 값이 없는 'uninhabited' 타입을 의미함. 전통적으로 '()'나 'unit'이 한 멤버를 가진 타입임. void는 abort 같은 함수의 반환타입임

    • C, C++에서는 void가 그런 대로 잘 쓰이고 있어서 많은 시스템 프로그래머들에게 익숙함. 형식 이론에서의 용어 논쟁은 실사용엔 무의미하다고 생각함. Zig에 오는 많은 사람들이 C, C++ 배경을 가지고 있기 때문에 void로 충분히 괜찮음

    • abort는 Rust의 ! 타입처럼 '도달 불가' 상태를 위한 타입임. void는 차라리 unit이나 ()에 더 가깝고, 값이 존재하지 않는 타입임. 재밌는 트릭으로, TypeScript에서는 void를 제네릭 제약조건에 쓰면 해당 파라미터를 옵셔널로 만들 수 있음

    • void 타입은 매우 오랜 전통이 있고, ALGOL 68까지 거슬러 올라감. 거기선 VOID 타입이 하나의 멤버(EMPTY)만 가진 타입으로 정의되어 있음

  • "Zig에 람다가 없다"는 점이 놀라움. C++에서는 람다를 거의 매 everywhere 쓰는데, 그럼 배열 sort 등에서 comparator는 어떻게 정의하는지 궁금함

    • 보통 함수 선언을 따로 한다는 점에서, Zig는 그 부분이 불편하다고 생각함

    • 익명 구조체와 거기에 포함된 함수를 인라인으로 참조할 수 있음. 사실 람다에서 주로 쓰는 캡처 기능이 Zig에는 없지만, 컨텍스트 파라미터(대개 구조체)를 넘기는 방식으로 대체할 수 있음

    • 기본적으로 C와 동일하게, 구분 함수 선언 후 그 포인터를 정렬 함수에 전달하는 방식임

  • "문법은 중요하지 않다"고 말하지만, 실제로는 "문법은 중요하지 않으니 내가 선호하는 방식대로 쓰자"는 식임. 나 역시 Rust/Zig/Go처럼 C 계열에서 파생된 문법이 익숙하고, Haskell/OCaml처럼 공백으로 함수 호출을 구분하는 스타일은 아직 생소해서 대중화에 방해된다고 봄. Rust의 성공처럼, 함수형 프로그래밍의 '시금치'를 시스템 언어라는 '브라우니'에 잘 녹여 준 점을 타 언어도 참고해볼 만함

    • 문법이 중요하지 않다고 하는 말에 동의하지 않음. 결국 문법은 사용자가 언어와 상호작용하는 메인 인터페이스임. 어떤 언어를 읽을 때마다 문법 요소가 무의식적으로 더 크게 부각됨

    • C 계열 문법을 가진 함수형 언어를 원한다면 Gleam을 추천함: gleam.run
      코드도 매우 예쁨

      fn spawn_greeter(i: Int) {
       process.spawn(fn() {
        let n = int.to_string(i)
        io.println("Hello from " <> n)
       })
      }
      

      Reason도 추천할 만함. OCaml 기반이지만 C 계열 문법을 가짐: reasonml.github.io