Solod는 숨겨진 메모리 할당 없이 C로 변환되고 소스 수준 상호운용성을 제공하는 Go의 엄격한 부분집합 언어다.
저는 Solod(So)라는 새로운 프로그래밍 언어를 만들고 있습니다. 이것은 Go의 엄격한 부분집합으로, 숨겨진 메모리 할당 없이 C로 변환되며 소스 수준 상호운용성을 제공합니다.
주요 특징:
go test까지 가능합니다.So는 struct, method, interface, slice, 다중 반환, 그리고 defer를 지원합니다. 단순함을 유지하기 위해 channel, goroutine, closure, generics는 없습니다.
So는 C를 위한 시스템 프로그래밍 언어이지만, Go의 문법, 타입 안정성, 도구 생태계를 갖추고 있습니다.
Hello world • Language tour • Compatibility • Design decisions • FAQ • Final thoughts
파일 main.go에 있는 이 Go 코드는:
package main
type Person struct {
Name string
Age int
Nums [3]int
}
func (p *Person) Sleep() int {
p.Age += 1
return p.Age
}
func main() {
p := Person{Name: "Alice", Age: 30}
p.Sleep()
println(p.Name, "is now", p.Age, "years old.")
p.Nums[0] = 42
println("1st lucky number is", p.Nums[0])
}
헤더 파일 main.h로 변환됩니다:
#pragma once
#include "so/builtin/builtin.h"
typedef struct main_Person {
so_String Name;
so_int Age;
so_int Nums[3];
} main_Person;
so_int main_Person_Sleep(void* self);
그리고 구현 파일 main.c도 함께 생성됩니다:
#include "main.h"
so_int main_Person_Sleep(void* self) {
main_Person* p = (main_Person*)self;
p->Age += 1;
return p->Age;
}
int main(void) {
main_Person p = (main_Person){.Name = so_str("Alice"), .Age = 30};
main_Person_Sleep(&p);
so_println("%.*s %s %" PRId64 " %s", p.Name.len, p.Name.ptr, "is now", p.Age, "years old.");
p.Nums[0] = 42;
so_println("%s %" PRId64, "1st lucky number is", p.Nums[0]);
}
기능 측면에서 So는 Go와 C의 교집합에 있는 언어이며, 그 덕분에 가장 단순한 C 계열 언어 중 하나입니다 — Hare와 비슷한 수준입니다.
그리고 So는 Go의 엄격한 부분집합이므로, 이미 Go를 안다면 So도 알고 있는 셈입니다. 다른 문법을 또 배우고 싶지 않을 때 꽤 편리합니다.
이제 언어 기능을 간단히 살펴보고, 그것들이 C로 어떻게 변환되는지 보겠습니다.
Variables • Strings • Arrays • Slices • Maps • If/else and for • Functions • Multiple returns • Structs • Methods • Interfaces • Enums • Errors • Defer • C interop • Packages
So는 기본적인 Go 타입과 변수 선언을 지원합니다:
// so
const n = 100_000
f := 3.14
var r = '本'
var v any = 42
// c
const so_int n = 100000;
double f = 3.14;
so_rune r = U'本';
void* v = &(so_int){42};
byte는 so_byte(uint8_t)로, rune은 so_rune(int32_t)로, int는 so_int(int64_t)로 변환됩니다.
any는 interface로 취급되지 않습니다. 대신 void*로 변환됩니다. 덕분에 포인터를 훨씬 다루기 쉬워지고 unsafe.Pointer도 필요 없어집니다.
nil은 포인터 타입에서 NULL로 변환됩니다.
문자열은 C에서 so_String 타입으로 표현됩니다:
// c
typedef struct {
const char* ptr;
size_t len;
} so_String;
인덱싱, 슬라이싱, for-range 루프 순회를 포함해 표준 문자열 연산을 모두 지원합니다.
// so
str := "Hi 世界!"
println("str[1] =", str[1])
for i, r := range str {
println("i =", i, "r =", r)
}
// c
so_String str = so_str("Hi 世界!");
so_println("%s %u", "str[1] =", so_at(so_byte, str, 1));
for (so_int i = 0, _iw = 0; i < so_len(str); i += _iw) {
_iw = 0;
so_rune r = so_utf8_decode(str, i, &_iw);
so_println("%s %" PRId64 " %s %d", "i =", i, "r =", r);
}
문자열을 바이트 슬라이스로, 다시 문자열로 변환하는 것은 복사 없는 연산입니다:
// so
s := "1世3"
bs := []byte(s)
s1 := string(bs)
// c
so_String s = so_str("1世3");
so_Slice bs = so_string_bytes(s); // wraps s.ptr
so_String s1 = so_bytes_string(bs); // wraps bs.ptr
문자열을 룬 슬라이스로, 다시 문자열로 변환할 때는 alloca를 사용해 스택에 할당합니다:
// so
s := "1世3"
rs := []rune(s)
s1 := string(rs)
// c
so_String s = so_str("1世3");
so_Slice rs = so_string_runes(s); // allocates
so_String s1 = so_runes_string(rs); // allocates
힙 할당 문자열과 다양한 문자열 연산을 위한 so/strings 표준 라이브러리 패키지도 있습니다.
배열은 일반 C 배열(T name[N])로 표현됩니다:
// so
var a [5]int // zero-initialized
b := [5]int{1, 2, 3, 4, 5} // explicit values
c := [...]int{1, 2, 3, 4, 5} // inferred size
d := [...]int{100, 3: 400, 500} // designated initializers
// c
so_int a[5] = {0};
so_int b[5] = {1, 2, 3, 4, 5};
so_int c[5] = {1, 2, 3, 4, 5};
so_int d[5] = {100, [3] = 400, 500};
배열에 대한 len()은 컴파일 타임 상수로 생성됩니다.
배열을 슬라이싱하면 so_Slice가 만들어집니다.
슬라이스는 C에서 so_Slice 타입으로 표현됩니다:
// c
typedef struct {
void* ptr;
size_t len;
size_t cap;
} so_Slice;
인덱싱, 슬라이싱, for-range 루프 순회를 포함해 표준 슬라이스 연산을 모두 지원합니다.
// so
s1 := []string{"a", "b", "c", "d", "e"}
s2 := s1[1 : len(s1)-1]
for i, v := range s2 {
println(i, v)
}
// c
so_Slice s1 = (so_Slice){(so_String[5]){
so_str("a"), so_str("b"), so_str("c"),
so_str("d"), so_str("e")}, 5, 5};
so_Slice s2 = so_slice(so_String, s1, 1, so_len(s1) - 1);
for (so_int i = 0; i < so_len(s2); i++) {
so_String v = so_at(so_String, s2, i);
so_println("%" PRId64 " %.*s", i, v.len, v.ptr);
}
Go와 마찬가지로 슬라이스는 값 타입입니다. 하지만 Go와 달리 nil 슬라이스와 빈 슬라이스는 같은 것입니다:
// so
var nils []int = nil
var empty []int = []int{}
// c
so_Slice nils = (so_Slice){0};
so_Slice empty = (so_Slice){0};
make()는 스택에 고정된 크기의 메모리(sizeof(T)*cap)를 할당합니다. append()는 초기 용량까지만 동작하며, 이를 넘기면 panic이 발생합니다. 자동 재할당은 없습니다. 힙 할당과 동적 배열이 필요하면 so/slices 표준 라이브러리 패키지를 사용하세요.
맵은 크기가 고정되고 스택에 할당되며, 병렬 key/value 배열과 선형 탐색을 기반으로 동작합니다. 포인터 기반 참조 타입이며 C에서는 so_Map*로 표현됩니다. delete도 없고 resize도 없습니다.
// c
typedef struct {
void* keys;
void* vals;
size_t len;
size_t cap;
} so_Map;
맵은 key-value 쌍의 개수가 작고 고정되어 있을 때만 사용하세요. 그 외의 경우에는 so/maps 패키지의 힙 할당 맵을 사용하면 됩니다(예정).
값 읽기/쓰기와 for-range 루프 순회를 포함해 대부분의 표준 맵 연산을 지원합니다:
// so
m := map[string]int{"a": 11, "b": 22}
for k, v := range m {
println(k, v)
}
// c
so_Map* m = &(so_Map){(so_String[2]){
so_str("a"), so_str("b")},
(so_int[2]){11, 22}, 2, 2};
for (so_int _i = 0; _i < (so_int)m->len; _i++) {
so_String k = ((so_String*)m->keys)[_i];
so_int v = ((so_int*)m->vals)[_i];
so_println("%.*s %" PRId64, k.len, k.ptr, v);
}
Go와 마찬가지로 맵은 포인터 타입입니다. nil 맵은 C에서 NULL로 생성됩니다.
If-else와 for는 Go처럼 다양한 형태를 모두 지원합니다.
체이닝이 가능한 표준 if-else:
// so
if x > 0 {
println("positive")
} else if x < 0 {
println("negative")
} else {
println("zero")
}
// c
if (x > 0) {
so_println("%s", "positive");
} else if (x < 0) {
so_println("%s", "negative");
} else {
so_println("%s", "zero");
}
초기화 문장(if 블록 범위 안에 한정됨):
// so
if num := 9; num < 10 {
println(num, "has 1 digit")
}
// c
{
so_int num = 9;
if (num < 10) {
so_println("%" PRId64 " %s", num, "has 1 digit");
}
}
전통적인 for 루프:
// so
for j := 0; j < 3; j++ {
println(j)
}
// c
for (so_int j = 0; j < 3; j++) {
so_println("%" PRId64, j);
}
while 스타일 루프:
// so
i := 1
for i <= 3 {
println(i)
i = i + 1
}
// c
so_int i = 1;
for (; i <= 3;) {
so_println("%" PRId64, i);
i = i + 1;
}
정수 범위에 대한 range:
// so
for k := range 3 {
println(k)
}
// c
for (so_int k = 0; k < 3; k++) {
so_println("%" PRId64, k);
}
일반 함수는 자연스럽게 C로 변환됩니다:
// so
func sumABC(a, b, c int) int {
return a + b + c
}
// c
static so_int sumABC(so_int a, so_int b, so_int c) {
return a + b + c;
}
이름 있는 함수 타입은 typedef가 됩니다:
// so
type SumFn func(int, int, int) int
fn1 := sumABC // infer type
var fn2 SumFn = sumABC // explicit type
s := fn2(7, 8, 9)
// main.h
typedef so_int (*main_SumFn)(so_int, so_int, so_int);
// main.c
main_SumFn fn1 = sumABC;
main_SumFn fn2 = sumABC;
so_int s = fn2(7, 8, 9);
공개 함수(대문자로 시작)는 패키지 이름이 접두사로 붙은 공개 C 심볼(package_Func)이 됩니다. 비공개 함수는 static입니다.
가변 인자 함수는 표준 ... 문법을 사용하며, 슬라이스를 넘기는 방식으로 변환됩니다:
// so
func sum(nums ...int) int {
total := 0
for _, num := range nums {
total += num
}
return total
}
func main() {
sum(1, 2, 3, 4, 5)
}
// c
static so_int sum(so_Slice nums) {
so_int total = 0;
for (so_int _ = 0; _ < so_len(nums); _++) {
so_int num = so_at(so_int, nums, _);
total += num;
}
return total;
}
int main(void) {
sum((so_Slice){(so_int[5]){1, 2, 3, 4, 5}, 5, 5});
}
함수 리터럴(익명 함수와 클로저)은 지원하지 않습니다.
So는 두 가지 패턴의 2값 다중 반환을 지원합니다: (T, error)와 (T1, T2). 두 경우 모두 C의 so_Result 타입으로 변환됩니다:
// so
func divide(a, b int) (int, error) {
return a / b, nil
}
func divmod(a, b int) (int, int) {
return a / b, a % b
}
// c
typedef struct {
so_Value val;
so_Value val2;
so_Error err;
} so_Result;
// c
static so_Result divide(so_int a, so_int b) {
return (so_Result){.val.as_int = a / b, .err = NULL};
}
static so_Result divmod(so_int a, so_int b) {
return (so_Result){.val.as_int = a / b, .val2.as_int = a % b};
}
이름 있는 반환값은 지원하지 않습니다.
구조체는 자연스럽게 C로 변환됩니다:
// so
type person struct {
name string
age int
}
bob := person{"Bob", 20}
alice := person{name: "Alice", age: 30}
fred := person{name: "Fred"}
// c
typedef struct person {
so_String name;
so_int age;
} person;
person bob = (person){so_str("Bob"), 20};
person alice = (person){.name = so_str("Alice"), .age = 30};
person fred = (person){.name = so_str("Fred")};
new()는 타입과 값 모두에 사용할 수 있습니다:
// so
n := new(int) // *int, zero-initialized
p := new(person) // *person, zero-initialized
n2 := new(42) // *int with value 42
p2 := new(person{name: "Alice"}) // *person with values
// c
so_int* n = &(so_int){0};
person* p = &(person){0};
so_int* n2 = &(so_int){42};
person* p2 = &(person){.name = so_str("Alice")};
메서드는 포인터 리시버 또는 값 리시버로 struct 타입에 정의됩니다:
// so
type Rect struct {
width, height int
}
func (r *Rect) Area() int {
return r.width * r.height
}
func (r Rect) resize(x int) Rect {
r.height *= x
r.width *= x
return r
}
포인터 리시버는 C에서 void* self를 받고 struct 포인터로 캐스팅합니다. 값 리시버는 struct를 값으로 전달하므로 수정은 복사본에 대해 일어납니다:
// c
typedef struct main_Rect {
so_int width;
so_int height;
} main_Rect;
so_int main_Rect_Area(void* self) {
main_Rect* r = (main_Rect*)self;
return r->width * r->height;
}
static main_Rect main_Rect_resize(main_Rect r, so_int x) {
r.height *= x;
r.width *= x;
return r;
}
값과 포인터에 대해 메서드를 호출할 때는 필요에 따라 포인터나 값이 생성됩니다:
// so
r := Rect{width: 10, height: 5}
r.Area() // called on value (address taken automatically)
r.resize(2) // called on value (passed by value)
rp := &r
rp.Area() // called on pointer
rp.resize(2) // called on pointer (dereferenced automatically)
// c
main_Rect r = (main_Rect){.width = 10, .height = 5};
main_Rect_Area(&r);
main_Rect_resize(r, 2);
main_Rect* rp = &r;
main_Rect_Area(rp);
main_Rect_resize(*rp, 2);
이름 있는 기본형에 대한 메서드도 지원합니다.
So의 interface는 Go의 interface와 비슷하지만, 런타임 타입 정보는 포함하지 않습니다.
interface 선언은 필요한 메서드를 나열합니다:
// so
type Shape interface {
Area() int
Perim(n int) int
}
C에서 interface는 void* self 포인터와 각 메서드에 대한 함수 포인터를 가진 struct입니다(정적 메서드 테이블을 쓰는 방식보다 비효율적이지만 더 단순합니다. 앞으로 바뀔 수도 있습니다):
// c
typedef struct main_Shape {
void* self;
so_int (*Area)(void* self);
so_int (*Perim)(void* self, so_int n);
} main_Shape;
Go와 마찬가지로 구체 타입은 필요한 메서드를 제공함으로써 interface를 구현합니다:
// so
func (r *Rect) Area() int {
// ...
}
func (r *Rect) Perim(n int) int {
// ...
}
// c
so_int main_Rect_Area(void* self) {
// ...
}
so_int main_Rect_Perim(void* self, so_int n) {
// ...
}
interface를 받는 함수에 구체 타입 전달하기:
// so
func calcShape(s Shape) int {
return s.Perim(2) + s.Area()
}
r := Rect{width: 10, height: 5}
calcShape(&r) // implicit conversion
calcShape(Shape(&r)) // explicit conversion
// c
static so_int calcShape(main_Shape s) {
return s.Perim(s.self, 2) + s.Area(s.self);
}
main_Rect r = (main_Rect){.width = 10, .height = 5};
calcShape((main_Shape){.self = &r,
.Area = main_Rect_Area,
.Perim = main_Rect_Perim});
calcShape((main_Shape){.self = &r,
.Area = main_Rect_Area,
.Perim = main_Rect_Perim});
타입 단언은 구체 타입에 대해서는 동작하지만(v := iface.(*Type)), interface에 대해서는 동작하지 않습니다(iface.(Interface)). 타입 스위치는 지원하지 않습니다.
빈 interface(interface{}와 any)는 void*로 변환됩니다.
So는 enum처럼 동작하는 타입 지정 상수 그룹을 지원합니다:
// so
type ServerState string
const (
StateIdle ServerState = "idle"
StateConnected ServerState = "connected"
StateError ServerState = "error"
)
각 상수는 C의 const로 생성됩니다:
// main.h
typedef so_String main_ServerState;
extern const main_ServerState main_StateIdle;
extern const main_ServerState main_StateConnected;
extern const main_ServerState main_StateError;
// main.c
const main_ServerState main_StateIdle = so_str("idle");
const main_ServerState main_StateConnected = so_str("connected");
const main_ServerState main_StateError = so_str("error");
정수 타입 상수에 대해서는 iota를 지원합니다:
// so
type Day int
const (
Sunday Day = iota
Monday
Tuesday
)
Iota 값은 컴파일 타임에 계산되어 정수 리터럴로 변환됩니다:
// c
typedef so_int main_Day;
const main_Day main_Sunday = 0;
const main_Day main_Monday = 1;
const main_Day main_Tuesday = 2;
에러는 so_Error 타입(포인터)을 사용합니다:
// c
struct so_Error_ {
const char* msg;
};
typedef struct so_Error_* so_Error;
So는 sentinel error만 지원하며, 이것은 errors.New를 사용해 패키지 수준에서 정의합니다(컴파일러 내장 기능으로 구현됨):
// so
import "solod.dev/so/errors"
var ErrOutOfTea = errors.New("no more tea available")
// c
#include "so/errors/errors.h"
so_Error main_ErrOutOfTea = errors_New("no more tea available");
에러는 ==로 비교합니다. 이는 O(1) 연산입니다(문자열이 아니라 포인터를 비교함):
// so
func makeTea(arg int) error {
if arg == 42 {
return ErrOutOfTea
}
return nil
}
err := makeTea(42)
if err == ErrOutOfTea {
println("out of tea")
}
// c
static so_Error makeTea(so_int arg) {
if (arg == 42) {
return main_ErrOutOfTea;
}
return NULL;
}
so_Error err = makeTea(42);
if (err == main_ErrOutOfTea) {
so_println("%s", "out of tea");
}
동적 에러(fmt.Errorf), 지역 에러 변수(함수 안의 errors.New), 에러 래핑은 지원하지 않습니다.
defer는 둘러싼 범위가 끝날 때 실행할 함수 또는 메서드 호출을 예약합니다.
그 범위는 함수일 수도 있고(Go와 같음):
// so
func funcScope() {
xopen(&state)
defer xclose(&state)
if state != 1 {
panic("unexpected state")
}
}
또는 이름 없는 블록일 수도 있습니다(Go와 다름):
// so
func blockScope() {
{
xopen(&state)
defer xclose(&state)
if state != 1 {
panic("unexpected state")
}
// xclose(&state) runs here, at block end
}
// state is already closed here
}
지연된 호출은 LIFO 순서로 인라인 생성됩니다(반환, panic, 범위 종료 전에 삽입됨):
// c
static void funcScope(void) {
xopen(&state);
if (state != 1) {
xclose(&state);
so_panic("unexpected state");
}
xclose(&state);
}
defer는 for나 if 같은 다른 범위 안에서는 지원되지 않습니다.
so:include로 C 헤더 파일을 포함합니다:
//so:include <stdio.h>
so:extern으로 외부 C 타입을 선언할 수 있습니다(출력에서는 제외됨):
//so:extern FILE
type os_file struct{}
외부 C 함수 선언(본문이 없거나 so:extern 사용):
func fopen(path string, mode string) *os_file
//so:extern
func fclose(stream *os_file) int {
_ = stream
return 0
}
extern 함수를 호출할 때 string과 []T 인수는 자동으로 해당하는 C 형태로 decay됩니다. 문자열 리터럴은 원시 C 문자열("hello")이 되고, 문자열 값은 char*, 슬라이스는 원시 포인터가 됩니다. 덕분에 상호운용이 더 깔끔해집니다:
// so
f := fopen("/tmp/test.txt", "w")
// c
os_file* f = fopen("/tmp/test.txt", "w");
// not like this:
// fopen(so_str("/tmp/test.txt"), so_str("w"))
이 decay 동작은 nodecay 플래그로 끌 수 있습니다:
//so:extern nodecay
func set_name(acc *Account, name string)
so/c 패키지에는 C 포인터를 다시 So 문자열과 슬라이스 타입으로 변환하는 도우미가 들어 있습니다. unsafe 패키지도 사용할 수 있으며, 컴파일러 내장 기능으로 구현되어 있습니다.
각 Go 패키지는 포함된 .go 파일 수와 관계없이 하나의 .h + .c 쌍으로 변환됩니다. 같은 패키지의 여러 .go 파일은 하나의 .c 파일로 합쳐지며, // -- filename.go -- 주석으로 구분됩니다.
공개 심볼(대문자로 시작하는 이름)은 패키지 이름이 접두사로 붙습니다:
// geom/geom.go
package geom
const Pi = 3.14159
func RectArea(width, height float64) float64 {
return width * height
}
다음과 같이 됩니다:
// geom.h
extern const double geom_Pi;
double geom_RectArea(double width, double height);
// geom.c
const double geom_Pi = 3.14159;
double geom_RectArea(double width, double height) { ... }
비공개 심볼(소문자 이름)은 원래 이름을 유지하고 static으로 표시됩니다:
// c
static double rectArea(double width, double height);
공개 심볼은 .h 파일에 선언되고(변수는 extern 사용), 비공개 심볼은 .c 파일에만 나타납니다.
So 패키지를 import하면 C의 #include로 변환됩니다:
// so
import "example/geom"
// c
#include "geom/geom.h"
가져온 심볼을 호출할 때는 패키지 접두사를 사용합니다:
// so
a := geom.RectArea(5, 10)
_ = geom.Pi
// c
double a = geom_RectArea(5, 10);
(void)geom_Pi;
이것으로 언어 둘러보기는 끝입니다!
So는 여러 GCC/Clang 확장을 사용하는 C11 코드를 생성합니다:
0b1010)({...}))__attribute__((constructor))__auto_type__typeof__make() 및 기타 동적 스택 할당을 위한 alloca변환된 C 코드는 GCC, Clang, 또는 zig cc로 컴파일할 수 있습니다. MSVC는 지원하지 않습니다.
지원 운영체제: Linux, macOS, Windows(부분 지원).
So는 매우 강한 취향을 가진 언어입니다.
단순함이 핵심입니다. 기능은 적을수록 항상 더 좋습니다. 새로운 기능은 기본적으로 강하게 억제되어야 하며, 아주 설득력 있는 실제 사용 사례가 있을 때만 추가되어야 합니다. 이는 표준 라이브러리에도 적용됩니다 — So는 실제 사용에 충분히 유용하면서도 Go 표준 라이브러리 API는 가능한 한 적게 노출하려고 합니다.
힙 할당 금지. 언어 내장 기능(맵, 슬라이스, new, append 등)에서는 힙 할당이 허용되지 않습니다. 표준 라이브러리에서는 힙 할당이 가능하지만, 언제 할당이 일어나는지와 할당된 데이터를 누가 소유하는지를 명확히 밝혀야 합니다.
빠르고 쉬운 C 상호운용성. So는 Go 문법을 사용하지만, 본질적으로 자체 표준 라이브러리를 가진 C입니다. So에서 C를 호출하고, C에서 So를 호출하는 일은 언제나 작성하기 쉽고 효율적으로 실행되어야 합니다. C로 변환된 So 표준 라이브러리는 어떤 C 프로젝트에도 쉽게 추가할 수 있어야 합니다.
가독성. 자신들이 읽기 쉬운 C 코드를 생성한다고 주장하는 언어는 여럿 있습니다. 하지만 불행히도 그들이 생성하는 C 코드는 대개 읽기 어렵거나, 잘해봐야 겨우 읽을 만한 수준입니다. So도 이 점에서 완벽하진 않지만(다른 것들보다 낫다고 말할 수는 있어도), 가능한 한 읽기 쉬운 C 코드를 생성하는 것을 목표로 합니다.
Go 호환성. So 코드는 유효한 Go 코드입니다. 예외는 없습니다.
비목표:
순수 성능. 물론 So가 만들어낸 코드보다 더 빠르게 도는 C 코드를 손으로 작성할 수 있습니다. 또한 interface 같은 So의 일부 기능은 현재 단순함을 유지하기 위해 그다지 효율적이지 않은 방식으로 구현되어 있습니다.
C를 완전히 숨기기. So는 C를 대체하는 것이 아니라, C를 더 깔끔하게 작성하는 방법입니다. So를 효과적으로 사용하려면 C를 알아야 합니다.
Go 기능 완전 대응. 적을수록 좋습니다. iterator는 들어오지 않을 것이고, generic method도 마찬가지입니다.
이 질문들은 여러 번 들었기 때문에 답해둘 가치가 있습니다.
왜 Rust/Zig/Odin/다른 언어가 아닌가요?
제가 C와 Go를 좋아하기 때문입니다.
왜 TinyGo가 아닌가요?
TinyGo는 가볍지만, 여전히 가비지 컬렉터와 런타임이 있고, 모든 Go 기능 지원을 목표로 합니다. 제가 원하는 것은 그보다 더 단순한 것으로, 런타임이 전혀 없고, 소스 수준 C 상호운용성을 가지며, 궁극적으로는 Go의 표준 라이브러리가 순수 C로 포팅되어 일반적인 C 프로젝트에서도 사용될 수 있는 형태입니다.
So는 메모리를 어떻게 다루나요?
모든 것은 기본적으로 스택에 할당됩니다. 가비지 컬렉터도 참조 카운팅도 없습니다. 필요할 경우 표준 라이브러리의 so/mem 패키지가 명시적인 힙 할당을 제공합니다.
안전한가요?
So 자체에는 기본적인 Go 타입 검사를 제외하면 보호 장치가 많지 않습니다. 배열 범위를 벗어난 접근에서는 panic이 발생하지만, 수명이 끝난 포인터를 반환하거나 할당한 메모리를 해제하지 않는 실수까지 막아주지는 않습니다.
메모리 관련 문제의 대부분은 최신 컴파일러의 AddressSanitizer로 잡아낼 수 있으므로, 개발 중에는 CFLAGS에 -fsanitize=address를 추가해 활성화하길 권장합니다.
C에서 So 코드를 사용할 수 있나요(그리고 그 반대도 가능한가요)?
예. So는 순수 C로 컴파일되므로, C에서 So를 호출하는 것은 그냥 C에서 C를 호출하는 일입니다. So에서 C를 호출하는 것도 똑같이 간단합니다.
기존 Go 패키지를 So로 컴파일할 수 있나요?
사실상 어렵습니다. Go는 자동 메모리 관리를 사용하지만, So는 수동 메모리 관리를 사용합니다. So가 지원하는 기능도 Go보다 훨씬 적습니다. Go 표준 라이브러리도, 서드파티 패키지도 수정 없이 So에서 동작하지 않습니다.
얼마나 안정적인가요?
현재로서는 프로덕션용이 아닙니다.
표준 라이브러리는 어디 있나요?
점점 늘어나는 고수준 패키지 집합이 있습니다(so/bytes, so/mem, so/slices, ...). libc API를 감싼 저수준 패키지들도 있습니다(so/c/stdlib, so/c/stdio, so/c/cstring, ...). 자세한 내용은 아래 링크를 확인하세요.
아직 So가 프로덕션 준비가 된 것은 아니지만, 이 개념이 마음에 든다면 취미 프로젝트에서 한 번 써보거나 계속 지켜봐 주셨으면 합니다.
더 읽어볼 거리:
새 글을 놓치지 않으려면 ★Subscribe 하세요.
21 Mar, 2026