1P by GN⁺ 20시간전 | ★ favorite | 댓글 1개
  • C 코드 컴파일과 크로스 컴파일 기능을 기본 제공하는 Zig 컴파일러는, 45년 경력의 저자가 경험한 언어 중 가장 놀라운 언어임
  • 컴파일 타임 실행, 임의 비트 크기 변수, 테스트 블록 환경 등 독특한 기능으로 단순한 C/C++ 대체를 넘어 완전히 새로운 프로그래밍 방식 제공
  • 타입 추론을 통한 변수 선언, 익명 구조체, 레이블 브레이크 등 간결하고 명확한 문법으로 빠른 학습 가능
  • 테스트 블록을 통한 독립적 모듈 테스트와 @breakpoint 내장 함수로 최적화된 코드 디버깅 지원
  • 비트 필드와 비트 연산을 활용한 저수준 프로그래밍 지원으로 효율성과 견고성을 동시에 달성하며, 인터프리터 언어의 장점을 컴파일 언어에 통합

서문

  • 45년 경력 중 Zig만큼 놀라운 언어는 없었음
    • Zig는 단순한 새로운 언어가 아니라, 프로그래밍 방식을 근본적으로 바꾸는 도구
  • C나 C++을 대체하는 수준으로만 보는 것은 큰 과소평가
  • 이 글의 목적은 Zig의 간단하면서도 매력적인 기능을 소개하고, 프로그래머가 빠르게 시작할 수 있도록 돕는 것
  • 산업계에서 Zig의 수용성에 영향을 미치는 더 많은 기능이 존재함

Zig 컴파일러

  • C 코드 컴파일과 크로스 컴파일 기능을 별도 설정 없이 기본 제공하여 산업계에 큰 영향을 줌
  • 설치는 Zinglang 다운로드 페이지에서 프로세서/OS별 컴파일러를 다운로드 후 압축 해제하여 원하는 디렉토리에 복사
    • Windows 10에서는 x86_64 zip 파일을 "Program Files"에 복사하고, 루트 디렉토리 이름을 "zig-windows-x86_64"로 변경하여 버전 업데이트 시 Path 환경 변수 수정 불필요
    • Path 환경 변수에 루트 디렉토리 경로 추가 후 CLI 모드에서 컴파일러 사용 가능
  • "Hello World!" 프로그램 빌드는 공식 사이트의 "Getting Started" 섹션 참조 권장

주요 개념과 명령

변수 선언

  • 변수 선언은 접근성(pub 또는 생략), var/const, 변수명으로 구성된 첫 번째 부분, 타입 선언인 두 번째 부분, 초기화인 세 번째 부분으로 구성
    • 첫 번째와 세 번째 부분만 필수이며, 타입은 초기화 값으로부터 추론 가능
    • 예: var sum : usize = 0;
  • pub 없이 선언된 변수는 모듈 내부에서만 접근 가능 (C의 static 변수와 유사)
  • pub 변수 선언은 권장되지 않으며, pub 함수는 최소화하여 결합도를 낮추고 응집도를 높이는 것이 권장됨

구조체, 익명 구조체, 테스트 블록

  • .{}로 둘러싸인 익명 구조체 리터럴은 다른 구조체 요소 초기화 또는 요소가 초기화된 새 구조체 생성에 사용
  • .{ }는 빈 익명 구조체 리터럴
  • struct { } 형태는 구조체 선언
  • 테스트 블록은 실행 파일 없이 컴파일 및 테스트 실행 가능

비트 필드

  • 비트 필드는 packed struct에서 특정 크기의 타입을 가진 필드로 선언
  • 포인터는 특정 비트 필드를 가리킬 수 있음

For 루프

  • Zig 문법은 C보다 명확하나, [0..8]이 아닌 열린 구간 [0..9) 사용
  • 루프 변수 i의 타입 선언, 초기화, 테스트, 증가가 자동으로 처리됨

