Go의 인터페이스와 Rust의 트레이트처럼, 클래스 계층이나 상속 없이 다형성을 C에서 구현해보는 방법을 살펴본다.
URL: https://antonz.org/interfaces-in-c/
Title: Interfaces and traits in C
모두가 Go의 인터페이스와 Rust의 트레이트를 좋아합니다. 클래스 기반 계층이나 상속 없이도 가능한 다형성이야말로 이상적인 지점처럼 보입니다. 그렇다면 이걸 C에서 구현해보면 어떨까요?
Go의 인터페이스• Rust의 트레이트• 장난감 예제• 인터페이스 정의• 인터페이스 데이터• 메서드 테이블• 구현체에 메서드 테이블 두기• 타입 단언• 마무리 생각
Go에서 인터페이스는 어떤 유용한 동작에 대한 계약(contract)을 정의하는 편리한 방법입니다. 예를 들어, 널리 쓰이는 io.Reader를 보죠:
// Reader is the interface that wraps the basic Read method.
type Reader interface {
// Read reads up to len(p) bytes into p. It returns the number of bytes
// read (0 <= n <= len(p)) and any error encountered.
Read(p []byte) (n int, err error)
}
호출자가 제공한 바이트 슬라이스에 데이터를 읽어 넣을 수 있는 것이라면 무엇이든 Reader입니다. 아주 편리하죠. 코드는 데이터가 어디서 오는지(메모리, 파일 시스템, 네트워크 등)를 신경 쓸 필요가 없습니다. 중요한 건 슬라이스에 데이터를 읽어 넣을 수 있다는 점뿐입니다:
// work processes the data read from r.
func work(r io.Reader) int {
buf := make([]byte, 8)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
panic(err)
}
// ...
return n
}
Edit 어떤 종류의 리더라도 제공할 수 있습니다:
func main() {
var total int
b := bytes.NewBufferString("hello world")
// bytes.Buffer implements io.Reader, so we can use it with work.
total += work(b)
total += work(b)
fmt.Println("total =", total)
}
Edit
Go의 인터페이스는 구조적(structural)입니다. 덕 타이핑과 비슷하죠. 어떤 타입이 io.Reader를 구현한다고 명시적으로 선언할 필요가 없습니다. 그냥 Read 메서드만 있으면 됩니다:
// Zeros is an infinite stream of zero bytes.
type Zeros struct{}
func (z Zeros) Read(p []byte) (n int, err error) {
clear(p)
return len(p), nil
}
Edit 나머지는 Go 컴파일러와 런타임이 처리해줍니다:
func main() {
var total int
var z Zeros
// Zeros implements io.Reader, so we can use it with work.
total += work(z)
total += work(z)
fmt.Println("total =", total)
}
Rust에서 트레이트(trait)도 특정 동작에 대한 계약을 정의하는 방법입니다. std::io::Read 트레이트를 보겠습니다:
// The Read trait allows for reading bytes from a source.
pub trait Read {
// Readers are defined by one required method, read(). Each call to read()
// will attempt to pull bytes from this source into a provided buffer.
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
// ...
}
Go와 달리, Rust에서는 타입이 트레이트를 구현한다고 명시적으로 선언해야 합니다:
// An infinite stream of zero bytes.
struct Zeros;
impl io::Read for Zeros {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
buf.fill(0);
Ok(buf.len())
}
}
Edit 나머지는 Rust 컴파일러가 처리합니다:
// Processes the data read from r.
fn work(r: &mut dyn io::Read) -> usize {
let mut buf = [0; 8];
match r.read(&mut buf) {
Ok(n) => n,
Err(e) => panic!("Error: {}", e),
}
}
fn main() {
let mut total = 0;
let mut z = Zeros;
// Zeros implements Read, so we can use it with work.
total += work(&mut z);
total += work(&mut z);
println!("total = {}", total);
}
Edit 어쨌든 Go든 Rust든 호출자는 특정 구현이 아니라 (인터페이스/트레이트로 정의된) 계약에만 관심을 둡니다.
에러 처리 없는 Reader의 더 단순한 버전을 만들어봅시다(Go):
// Reader an interface that wraps the basic Read method.
// Read reads up to len(p) bytes into p.
type Reader interface {
Read(p []byte) int
}
Edit 사용 예:
// Zeros is an infinite stream of zero bytes.
type Zeros struct {
total int // total number of bytes read
}
// Read reads len(p) bytes into p.
func (z *Zeros) Read(p []byte) int {
clear(p)
z.total += len(p)
return len(p)
}
// work processes the data read from r.
func work(r Reader) int {
buf := make([]byte, 8)
return r.Read(buf)
}
func main() {
z := new(Zeros)
work(z)
work(z)
fmt.Println("total =", z.total)
}
Edit 이걸 C에서 어떻게 할 수 있는지 봅시다!
C의 주요 구성 요소는 구조체와 함수이니, 이를 사용합시다. Reader는 Read라는 단일 필드를 가진 구조체가 됩니다. 이 필드는 올바른 시그니처를 가진 함수 포인터입니다:
// An interface that wraps the basic Read method.
// Read reads up to len(p) bytes into p.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
} Reader;
Edit
Zeros를 완전히 동적으로 만들기 위해 Read 함수 포인터를 가진 구조체로 바꿉시다(알아요, 알아요 — 일단 참고 봐주세요):
// An infinite stream of zero bytes.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
size_t total;
} Zeros;
Edit
Zeros_Read “메서드” 구현은 다음과 같습니다:
// Reads up to len(p) bytes into p.
size_t Zeros_Read(void* self, uint8_t* p, size_t len) {
Zeros* z = (Zeros*)self;
for (size_t i = 0; i < len; i++) {
p[i] = 0;
}
z->total += len;
return len;
}
Edit
work는 아주 명확하죠:
// Does some work reading from r.
size_t work(Reader* r) {
uint8_t buf[8];
return r->Read(r, buf, sizeof(buf));
}
Edit
그리고 마지막으로 main 함수:
int main(void) {
Zeros z = {.Read = Zeros_Read, .total = 0};
Reader* r = (Reader*)&z;
work(r);
work(r);
printf("total = %zu\n", z.total);
}
Edit
Zeros를 Reader로 바꾸는 게 얼마나 쉬운지 보세요. (Reader*)&z만 있으면 됩니다. 꽤 멋지죠?
사실 그렇지 않습니다. 이 구현은(Reader 정의를 제외하면) 거의 모든 면에서 심각하게 잘못되어 있습니다.
메모리 오버헤드. 각 Zeros 인스턴스는 “메서드”로서 자기만의 함수 포인터(64비트 시스템에서 함수당 8바이트)를 가집니다. 몇 개 없더라도 실용적이지 않습니다. 일반 객체는 함수가 아니라 데이터를 저장해야 합니다.
레이아웃 의존성. (Reader*)&z 같은 방식의 Zeros*→Reader* 변환은 두 구조체가 첫 번째 멤버로 동일한 Read 필드를 가질 때만 동작합니다. 다른 인터페이스를 하나 더 구현하려고 하면:
// Reader interface.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
} Reader;
// Closer interface.
typedef struct {
void (*Close)(void* self);
} Closer;
// Zeros implements both Reader and Closer.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
void (*Close)(void* self);
size_t total;
} Zeros;
모든 게 무너집니다:
int main(void) {
Zeros z = {
.Read = Zeros_Read,
.Close = Zeros_Close,
.total = 0,
};
Closer* c = (Closer*)&z; // (X)
c->Close(c);
}
Segmentation fault: 11
Closer와 Zeros의 레이아웃이 다르기 때문에 ⓧ의 타입 변환은 유효하지 않으며 정의되지 않은 동작(undefined behavior)을 일으킵니다.
타입 안전성 부족. Zeros_Read에서 리시버로 void*를 쓰면 호출자가 어떤 타입이든 넘길 수 있고, 컴파일러는 경고조차 하지 않습니다:
int main(void) {
int x = 42;
uint8_t buf[8];
Zeros_Read(&x, buf, sizeof(buf)); // bad decision
}
size_t Zeros_Read(void* self, uint8_t* p, size_t len) {
Zeros* z = (Zeros*)self;
// ...
z->total += len; // consequences
return len;
}
Abort trap: 6
C가 아주 타입 안전한 언어는 아니지만, 이건 너무합니다. 다른 방법을 써봅시다.
더 나은 방법은 인터페이스에 실제 객체에 대한 참조를 저장하는 것입니다:
// An interface that wraps the basic Read method.
// Read reads up to len(p) Zeros into p.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
void* self;
} Reader;
Read메서드가void*대신Reader를 받도록 할 수도 있지만, 실질적인 이점 없이 구현만 더 복잡해집니다. 그래서void*로 유지하겠습니다.
그 다음 Zeros는 자기 필드만 가지면 됩니다:
// An infinite stream of zero bytes.
typedef struct {
size_t total;
} Zeros;
Edit
이제 Zeros_Read 메서드를 타입 안전하게 만들 수 있습니다:
// Reads len(p) bytes into p.
size_t Zeros_Read(Zeros* z, uint8_t* p, size_t len) {
for (size_t i = 0; i < len; i++) {
p[i] = i % 256;
}
z->total += len;
return len;
}
Edit
이를 동작시키기 위해 인스턴스를 Reader 인터페이스로 감싸서 반환하는 Zeros_Reader 메서드를 추가합니다:
// Returns a Reader implementation for Zeros.
Reader Zeros_Reader(Zeros* z) {
return (Reader){
.Read = (size_t (*)(void*, uint8_t*, size_t))Zeros_Read,
.self = z,
};
}
Edit
work와 main은 꽤 단순하게 유지됩니다:
// Does some work reading from r.
size_t work(Reader r) {
uint8_t buf[8];
return r.Read(r.self, buf, sizeof(buf));
}
int main(void) {
Zeros z = {0};
Reader r = Zeros_Reader(&z);
work(r);
work(r);
printf("total = %zu\n", z.total);
}
Edit 이 접근은 이전 것보다 훨씬 낫습니다:
Zeros 구조체가 가볍고 인터페이스 관련 필드가 없습니다.Zeros_Read 메서드가 void* 대신 Zeros*를 받습니다.Zeros에서 Reader로의 캐스트는 Zeros_Reader 내부에서 처리됩니다.이제
Zeros타입이(Zeros_Reader를 통해)Reader인터페이스를 알고 있으므로, 이 구현은 진정한 Go 인터페이스라기보다 Rust 트레이트의 기본 버전에 가깝습니다. 단순화를 위해 계속 “인터페이스”라는 용어를 쓰겠습니다.
다만 단점이 하나 있습니다. 각 Reader 인스턴스는 인터페이스 메서드마다 함수 포인터를 하나씩 갖습니다. Reader는 메서드가 하나뿐이라 문제가 없지만, 어떤 인터페이스가 열두 개의 메서드를 가지고 프로그램에서 이런 인터페이스 인스턴스를 많이 사용한다면 문제가 될 수 있습니다.
이를 고쳐봅시다.
인터페이스 메서드를 별도의 구조체(메서드 테이블)로 추출합시다. 인터페이스는 mtab 필드를 통해 메서드들을 참조합니다:
// An interface that wraps the basic Read method.
// Read reads up to len(p) bytes into p.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
} ReaderTable;
typedef struct {
const ReaderTable* mtab;
void* self;
} Reader;
Edit
Zeros와 Zeros_Read는 전혀 바뀌지 않습니다:
// An infinite stream of zero bytes.
typedef struct {
size_t total;
} Zeros;
// Reads len(p) bytes into p.
size_t Zeros_Read(Zeros* z, uint8_t* p, size_t len) {
for (size_t i = 0; i < len; i++) {
p[i] = i % 256;
}
z->total += len;
return len;
}
Edit
Zeros_Reader는 정적 메서드 테이블을 초기화하고, 이를 인터페이스 인스턴스에 할당합니다:
// Returns a Reader implementation for Zeros.
Reader Zeros_Reader(Zeros* z) {
// The method table is only initialized once.
static const ReaderTable impl = {
.Read = (size_t (*)(void*, uint8_t*, size_t))Zeros_Read,
};
return (Reader){.mtab = &impl, .self = z};
}
Edit
work에서의 유일한 차이는 메서드 테이블을 사용해 인터페이스의 Read 메서드를 간접 호출한다는 점입니다(r.Read 대신 r.mtab->Read):
// Does some work reading from r.
size_t work(Reader r) {
uint8_t buf[8];
return r.mtab->Read(r.self, buf, sizeof(buf));
}
Edit
main은 동일합니다:
int main(void) {
Zeros z = {0};
Reader r = Zeros_Reader(&z);
work(r);
work(r);
printf("total = %zu\n", z.total);
}
Edit
이제 Reader 인스턴스는 메서드를 위한 포인터 필드를 항상 하나만 가집니다. 그래서 큰 인터페이스라도 16바이트(mtab + self)만 사용합니다. 이 접근은 이전 버전의 장점도 모두 유지합니다:
Zeros 구조체.Zeros에서 Reader로의 쉬운 변환.클라이언트가 r.mtab->Read 같은 구현 세부사항을 신경 쓰지 않도록 별도의 Reader_Read 헬퍼를 추가할 수도 있습니다:
// Reads len(p) bytes into p.
size_t Reader_Read(Reader r, uint8_t* p, size_t len) {
return r.mtab->Read(r.self, p, len);
}
// Does some work reading from r.
size_t work(Reader r) {
uint8_t buf[8];
return Reader_Read(r, buf, sizeof(buf));
}
좋습니다!
다른 접근도 있습니다. 저는 별로 좋아하지 않지만 완전성을 위해 언급할 가치는 있습니다.
인터페이스에 Reader 메서드 테이블을 넣는 대신, 구현체(Zeros)에 넣는 방식입니다:
// An interface that wraps the basic Read method.
// Read reads up to len(p) bytes into p.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
} ReaderTable;
typedef ReaderTable* Reader;
// An infinite stream of zero bytes.
typedef struct {
Reader mtab;
size_t total;
} Zeros;
Edit
Zeros 생성자에서 메서드 테이블을 초기화합니다:
// Returns a new Zeros instance.
Zeros NewZeros(void) {
static const ReaderTable impl = {
.Read = (size_t (*)(void*, uint8_t*, size_t))Zeros_Read,
};
return (Zeros){
.mtab = (Reader)&impl,
.total = 0,
};
}
Edit
이제 work는 Reader 포인터를 받습니다:
// Does some work reading from r.
size_t work(Reader* r) {
uint8_t buf[8];
return (*r)->Read(r, buf, sizeof(buf));
}
Edit
그리고 main은 간단한 타입 캐스트로 Zeros*를 Reader*로 변환합니다:
int main(void) {
Zeros z = NewZeros();
Reader* r = (Reader*)&z;
work(r);
work(r);
printf("total = %zu\n", z.total);
}
Edit
이 방식은 Zeros를 꽤 가볍게 유지하며 mtab 필드 하나만 추가합니다. 하지만 (Reader*)&z 캐스트는 Reader mtab이 Zeros의 첫 번째 필드이기 때문에 동작하는 것뿐입니다. 두 번째 인터페이스를 구현하려 하면 처음 솔루션과 마찬가지로 망가집니다.
저는 “인터페이스에 메서드 테이블을 두는” 접근이 훨씬 낫다고 생각합니다.
Go에는 소스(리더)에서 목적지(라이터)로 데이터를 복사하는 io.Copy 함수가 있습니다:
func Copy(dst Writer, src Reader) (written int64, err error)
문서에 흥미로운 코멘트가 있습니다:
src가WriterTo를 구현한다면, 복사는src.WriteTo(dst)를 호출하여 구현됩니다. 그렇지 않고dst가ReaderFrom을 구현한다면, 복사는dst.ReadFrom(src)를 호출하여 구현됩니다.
함수는 대략 이렇게 생겼습니다:
func Copy(dst Writer, src Reader) (written int64, err error) {
// If the reader has a WriteTo method, use it to do the copy.
// Avoids an allocation and a copy.
if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rf, ok := dst.(ReaderFrom); ok {
return rf.ReadFrom(src)
}
// The default implementation using regular Reader and Writer.
// ...
}
src.(WriterTo)는 타입 단언(type assertion)으로, src 리더가 단순히 Reader일 뿐 아니라 WriterTo 인터페이스도 구현하는지 확인합니다. Go 런타임이 이런 동적 타입 체크를 처리합니다.
C에서도 이런 걸 할 수 있을까요? 저는 완전히 동적으로 만들고 싶진 않습니다. C에서 Go 런타임의 일부를 재현하려고 드는 건 아마 좋은 생각이 아닐 테니까요.
대신 Reader 인터페이스에 선택적(optional) AsWriterTo 메서드를 추가할 수 있습니다:
// An interface that wraps the basic Read method.
// Read reads up to len(p) bytes into p.
typedef struct {
// required
size_t (*Read)(void* self, uint8_t* p, size_t len);
// optional
WriterTo (*AsWriterTo)(void* self);
} ReaderTable;
typedef struct {
const ReaderTable* mtab;
void* self;
} Reader;
그러면 주어진 Reader가 WriterTo이기도 한지 쉽게 확인할 수 있습니다:
void work(Reader r) {
// Check if r implements WriterTo.
if (r.mtab->AsWriterTo) {
WriterTo wt = r.mtab->AsWriterTo(r.self);
// Use r as WriterTo...
return;
}
// Use r as a regular Reader...
return;
}
하지만 이건 어딘가 해킹처럼 느껴지기도 합니다. 정말 필요하지 않다면 타입 단언 사용은 피하고 싶습니다.
C에서 인터페이스(정확히는 트레이트에 더 가깝지만)를 만드는 건 가능합니다. 다만 Go나 Rust만큼 단순하거나 우아하진 않습니다. 우리가 논의한 메서드 테이블 접근은 좋은 출발점입니다. 메모리 효율적이고, C의 한계 내에서 가능한 한 타입 안전하며, 다형적 동작을 지원합니다.
관심이 있다면 전체 소스 코드는 다음과 같습니다:
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
// An interface that wraps the basic Read method.
// Read reads up to len(p) bytes into p.
typedef struct {
size_t (*Read)(void* self, uint8_t* p, size_t len);
} ReaderTable;
typedef struct {
const ReaderTable* mtab;
void* self;
} Reader;
// Reads len(p) bytes into p.
size_t Reader_Read(Reader r, uint8_t* p, size_t len) {
return r.mtab->Read(r.self, p, len);
}
// An infinite stream of zero bytes.
typedef struct {
size_t total;
} Zeros;
// Reads len(p) bytes into p.
size_t Zeros_Read(Zeros* z, uint8_t* p, size_t len) {
for (size_t i = 0; i < len; i++) {
p[i] = i % 256;
}
z->total += len;
return len;
}
// Returns a Reader implementation for Zeros.
Reader Zeros_Reader(Zeros* z) {
// The method table is only initialized once.
static const ReaderTable impl = {
.Read = (size_t (*)(void*, uint8_t*, size_t))Zeros_Read,
};
return (Reader){.mtab = &impl, .self = z};
}
// Does some work reading from r.
size_t work(Reader r) {
uint8_t buf[8];
return Reader_Read(r, buf, sizeof(buf));
}
int main(void) {
Zeros z = {0};
Reader r = Zeros_Reader(&z);
work(r);
work(r);
printf("total = %zu\n", z.total);
}
Edit 건승을 빕니다!
새 글을 계속 받아보려면 ★Subscribe 하세요.