코딩 · 프로그래밍 언어
초보자를 위한 Zig 기초 튜토리얼
핵심 답변
Zig는 C의 현대적인 대안을 목표로 하는 범용 시스템 프로그래밍 언어이자 툴체인입니다. 숨겨진 제어 흐름도, 가비지 컬렉터도, 숨겨진 메모리 할당도 없습니다. 할당자를 명시적으로 전달하고, 에러 유니온과 try로 오류를 처리하며, defer로 정리합니다. 컴파일 시점 코드 실행(comptime)이 매크로와 제네릭을 대신합니다. 파일은 zig run hello.zig로 실행합니다. Zig는 아직 1.0 이전 버전이므로 언어와 표준 라이브러리의 세부 사항이 릴리스마다 바뀝니다.
Zig란 무엇이며 누가 쓰는가
Zig는 Andrew Kelley가 만들었고 비영리 단체인 Zig Software Foundation이 개발합니다. 운영체제 구성 요소, 임베디드 펌웨어, 게임 엔진, 데이터베이스, 빠른 명령줄 도구처럼 C와 같은 영역을 대상으로 합니다. Zig 컴파일러는 C와 C++ 코드도 컴파일(zig cc)하고 여러 타깃으로 크로스 컴파일할 수 있어서, 일부 프로젝트는 C 툴체인으로만 사용하기도 합니다.
Zig로 작성된 잘 알려진 프로젝트로는 Bun JavaScript 런타임, TigerBeetle 금융 데이터베이스, Ghostty 터미널 에뮬레이터가 있습니다.
Zig 설치와 Hello, world 실행
ziglang.org/download에서 사용하는 시스템에 맞는 빌드된 아카이브를 내려받아 압축을 풀고 폴더를 PATH에 추가하거나, 시스템 패키지 관리자로 Zig를 설치합니다. 별도의 설치 프로그램이나 런타임은 없습니다.
// hello.zig
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, world!\n", .{});
}zig run hello.zig # compile and run
# Hello, world!
zig build-exe hello.zig # produce an executable named hello
zig init # create a project with build.zigstd.debug.print는 표준 오류에 출력하며 최근 모든 버전에서 가장 간단한 출력 방법입니다. 두 번째 인자는 포맷 자리 표시자에 채울 값의 튜플이고, .{}는 빈 튜플입니다. 표준 출력에 쓰는 API는 릴리스마다 바뀌었으므로 사용하는 버전의 표준 라이브러리 문서를 확인하세요.
변수와 기본 타입
const는 바뀌지 않는 값을, var는 변경 가능한 변수를 선언합니다. 컴파일러는 엄격해서 사용하지 않는 지역 변수는 오류이고, 한 번도 수정하지 않는 var도 오류이므로 기본적으로 const를 쓰세요.
| 타입 | 예시 | 설명 |
|---|---|---|
| 부호 있는 정수 | i8, i32, i64 | i7처럼 어떤 너비든 가능 |
| 부호 없는 정수 | u8, u32, usize | usize는 포인터 크기이며 길이와 인덱스에 사용 |
| 실수 | f32, f64 | IEEE 754 |
| bool | true, false | 정수로부터의 암묵적 변환 없음 |
| 배열 | [3]u8 | 컴파일 시점에 알려진 고정 길이 |
| 슬라이스 | []const u8 | 포인터와 길이. 문자열은 바이트 슬라이스 |
| 옵셔널 | ?i32 | 값 또는 null |
| 에러 유니온 | !u32 | 값 또는 에러 |
const std = @import("std");
pub fn main() void {
const age: u32 = 30;
var count: i32 = 0;
count += 1;
const name = "Ana"; // string literal, coerces to []const u8
std.debug.print("{s} is {d}, count = {d}\n", .{ name, age, count });
var maybe: ?i32 = null;
maybe = 5;
const value = maybe orelse 0;
std.debug.print("value = {d}\n", .{value});
}포맷 자리 표시자는 출력 방식을 정합니다. 숫자는 {d}, 문자열은 {s}, 바이트 하나를 문자로는 {c}, 디버깅용 아무 값은 {any}입니다.
제어 흐름
if, switch, while, for는 모두 표현식으로 쓸 수 있습니다. switch는 가능한 모든 값을 다뤄야 하므로 보통 else 분기를 추가합니다.
const std = @import("std");
pub fn main() void {
const n: i32 = -2;
const sign: []const u8 = if (n > 0) "positive" else if (n < 0) "negative" else "zero";
const score: u8 = 85;
const letter: u8 = switch (score) {
90...100 => 'A',
80...89 => 'B',
else => 'C',
};
std.debug.print("{s} {c}\n", .{ sign, letter }); // negative B
var i: u32 = 0;
while (i < 3) : (i += 1) {
std.debug.print("{d} ", .{i}); // 0 1 2
}
const items = [_]u8{ 10, 20, 30 };
for (items, 0..) |item, index| {
std.debug.print("\n{d}: {d}", .{ index, item });
}
std.debug.print("\n", .{});
}[_]u8{ ... }를 쓰면 컴파일러가 배열 길이를 셉니다. for (items, 0..)는 항목과 카운터를 동시에 순회하며, 파이프 사이의 값을 캡처라 부릅니다.
함수와 comptime
함수는 매개변수 타입과 반환 타입을 선언합니다. 인자는 함수 안에서 불변입니다. 제네릭 코드는 comptime 매개변수로 작성합니다. 타입은 컴파일 시점에 존재하는 값이므로 함수가 타입을 인자로 받을 수 있습니다.
const std = @import("std");
fn add(a: i32, b: i32) i32 {
return a + b;
}
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
pub fn main() void {
std.debug.print("{d}\n", .{add(2, 3)}); // 5
std.debug.print("{d}\n", .{max(i32, 3, 7)}); // 7
std.debug.print("{d}\n", .{max(f64, 1.5, 0.5)}); // 1.5
}컴파일러는 max를 호출된 각 타입마다 별도 버전으로 만듭니다. 같은 메커니즘으로 표준 라이브러리의 제네릭 자료구조를 만듭니다. 타입을 받아 새 구조체 타입을 반환하는 함수입니다.
에러 유니온, try, defer를 이용한 오류 처리
에러는 값입니다. 반환 타입 !u32는 u32 또는 에러라는 뜻입니다. try expr는 에러가 있으면 호출자에게 반환하고, catch는 그 자리에서 처리합니다. 에러는 조용히 무시할 수 없습니다.
const std = @import("std");
fn parseAge(s: []const u8) !u32 {
const n = try std.fmt.parseInt(i32, s, 10);
if (n < 0) return error.Negative;
return @intCast(n);
}
pub fn main() void {
const age = parseAge("-5") catch |err| {
std.debug.print("error: {s}\n", .{@errorName(err)});
return;
};
std.debug.print("age {d}\n", .{age});
}
// error: Negativedefer는 현재 블록이 끝날 때 문장을 실행하고, errdefer는 블록이 에러로 끝날 때만 실행합니다. 이들은 자원을 얻은 코드 바로 옆에 정리 코드를 둘 수 있게 해 줍니다.
메모리와 할당자
Zig에는 가비지 컬렉터도 전역의 숨겨진 할당자도 없습니다. 힙 메모리가 필요한 함수는 std.mem.Allocator 매개변수를 받으므로 메모리를 어디서 가져올지 호출자가 결정합니다(페이지 할당자, 아레나, 고정 버퍼, 누수를 감지하는 디버깅 할당자 등).
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(u32, 5);
defer allocator.free(buf);
for (buf, 0..) |*slot, i| {
slot.* = @intCast(i * i);
}
std.debug.print("{any}\n", .{buf}); // { 0, 1, 4, 9, 16 }
}|*slot|은 각 요소의 포인터를 캡처해 반복문에서 값을 쓸 수 있게 하고, slot.*는 포인터를 역참조합니다. Debug와 ReleaseSafe 빌드에서는 정수 오버플로와 범위를 벗어난 인덱싱이 정의되지 않은 동작을 일으키는 대신 명확한 패닉과 함께 프로그램을 멈춥니다.
작은 완성 프로그램: 숫자 평균
const std = @import("std");
fn average(numbers: []const i64) ?f64 {
if (numbers.len == 0) return null;
var sum: i64 = 0;
for (numbers) |n| sum += n;
const total: f64 = @floatFromInt(sum);
const len: f64 = @floatFromInt(numbers.len);
return total / len;
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(i64, 16);
defer allocator.free(buf);
var it = std.mem.tokenizeScalar(u8, "4 8 15 16 23 42", ' ');
var count: usize = 0;
while (it.next()) |word| : (count += 1) {
buf[count] = try std.fmt.parseInt(i64, word, 10);
}
if (average(buf[0..count])) |avg| {
std.debug.print("count={d} avg={d:.2}\n", .{ count, avg });
}
}
// count=6 avg=18.00이 프로그램은 문자열을 공백으로 나누고, 각 조각을 try로 파싱하고, 숫자를 defer로 해제하는 힙 메모리에 저장하며, 빈 리스트이면 null인 옵셔널 평균을 반환합니다.
다음 단계
- ziglang.org/documentation의 언어 레퍼런스. 설치한 버전에 맞는 페이지를 고르세요.
- ziglang.org의 Learn 섹션. Getting Started와 언어 개요가 있습니다.
- 고장 난 작은 프로그램들을 하나씩 고치는 Ziglings는 인기 있는 커뮤니티 연습 모음입니다.
- 표준 라이브러리 소스를 읽어 보세요. Zig로 작성되어 있어서 최신 API의 가장 좋은 레퍼런스입니다.
자주 묻는 질문
Zig는 프로덕션에 쓸 준비가 되었나요?
프로덕션에서 Zig를 쓰는 회사도 있지만 언어가 1.0 이전이므로 컴파일러를 업그레이드할 때 호환성이 깨지는 변경을 예상하고 그에 필요한 시간을 계획해야 합니다.
Zig는 C나 Rust를 대체하나요?
Zig는 C에 가장 가깝습니다. 수동 메모리 관리에 더 나은 안전성 검사, 오류 처리, 컴파일 시점 코드를 더했습니다. Rust는 소유권 시스템과 빌림 검사기로 메모리 안전성을 보장하지만 Zig에는 그런 것이 없습니다.
Zig에서 C 라이브러리를 쓸 수 있나요?
네. Zig는 C 헤더 파일을 임포트하고 C 함수를 직접 호출할 수 있으며, Zig 컴파일러가 C와 C++ 소스 파일을 빌드할 수 있어서 두 언어를 섞기가 수월합니다.
Zig의 comptime은 무슨 뜻인가요?
comptime은 컴파일하는 동안 평가되는 코드나 매개변수를 표시합니다. Zig는 별도의 매크로 언어 대신 제네릭, 상수 테이블, 컴파일 시점 검사에 이것을 사용합니다.