배열

  • [_]는 크기를 알 수 없는 배열을 정의하며, 요소 타입과 초기화가 뒤따름
    • 예: var grid = [_]u8{0} ** 81;는 81개의 u8 요소를 0으로 초기화
    • 배열 크기는 초기화 반복 인자로부터 추론됨
  • 테스트 환경에서 배열 요소를 순회하며 합산 가능
  • for 루프의 | 사이에 선언된 변수는 배열 요소와 동일한 타입으로 자동 가정
  • usize는 플랫폼의 자연 부호 없는 정수 (64비트에서 u64, 32비트에서 u32)

다항목 포인터

  • 배열 포인터가 포인터 산술 연산을 사용하려면 [*]const i32처럼 명시적으로 다항목 포인터로 선언 필요
  • 배열이 const여도 포인터는 var로 선언 가능

포인터 역참조

  • 개별 배열 위치의 주소가 할당된 포인터는 포인터 산술로 업데이트 불가
  • 포인터 역참조는 ptr.* 사용

레이블 브레이크

  • 컴파일 타임에 배열 초기화 등 다양한 작업 수행 가능
  • 레이블 브레이크는 블록 이름 뒤에 :를 붙이고, break로 블록에서 값 반환
    • 예: break :init m;
  • 0..는 0부터 시작하는 무한 범위
  • for 루프에서 변수들은 자동 초기화 및 증가되며, 배열 마지막 위치 처리 후 루프 종료
  • 배열은 undefined로 명시적으로 초기화하지 않을 수 있음

Zig의 함수

  • 함수는 fn으로 선언하며 기본적으로 static (파일 내부에서만 사용)
    • pub fn으로 선언 시 다른 파일에서 import 가능
  • 함수는 "inlined" 가능
  • 함수 포인터는 const가 앞에 오고 함수 프로토타입이 뒤따름

Zig의 객체지향 프로그래밍

  • 구조체는 함수를 가질 수 있음
  • 스택 예제에서 최대 81개 요소(StkNode 타입) 저장 가능
  • ++ 및 -- 연산자는 Zig에 존재하지 않으며, += 및 -= 사용
  • 스택 포인터는 stk 배열의 인덱스로 사용되는 정수
  • 포인터 self는 매개변수로 명시적으로 전달되지 않으며, 함수가 호출되는 스택 인스턴스의 포인터로 간접 가정
    • stack.pop()처럼 호출 시 selfstack에 대한 포인터 (Java/C++의 this와 유사)
  • init() 함수는 스택 생성자
  • poppush 함수는 "inlined"

Zig 프로그램 빌드 및 실행

실행 파일 빌드

  • 실행 파일 생성을 위해 프로그램 진입점을 나타내는 main 함수 필요
  • 간단한 프로그램은 main 함수를 같은 파일에 포함 가능
  • 모듈 독립 디버깅을 위해 파일 끝에 main 함수를 삽입한 후, 디버깅 완료 후 주석 처리 가능
  • 컴파일 명령: zig build-exe -O ReleaseFast program.zig

모듈의 테스트 블록 실행

  • Zig의 가장 우수한 기능으로, 테스트와 프로토타이핑에 사용
  • 테스트 블록은 test "message" {로 시작하여 }로 끝남
    • "message"는 테스트 실행 시 표시될 문자열
  • 테스트 블록은 실행 파일과 독립적으로 실행되며, 최종 실행 파일은 테스트를 실행하지 않음
  • 테스트 명령: zig test module.zig
  • example.zig의 테스트 블록은 setprint 함수를 테스트하며, set은 십진수 문자열을 매개변수로 받고, print는 "Input Grid" 헤더를 출력한 후 grid를 출력

Zig의 출력

  • std.debug.print 문은 표준 Zig 라이브러리 stddebug.zig에 있는 print 함수 호출
  • 첫 번째 매개변수는 형식 문자열, 두 번째는 표시할 변수 리스트를 포함하는 익명 구조체
  • 형식이 없는 경우 구조체는 비어 있음
  • 기본적으로 stderr에 표시
  • C의 printf와 달리 Zig은 리터럴 문자열과 변수 리스트를 컴파일 타임에 처리 가능

실행 파일 디버깅

  • 디버거 사용은 통합 디버거가 있는 IDE(Eclipse, IntelliJ IDEA) 또는 통합 개발 킷(w64devkit) 외에는 간단하지 않음
  • 심볼 통합은 코드를 비대화하고 Debug 모드 컴파일을 요구하여 효율성이 현저히 낮은 실행 코드 생성
  • Zig은 이러한 문제를 피하기 위한 편리한 해결책 제공

