타입
SafeC는 암묵적 변환이 없는(단, 안전한 숫자 확장 — 작은 것에서 큰 것으로만, 그 반대는 절대 없음 — 은 예외) 강력한 정적 타입 시스템을 제공합니다. 모든 타입은 컴파일 타임에 해석되며, 제네릭은 런타임 오버헤드 없이 완전히 단형화됩니다.
기본 타입
| 타입 | 크기 | 설명 |
|---|---|---|
int |
32비트 | 부호 있는 정수 |
long / long long |
64비트 | 부호 있는 long 정수 |
float |
32비트 | IEEE 754 단정밀도 |
double |
64비트 | IEEE 754 배정밀도 |
bool |
1비트 | true 또는 false |
char |
8비트 | 문자 (부호 없는 바이트) |
void |
0비트 | 유닛 타입 / 값 없음 |
크기 지정 정수 타입
너비와 부호 여부를 명시적으로 제어하기 위한 타입입니다.
| 부호 있음 | 부호 없음 | 너비 |
|---|---|---|
int8_t |
uint8_t |
8비트 |
int16_t |
uint16_t |
16비트 |
int32_t |
uint32_t |
32비트 |
int64_t |
uint64_t |
64비트 |
intptr_t, ptrdiff_t |
uintptr_t, size_t |
포인터 너비 |
size_t와 그 친구들은 타깃의 포인터 너비(__SIZEOF_POINTER__)를 따릅니다: 64비트 타깃에서는 8바이트, x86, aarch32, Cortex-M, RV32 같은 32비트 타깃에서는 4바이트입니다. SafeC 자체의 long은 모든 타깃에서 항상 64비트이므로 포인터 크기 정수가 아닙니다 — 크기와 주소에는 size_t/uintptr_t를 쓰세요. extern C 함수의 포인터 너비 정수 매개변수(32비트 타깃의 size_t)에 넘기는 정수 인자는 캐스트 없이 호출 시 변환되므로, unsigned long 크기로 작성한 libc 호출도 이식성을 유지합니다.
auto를 이용한 타입 추론
auto는 초기화식으로부터 지역 변수의 타입을 추론합니다 — 변수는 반드시 초기화식을 가져야 합니다(초기화식이 없는 auto x;는 컴파일 오류입니다).
auto n = 42; // int
auto pi = 3.14; // double
auto p = compute(); // compute()가 반환하는 무엇이든
for (auto i = 0; i < 10; i++) {
// i : int
}
auto는 오직 초기화식 표현식 자체의 타입으로부터만 추론하며, 더 넓은 범위의 흐름 분석을 수행하지 않습니다. (다른 모든 선언된 타입과 마찬가지로) 추론된 타입은 변수의 생애 동안 고정되며, 재대입 시 재추론되지 않습니다. 위에서 보인 것처럼 for 루프의 초기화절을 포함해, 일반적인 선언 타입을 가진 지역 변수가 쓰일 수 있는 어디서든 사용할 수 있습니다.
구조체 타입
구조체는 C와 호환되는 레이아웃을 가진 값 타입입니다. 대입은 값 전체를 복사합니다.
struct Point {
double x;
double y;
double length() const;
void scale(double s);
};
구조체는 메서드(함수 참고), 연산자 오버로딩을 지원하며, 제네릭 타입 인자로 사용될 수 있습니다.
유니온 타입
SafeC의 모든 union은 **태그된 유니온(tagged union)**입니다: 선언한
필드들과 함께, 컴파일러는 실제로 어느 필드가 살아있는지를 기록하는
숨겨진 판별자(discriminant)를 저장하며, 가장 최근에 쓴 필드만 읽을 수
있도록(직접적인 필드 접근이 아니라 match를 통해 — 아래 참고) 강제합니다.
이는 C의 union으로부터의 의도적인 이탈입니다. C에서는 마지막으로
어떤 필드가 쓰였는지와 무관하게 원하는 필드를 읽는 것이 (타입 펀닝에
의존적이긴 하지만) 합법입니다 — SafeC는 "안전한 코드에는 정의되지 않은
동작이 없다"는 일반 원칙에 따라 이를 허용하지 않습니다.
union Result {
int ok;
int err;
}
Type.field(value) / match를 통해 생성하고 읽으십시오. 일반적인 .field 대입은 안 됩니다
모든 유니온이 태그되어 있으므로, union Result r; r.ok = 42; 다음에
일반 필드 읽기를 하는 것은 C의 유니온처럼 동작하지 않습니다(그리고
판별자와 페이로드가 배치되는 방식 때문에, int 크기가 아닌 필드에
대해서는 완전히 잘못된 값을 만들어낼 수도 있습니다) — 이는 유니온을
사용하는 허용된 방법이 아닙니다. 유니온 값은 TypeName.field(value)로
생성하고, match의 점(dot) 접두사 변형 패턴인 case .field(x):로
읽으십시오.
extern int printf(const char* fmt, ...);
union Result {
int ok;
int err;
}
void handle(union Result r) {
match (r) {
case .ok(v): printf("ok: %d\n", v);
case .err(e): printf("err: %d\n", e);
default: printf("unreachable\n");
}
}
int main() {
union Result a = Result.ok(42);
union Result b = Result.err(-1);
handle(a); // ok: 42
handle(b); // err: -1
return 0;
}
플레이그라운드에서 실행이는 ?T/?®ion T의 some(x)가 사용하는 것(위의 "nullable 값 읽기" 참고)과 동일한 .variant(x) 패턴 형태입니다. 다만 그쪽에는 선행하는 점이 없습니다 — some/none은 태그된 유니온 자체의 변형 이름과 달리 점이 붙지 않은 일반 식별자입니다.
유니온도 제네릭이 될 수 있습니다. 구조체와 마찬가지로, generic<T, E> union Result { T ok; E err; }는 임의의 타입 쌍에 대한 진짜 합 타입(sum type)이며, 다른 제네릭 타입과 마찬가지로 구체적인 <T, E> 인스턴스화마다 단형화됩니다.
태그된 유니온과 완전성 검사된 match는 SafeC의 대수적 데이터 타입 / 닫힌 다형성(closed-polymorphism) 메커니즘입니다 — 다형성과 OOP와 함수형 프로그래밍을 참고하십시오.
튜플 타입
튜플은 익명 곱 타입(product type)입니다. 멤버는 .0, .1 등으로 인덱스를 통해 접근합니다.
tuple(int, double) pair = (42, 3.14);
int first = pair.0;
double second = pair.1;
괄호로 감싼 쉼표 구분 목록은 튜플 타입이 기대되는 곳이라면 어디서든 튜플 리터럴입니다: 초기화식, 대입, return, 인자. 각 원소는 대응하는 멤버 타입으로 변환되므로, 리터럴은 일반 대입에서처럼 넓혀집니다:
tuple(int, double) divmod(int a, int b) { return (a / b, (double)(a % b)); }
int sum(tuple(int, int) t) { return t.0 + t.1; }
tuple(long long, int) q = (1, 2); // 1 widens to long long
int s = sum((3, 4));
튜플은 코드 생성 시 익명 LLVM 구조체 타입으로 lowering됩니다.
슬라이스 타입
슬라이스는 데이터 포인터와 길이로 구성된 팻 포인터(fat pointer)입니다. 슬라이스는 연속된 메모리에 대한 경계 검사된 접근을 제공합니다.
int arr[5] = {10, 20, 30, 40, 50};
[]int s = arr[1..4]; // {int*, i64}이며 len=3
int x = s[0]; // 경계 검사된 접근
long len = s.len; // 길이 필드
int *raw = s.ptr; // 내부 포인터
슬라이싱은 반열린 범위를 받습니다: a[i..j], a[i..](배열이나 슬라이스의 끝까지), a[..j], a[..](전부). 기반을 벗어나거나 i > j인 범위는 범위 밖 인덱스처럼 실행 시간에 트랩합니다. unsafe { } 안에서는 검사를 생략합니다. 원시 포인터는 unsafe { } 안에서만 슬라이스할 수 있고(p[0..n]은 원소 n개가 있다고 단언합니다), 슬라이스는 포인터로 캐스트할 수 없습니다 — .ptr 필드를 쓰세요.
읽기 전용 슬라이스
[]const T는 원소를 쓸 수 없는 뷰입니다. []T는 []const T로 암시적으로 변환되므로(반대는 불가) 읽기만 하는 함수는 []const T를 받아 둘 다 받아들입니다:
long sum([]const int xs) {
long s = 0L;
for (unsigned long i = 0UL; i < xs.len; i = i + 1UL) { s = s + (long)xs[i]; }
// xs[0] = 1; // 오류: cannot assign through a read-only slice '[]const int'
return s;
}
int a[4] = {1, 2, 3, 4};
long total = sum(a[..]);
바이트 슬라이스는 char, signed char, unsigned char 사이에서 (const를 유지한 채) 서로 변환되므로 텍스트도 바이트 버퍼입니다: std::str_bytes("text")(std/str.h)는 문자열의 바이트를 []const char로 주며, []const unsigned char를 기대하는 곳 어디서나 받아들여집니다.
옵셔널 슬라이스와 널 가능 함수
?[]T는 옵셔널 슬라이스입니다(match (s) { case some(v): ... case none: ... }로 좁힙니다). ?fn R(Args)는 널 가능 함수 참조입니다: null이나 함수를 담으며, match나 (unsafe 안의) !로 함수를 꺼내고, 그대로 호출하면 컴파일 오류입니다.
표준 라이브러리는 C라면 포인터와 길이를 받을 자리에서 슬라이스를 받습니다 — f.read(buf[..]), sha256(data, digest[..]), fft(xs[0..n]) — 표준 라이브러리 개요를 보세요.
옵셔널 타입
옵셔널 타입은 존재할 수도, 존재하지 않을 수도 있는 값을 나타냅니다. {T, i1} 쌍으로 lowering됩니다.
?int find_first([]int arr, int target) {
long i = 0L;
while (i < arr.len) {
if (arr[i] == target) return (int)i; // implicit T -> ?T wrap
i = i + 1L;
}
return null; // the empty case
}
// Usage with try (propagates the empty case to the caller)
?int wrapper([]int arr, int target) {
int val = try find_first(arr, target);
return val * 2;
}
순수한 T는 암묵적으로 ?T로 래핑되고(위의 return i;처럼), null은 ?T 옵셔널과 nullable 참조 양쪽 모두의 빈 값입니다 — 별도의 some(x)/none 생성자 문법은 없습니다. 이 두 식별자는 (일반 표현식이 아니라) 제어 흐름에서 본 것처럼 오직 match 패턴으로만 등장합니다.
Nullable 참조도 동일한 ? 문법을 사용합니다.
?&stack Node next; // nullable 스택 참조
Nullable 값 읽기
포인터(T*), nullable 참조(?®ion T — 여기서 "region"은 리전이 없는 ?&T 형태도 포괄합니다. 메모리와 리전의 "Outliving References" 절 참고), 또는 옵셔널(?T)은 직접 역참조, 멤버 접근, 강제 언래핑될 수 없습니다 — 컴파일러는 다음 중 하나를 요구합니다.
| 연산 | 대상 | 효과 |
|---|---|---|
x.is_null() |
T*, ?®ion T |
bool을 반환; 존재 여부 검사만 하며 x의 타입을 좁히지 않음 |
x.is_none() |
?T |
bool을 반환; 옵셔널에서 is_null()에 대응 |
x.default(fallback) |
세 가지 모두 | 값이 있으면 내부 값을, 없으면 fallback을 평가하여 반환(내부 타입과 일치해야 함) |
match (x) { case null / none: ... case some(v): ... } |
세 가지 모두 | some 분기 안에서 v는 좁혀진(narrowed) 값: ?&T에 대해서는 non-null 참조 &T(v를 통한 쓰기는 참조 대상에 반영됨), ?T에 대해서는 담긴 T |
std::expect_ref(x, "msg") (std/panic.h) |
?&T |
&T를 반환하거나, x가 null이면 msg와 함께 패닉 — 예상된 경우가 아니라 버그일 null에 사용 |
unsafe { } 안에서의 x! / *x / x.field / x->field |
세 가지 모두 | 위의 검사를 전부 우회함 |
struct Node { int value; };
int describe(?&stack Node n) {
// int v = n.value; // ERROR: 'unsafe'가 필요하거나 match/is_null()/.default(value) 필요
// Node v = n!; // ERROR: '!' 강제 언래핑은 'unsafe'가 필요
if (n.is_null()) { return -1; } // OK: 존재 여부 검사
Node fallback;
fallback.value = -1;
Node result = n.default(fallback); // 먼저 바인딩 — .default(...).value로
return result.value; // 바로 체이닝하는 것은 컴파일되지 않음(임시 리시버)
// OK: unsafe는 검사를 완전히 우회함
// unsafe { return n->value; }
}
int describe_match(?&stack Node n) {
return match (n) {
case null: -1,
case some(v): v.value, // v : &Node -- the narrowed, non-null reference
};
}
nullable 참조는 non-null 참조로 캐스트할 수 없습니다: ?&T q에 대한 (&T)q는 컴파일 오류입니다 — 그 캐스트는 match가 강제하는 바로 그 검사를 건너뛰게 되기 때문입니다. 반대 방향 캐스트나 다른 nullable 타입으로의 캐스트((?&T)p)는 괜찮습니다.
?T에 대한 x.is_null()(그리고 포인터/nullable 참조에 대한 x.is_none())은 컴파일 오류입니다 — 리시버의 종류에 맞는 것을 사용하십시오. null/some(x) 패턴을 가진 match는 nullable 참조뿐 아니라 원시 포인터(T*)에도 동작합니다.
try 기반 전파와 ?T가 의도적으로 제공하지 않는 것들(.map()/.and_then() 조합자가 없음)에 대해서는 함수형 프로그래밍을 참고하십시오.
뉴타입 고유 타입
뉴타입은 기반 타입으로부터 구별되는 타입을 만듭니다. 기반 타입과 서로 교환하여 사용할 수 없습니다.
newtype UserId = int;
newtype Temperature = double;
UserId id = (UserId)42; // 명시적 캐스트 — UserId(42) 생성자 호출 문법은 없음
// int x = id; // ERROR: UserId는 int가 아님
열거형 타입
명시적인 하위 타입을 가진 열거형입니다.
enum Color : uint8_t {
Red = 0,
Green = 1,
Blue = 2
}
enum Status : int {
OK = 200,
NotFound = 404,
ServerError = 500
}
함수 타입
함수 포인터는 fn 키워드를 사용합니다.
fn int(int, double) compute;
fn int(int) transform = add_one;
int result = transform(5); // add_one(5)를 호출
리전 한정 참조 타입
참조는 컴파일러가 라이프타임 분석에 사용하는 리전 정보를 가지고 있습니다.
&stack int // non-null stack reference
&heap float // non-null heap reference
&static Config // non-null static reference
&arena<AudioPool> Frame // non-null arena reference
&Point // non-null reference, no region -- accepts any of the above
?&Point // nullable, no region
&volatile unsigned int // reference to a volatile object (MMIO registers)
&&int // reference to a reference (also &?&, ?&&, ?&?&)
리전 한정자를 아예 생략하는 것(&T / ?&T)은 선언되거나 추적되는 리전이
없는 참조를 만듭니다 — 이것이 특정 리전을 고정하는 것보다 적절한 선택이
되는 경우는 메모리와 리전의 "Outliving References" 절을 참고하십시오.
SafeC 시그니처에서는 참조가 원시 포인터를 대신합니다: void*는 타입이 있는
&T(어떤 타입이든 받는 경우 제네릭)가 되고, T**는
참조에 대한 참조가 되며,
포인터 배열은 배열이나 Slice<&T>가 됩니다
(리전별 배열 참고). 원시 포인터는
extern C 선언과 unsafe 코드에만 남습니다.
자세한 내용은 메모리와 리전을 참고하십시오.
제네릭 타입
제네릭은 컴파일 타임 전용이며 완전히 단형화됩니다. vtable이나 런타임 디스패치가 없습니다.
generic<T: Numeric>
T add(T a, T b) {
return a + b;
}
// 컴파일러는 별도의 버전들을 생성합니다:
// int add(int a, int b)
// double add(double a, double b)
제네릭 타입 매개변수는 트레이트로 제약될 수 있습니다.
generic<T: Numeric>
T clamp(T val, T lo, T hi) {
if (val < lo) return lo;
if (val > hi) return hi;
return val;
}
타입 변환
SafeC는 한 가지 예외를 제외하면 암묵적 변환이 없습니다 — 그 예외는 안전한 확장(widening)입니다: 정수, 부동소수점, 정수→부동소수점에 걸쳐 더 작은 숫자 타입에서 더 큰 타입으로의 변환입니다. 축소(narrowing, 더 큰 것에서 더 작은 것으로, 또는 부동소수점에서 정수로)는 여전히 명시적 캐스트가 필요합니다.
int x = 42;
double d = x; // 암묵적: int -> double은 안전하게 확장됨
float f = 3.14f;
double d2 = f; // 암묵적: float -> double은 안전하게 확장됨
int i = (int)f; // 명시적 캐스트 필요: float -> int는 축소됨
long long big = 100LL;
int small = (int)big; // 명시적 캐스트 필요: 축소됨
값 시맨틱 vs 참조 시맨틱
- 구조체는 값 타입입니다: 대입은 구조체 전체를 복사합니다
- 참조는 명시적입니다: 참조를 만들려면
&를 사용해야 하고 리전을 한정해야 합니다 - 숨겨진 이동 시맨틱이 없습니다: 작성한 그대로 동작합니다
- 배열은 함수에 전달될 때 첫 원소에 대한 참조로 decay됩니다 (C로 전달될 때는 포인터로)
struct Point { double x; double y; };
Point a = {1.0, 2.0};
Point b = a; // 구조체를 복사함
b.x = 99.0; // a에는 영향을 주지 않음
&stack Point ref = &a; // 명시적 참조