@breakpoint 내장 함수

  • 소스 코드에 @breakpoint();를 삽입하여 디버거에서 실행 시 해당 지점에서 프로그램 중지
  • 심볼 없이 최적화된 Zig 코드를 디버깅할 수 있는 유용한 기능
  • @breakpoint(); 직전에 std.debug.print를 사용하여 추적할 변수를 출력하면 해당 순간의 변수 값 확인 가능
  • debug_example.zig 예제에서 set 함수 내부에 grid와 변수들을 출력하는 코드와 @breakpoint(); 삽입
  • 빌드 명령: zig build-exe debug_example.zig
  • gdb 같은 디버거로 debug_example.exe 호출 후 r 명령으로 프로그램 실행
  • c 명령으로 계속 진행하며 grid 내용과 변수 추적
  • Enter 반복 입력으로 계속 진행하면, grid의 값들이 example.zig의 테스트 블록과 일치함을 확인 가능

Zig의 저수준 프로그래밍

행렬 표현

  • 십진수 숫자는 표준 u8 정수로 행렬에 저장
  • 입력 grid는 문자열 형식이지만 ASCII 문자는 내부적으로 u8 정수로 변환
  • 숫자 저장은 81개 위치의 배열 grid에 선형으로 한 줄씩 구성: var grid = [_]u8{0} ** 81;
  • grid 정확성 검증을 위해 각 줄과 열로 요소 접근 필요
  • 9개 위치의 포인터 배열 생성, 각 포인터는 각 줄의 시작을 가리킴
  • 레이블 브레이크를 사용하여 코드 블록에서 값 반환: break :fill9x9 m;로 matrix를 m으로 초기화
  • 요소 접근 표기: element = matrix[i][j]

십진수 숫자를 비트로 표현

  • 정수 십진수 숫자 i를 정수 code로 대체하는 핵심 개념
    • i ∈ [1,9] → code = 2ⁱ⁻¹
    • i = 0 → code = 0
  • code의 유일한 비트가 1로 설정되는 위치는 i-1 (i가 1~9 사이일 때), 그렇지 않으면 모든 비트가 0
  • 각 숫자에 대한 code 값 표 제공 (1→1, 2→2, 3→4, ..., 9→256)

Zig에서 code 계산

  • c가 0이 아닐 때만 왼쪽 시프트 연산자로 code 값 계산: code = @as(u9,1) << (c-1);
  • Zig에서 상수는 연산이 컴파일되고 결과가 변수에 할당되려면 적절한 크기가 필요
  • code는 u9 타입으로 선언 (최대값 256은 최소 9비트 필요)
  • Zig은 임의 비트 크기 변수 보유 가능
  • 내장 함수 @as1 상수를 u9 타입으로 캐스팅

비트 필드를 사용한 grid 표현

줄별 비트 필드 grid

  • 배열 lines는 각 줄을 9비트 정수로 표현하여 전체 grid를 미러링: var lines = [_]u9{0} ** 9;
  • i로 배열 접근 시, 특정 숫자가 해당 줄에 이미 있는지 비트 AND 연산(&)으로 확인: lines[i] & code
  • 연산 결과가 0이면 숫자가 줄 i에 아직 없음, 그렇지 않으면 중복

열별 비트 필드 grid

  • 배열 columns는 각 열을 9비트 정수로 표현하여 전체 grid를 미러링: var columns = [_]u9{0} ** 9;
  • j로 배열 접근 시, 특정 숫자가 해당 열에 이미 있는지 비트 AND 연산으로 확인: columns[j] & code
  • 연산 결과가 0이면 숫자가 열 j에 아직 없음, 그렇지 않으면 중복

스도쿠 규칙

  • 빈 스도쿠 grid에 새 숫자를 삽입할 때, 새 요소를 포함하는 전체 줄, 열, 셀에 이미 존재하지 않아야 함
  • 셀은 굵은 선으로 구분된 9개의 3x3 grid 각각
  • 9x9 grid의 각 특정 요소는 해당 요소를 포함하는 고유한 줄, 열, 셀을 가짐
  • 예제 grid에서 첫 번째 셀은 3, 5, 6, 8, 9를 포함하며, 1, 2, 4, 7이 누락
  • 배열 linescolumns는 줄과 열의 중복 검사를 처리
  • 셀의 중복 검사를 위해 새 배열 필요

셀별 비트 필드 grid

  • 배열 cells는 각 셀을 9비트 정수로 표현하여 전체 grid를 미러링: var cells = [_]u9{0} ** 9;
  • cells를 3x3 행렬로 접근하면 더 쉬움
  • 9x9 행렬에서 수행한 것과 유사하게 배열 cell 채우기
  • 원래 9x9 grid의 요소 줄과 열에서 cell 행렬의 줄과 열 결정 필요
  • 정수 나눗셈은 매우 느리므로, 배열 cindx = [_]usize{ 0,0,0, 1,1,1, 2,2,2 };를 사용하여 나눗셈 결과 제공
  • 9x9 grid의 요소 줄 i와 열 j로 행렬 접근 시, 특정 숫자가 요소의 셀에 이미 있는지 비트 AND 연산으로 확인: cell[cindx[i]][cindx[j]] & code
  • 연산 결과가 0이면 숫자가 셀에 아직 없음, 그렇지 않으면 중복

요소 중복 테스트

  • 동일한 줄, 열, 셀의 모든 이전 요소를 비트 OR(|)로 결합한 후, 요소의 code와 비트 AND 수행하여 요소 중복 검증 완료
if (((lines[i]|columns[j]|cell[cindx[i]][cindx[j]])&code) != 0) {  
    unreachable;  
}  
  • 결과가 0이면 요소가 아직 줄, 열, 셀에 존재하지 않음
  • 결과가 0이 아니면 프로그램은 unreachable 명령 실행으로 중지
  • Zig에서 실행 오류를 명시적으로 나타내는 가장 간단한 방법
  • 실제 코드는 오류 발생 위치의 세부 정보도 출력
  • 예: 입력 문자열의 첫 '8' 바로 다음 '0'을 '5'로 교체하면, 열 1의 줄 3에 이미 5가 있어 오류 발생

데이터 구조 업데이트

  • set 함수에서 이중 for 루프가 줄별로 상호 작용하여 입력 문자열 s의 각 새 요소를 grid에 복사
    • 변수 k는 문자열 s의 새 입력 문자 인덱스 유지
  • 문자는 '0'을 빼서 u4(변수 c)로 변환
  • grid에 삽입할 새 요소가 0이 아니면(c != 0), 왼쪽 시프트 명령으로 계산된 code를 각 미러 grid에 복사
    • 해당 미러 grid와 비트 OR(|=) 수행:
lines[i] |= code;  
columns[j] |= code;  
cell[cindx[i]][cindx[j]] |= code;  
  • c 값이 1~9 사이인지 명시적으로 테스트할 필요 없음 - 시프트 연산 실행 시 오버플로가 발생하기 때문
  • 예: 입력 문자열의 첫 '8' 바로 다음 '0'을 ':'로 교체하면 실행 오류 발생
  • 같은 '0'을 '/'로 교체해도 유사한 실행 오류 발생
  • 프로그램은 값이 1~9 사이, 즉 입력 grid가 십진수만 포함할 때만 작동
  • 웹의 많은 스도쿠 grid는 '0'을 '.'로 표현하므로, set 함수에 if (s[k] == '.') c = 0; 라인 존재
  • 이는 c 값이 0이므로 시프트 연산을 편리하게 우회

프로토타이핑과 견고성

  • 위 두 섹션의 강제 오류는 Zig의 중요한 기능 시연
  • 하나는 Zig의 견고성 - 시프트 연산의 경우 잘못된 동작이 허용되지 않으며 실행 시간에 포착됨
  • 모든 노력이 효율성을 향한 것처럼 보이지만, 성능이 견고성과 교환된 전형적인 사례
  • C에서는 시프트 연산이 비트를 잃어도 프로그래머의 문제이며, 이는 특정 어셈블러 명령의 더 나은 성능으로 변환됨
  • 또 다른 기능은 테스트 블록을 프로토타이핑에 사용할 가능성
  • 응용 가능성은 무수히 많으며, 보여진 응용은 오류 발생 시 특정 상황을 디버깅하는 것뿐
  • 이러한 기능만으로도 프로그래밍 언어에서 매우 드문 놀라운 능력 제공, 특히 컴파일된 프로그래밍 언어에서

결론

  • Zig는 C 호환성, 크로스 컴파일, 간단한 설치라는 세 가지 핵심 요소로 구성
  • 이러한 특성은 시스템 프로그래밍 언어의 새로운 표준으로 자리 잡을 가능성을 보여줌
  • 인터프리터 언어에서만 발견되던 많은 장점이 더 나은 성능을 제공하기 위해 점차 컴파일 언어로 이동
  • Zig은 컴파일 타임 실행 개념으로 인터프리터 언어와의 유사성이 매우 두드러짐
  • 이는 Zig을 특별히 다르고 강력하게 만드는 동시에 이해하기 어렵게 만들기도 함
Hacker News 의견
  • 이 글은 처음엔 “Zig은 단순한 언어가 아니라 완전히 새로운 프로그래밍 방식”이라고 주장하지만, 실제로는 Zig만의 고유한 기능을 거의 다루지 않음
    타입 추론, 익명 구조체, labeled break 등은 이미 오래전부터 다른 언어들에 존재했음
    진짜 독특한 건 comptime인데, 이 부분은 전혀 언급되지 않음
    Lisp 매크로처럼 완전히 새로운 개념은 아니지만, Zig이 이를 제네릭 대신 사용하는 방식은 흥미로움
    하지만 글의 주장은 과장된 느낌이 강함

    • Rust도 마찬가지로 “완전히 새로운 방식”이라 할 수 있음
      Rust는 코드 실행 시점을 명확히 표현할 수 있고, 전체 코드 공간을 탐색하는 쿼리 엔진 같은 설계가 인상적임
    • D 언어는 이미 2007년부터 컴파일 타임 함수 실행을 지원했음
      D 문서 링크 참고
      const-expression이면 자동으로 실행됨
    • C/C++을 하나로 묶는 건 이제 의미가 없음
      Java/Scala처럼 완전히 다른 언어이기 때문임
    • comptime은 마법적인 발명이라기보다 메타프로그래밍의 현대적 버전
      Zig은 C++ 템플릿보다 깔끔하지만, 혁명적이라기보단 실용적인 대안 정도로 느껴짐
      개인적으로는 Rust 때처럼 과도한 열광이 이해되지 않음
    • “완전히 새로운 방식”이라는 문구를 보고 LISP이나 Prolog 같은 새로운 패러다임을 기대했는데, 실제로는 그런 게 없었음
      Zig 문서를 다 읽었는데도 놀랄만한 게 없어서 당황했음
  • Zig의 가장 큰 문제는 에러에 데이터를 붙일 수 없다는 점
    에러는 부가 채널로만 전달돼서 디버깅이 어렵고, 결국 개발자들이 에러 데이터를 생략하게 됨
    관련 이슈 참고
    AccessDenied 같은 단순 코드만으로는 원인을 알기 힘듦

    • matklad의 을 읽었는데, 에러 코드와 진단 정보를 분리하는 접근이 설득력 있었음
      실제로 복잡한 Error 객체를 써도 별도의 진단 채널이 필요할 때가 많음
    • 시스템 언어에서는 에러에 데이터를 붙이는 게 항상 좋은 건 아님
      성능 오버헤드나 시스템 상태 문제 때문에, 상황에 따라 지연 바인딩으로 처리하는 게 더 안전함
      Zig은 이런 정밀성과 결정성을 우선시하는 철학을 가짐
    • Zig에서도 에러 스택 트레이스에 사용자 정의 정보를 넣는 기능이 논의 중임
      관련 이슈 참고
      하지만 진짜 필요한 건 구조적 로깅과 호출 스택 기반의 문맥 추적 기능임
    • std.zon이 좋은 예시로 꼽히며, 커뮤니티에서 다양한 에러 처리 패턴을 모아 표준에 반영하려는 움직임이 있음
    • 에러에 데이터를 못 붙이게 하는 건 오히려 명확한 에러 설계를 유도함
      게으른 개발자가 무조건 데이터를 덕지덕지 붙이는 걸 막을 수 있음
  • “Zig 개발 방식 자체가 새로운 언어 개발 방식”이라는 주장에 공감함
    기능을 신중히 검토하고 불필요한 걸 제거하는 느린 진화 과정이 인상적임

    • 하지만 이런 접근은 Java나 Rust 등에서도 흔함
      Zig만의 독특한 점이 무엇인지 더 구체적으로 듣고 싶음
  • Zig을 PyPI로 설치할 수 있는 게 마음에 듦
    ziglang 패키지pip install ziglang으로 설치하면 바로 사용 가능함
    uvx를 이용해 C 코드를 빌드할 수도 있음

    • Python wheel로 임의의 소프트웨어를 번들링할 수 있어서 이런 설치 방식이 가능함
    • 하지만 이런 접근은 nix보다 불편한 재발명처럼 느껴짐
    • Nim에도 이런 설치 옵션이 있었으면 좋겠음
    • 개인적으로는 pip/uv보다 micromamba나 pixi가 더 나은 패키지 관리 방식이라 생각함
    • AI 도구 덕분에 이제는 어떤 언어든 배우기가 훨씬 쉬워졌음
  • Ada, Object Pascal, Modula-2 같은 언어에서도 이미 존재하던 기능들을 Zig의 “혁신”으로 포장한 점이 아쉬움
    C 스타일 문법으로 다시 포장되니 40년 전 아이디어가 새로워 보이는 현상이 흥미로움

  • 글의 도입부는 좋았지만, 이후엔 단순히 Zig 기능 나열로 끝남
    Zig의 직관적 문법명시적 제어 흐름(defer 등)은 매력적임
    comptime 덕분에 별도의 매크로 문법을 배울 필요도 없음

    • Zig의 진짜 매력은 불필요한 중복이 없는 설계
      모든 구성요소가 자연스럽게 맞물려서, 처음 써도 오래 써온 도구처럼 느껴짐
    • matklad의 Zig 문법 분석 글도 참고할 만함
  • Zig의 for (0..9) 구문은 직관적이지만, 열린 구간이라 종종 헷갈림
    Python의 range(0, 9)처럼 마지막 값 포함 여부를 잊기 쉬움

    • Rust는 0..90..=9로 구분해서 명확함
    • Zig처럼 반열린 구간만 사용하는 일관성이 오히려 오류를 줄임
      구간 크기가 단순히 차이로 계산되고, 역방향 순회도 간단해짐
    • Odin은 0..<5(열린)과 0...5(닫힌)으로 더 명시적으로 구분함
  • Zig의 식별자 규칙이 마음에 들지 않음
    snake_case와 camelCase가 섞여 있어서 어색함
    그래도 빌드 시스템, 메모리 할당자, 컴파일 경험 등은 훌륭함
    Rust를 주로 쓰지만 Zig에 대한 호기심은 계속 있음

    • 나도 비슷함. 개인적으로는 private 함수 네이밍 규칙을 따르지 않음
      C 라이브러리의 접두사 규칙도 마찬가지로 귀찮음
  • Zig의 매력은 어떤 한 기능이 아니라, 실용적인 결정들의 누적
    처음엔 급진적으로 보이던 선택들도, 이해가 깊어질수록 납득하게 됨
    Zig은 호기심 많은 개발자에게 보상해주는 언어

    • Odin으로 작은 게임을 만들어봤는데, 정말 즐거운 경험이었음
  • Zig이 좋은 이유 중 하나는 저수준 시스템 코드의 현실을 인정한다는 점임
    많은 언어들이 미학적 이유로 이런 부분을 외면하지만, Zig은 그렇지 않음

    • 표준 라이브러리 정의로 이동해보면 Plan9 OS 같은 특수 케이스 처리도 직접 볼 수 있음
      page_allocator 문서 참고
    • 다만 이런 주장을 뒷받침할 구체적 예시가 더 필요함