C에서의 관용적 ‘메서드’ 패턴을 짚어 보고, 최신 시스템 프로그래밍 언어들이 메서드를 채택하거나 배제하는 이유와 그 의미를 살펴본다.
저는 이 글을 한 달쯤 띄엄띄엄 쓰다가, 결국 제 노트북에 영원히 커밋되지 않은 채로 남아 있을 것 같다는 걸 깨달았습니다. 간헐적으로 업데이트되는 블로그를 가진 사람이라면 누구나 공감할 거예요. 뭔가가 제게는 느낌 상 잘못된 것 같은데, 왜 그런지 정확히 짚어내질 못하겠습니다.
시간을 훌쩍 넘기고, 이 저주받은 글을 마지막으로 고친 지 두 달이 지났습니다. 영감이 치솟아 블로그 글을 쓰러 갑니다. Vim을 엽니다. 그러자 _이_게 저를 맞이합니다.
Untracked (7)
? content/methods.md
? ... a couple more unpublished articles
“오”, 저는 생각했습니다. “너를 잊고 있었네! 이거 대충 한 번만 훑어서 고치면 드디어 끝나겠지.” 그리고 저는 지금 여기서, 세 시간과 수많은 CSS 만지작거림 끝에 이 글자를 치고 있습니다.
에라 모르겠다, git add 할 시간입니다. 더는 편집하지 않겠습니다. 두서없는 문장, 어디론가 갈 듯하다가 그냥 … 안 가는 주장들, 그리고 전반적으로 불편한 분위기는 양해 부탁드립니다. 저 섹션 제목들 좀 보세요. 절 놀리는 것 같죠? 그리고 코드 블록들, 아 세상에 코드 블록이 너무 많아 a͟a͜a̴̧͙̥̟ͫ̂̐ͪ̔͂̀ḁ̶͔̦̆̎̓ą͈̰̞̻̩̘̩̅̔͐͌ͯ͛ͫ͆ͬa̛̠̻̭̘͕͓̋̽̓̎ͤ́a̵͔̳̗̫̻̦̲ͧ̓́ͧ̎͟á̻͔̏ͬ͜
흠흠.
최근 시스템 프로그래밍 언어들이 폭발적으로 늘었습니다. 그중 일부는 메서드가 있고, 일부는 없습니다. 어떤 의미에서는, 메서드가 이 새로운 세대의 시스템 언어들 사이에서 경계선을 그어 주는 요소이기도 합니다.
C에서는 데이터 구조와, 그 데이터 구조를 대상으로 동작하는 함수들을 한데 묶는 흔한 패턴을 재현하기가 아주 쉽습니다. 예로 동적 배열의 인터페이스를 들어 봅시다.
typedef struct Array {
uint32_t length;
uint32_t capacity;
uint32_t element_size;
float growth_factor;
void *ptr;
} Array;
Array init(
size_t capacity,
size_t element_size,
float growth_factor);
void *at(const Array *a, size_t index);
void push(Array *a, const void *element);
void pop(Array *a, void *element);
void insert(Array *a, const void *element, size_t index);
C에는 어떤 형태의 네임스페이싱도 없으므로, 다른 코드의 발을 밟지 않기 위해 각 함수에 접두사를 붙여 줍니다.
Array array_init(
size_t capacity,
size_t element_size,
float growth_factor);
void *array_at(const Array *a, size_t index);
void array_push(Array *a, const void *element);
void array_pop(Array *a, void *element);
void array_insert(Array *a, const void *element, size_t index);
이렇게 하면 메서드 몇 개를 가진 원시적인 클래스가 됩니다. 즉 데이터 구조 하나와, 그 데이터 구조에 대해 동작하는 함수들입니다. (물론 C에는 제대로 된 상속이 없으니 이걸 정말 클래스라고 부를 수 있을지는 의심스럽지만, 이 글에서는 일단 무시합시다.)
모든 함수에 공통 접두사를 붙이면, 이 함수들이 모두 같은 데이터 구조를 대상으로 동작한다는 점이 더 굳어집니다. 어떤 의미에서는 이 함수들이 정말로 “메서드”가 되는 셈이죠.
이 패턴이 쓰이는 예시는 곳곳에 널려 있습니다. GitHub에서 가장 인기 있는 C 프로젝트 몇 개를 살펴보니 redis, XNU, Neovim, Curl, Nuklear에서 이를 발견했습니다. 거의 보편적이라고 봐도 될 것 같습니다.
앞서 언급했던 현존 C 대체재들 몇 가지를 살펴봅시다. 먼저 Odin부터. Odin은 C의 건전하고 단순하며 재미있는 대안임을 표방하는 새로운 시스템 프로그래밍 언어입니다. Odin에는 메서드가 없습니다. FAQ 페이지에서 그 이유를 설명합니다:
왜 Odin에는 메서드가 없나요?
우리는 데이터와 코드는 분리된 개념이어야 한다고 믿습니다. 데이터는 “행동(behaviour)”을 가져서는 안 됩니다.
프로시저를 사용하세요.
비슷하게, 미니멀리즘을 지향하는 또 다른 시스템 프로그래밍 언어인 Hare 역시 메서드를 포함하지 않습니다. 다만 저는 이 결정의 근거를 찾지 못했습니다. 반대로 Zig와 C3는 둘 다 메서드가 있습니다.
참고로 Redis로 유명한 antirez는 C에 메서드가 있어야 한다고 생각합니다:
@antirez — 4 November 2022
C에 암묵적인 self 포인터로 호출되는 단순한 struct 메서드가 없는 사실에 대한 책임자는 누구인가? Undefined Behaviours가 악몽이 된 것에 대한 책임자는 누구인가? 그리고 마지막으로 libc의 정체에 대한 책임자는 누구인가? 간단한 질문들, 막대한 영향.
강한 의견입니다. 어쩌면 그냥 트위터일 뿐일지도요.
프로그래밍 언어 파서를 예로 들어 봅시다. Zig에서는:
const Parser = struct {
// fields
fn parseStatement(self: *Parser) Statement;
fn parseExpression(self: *Parser) Expression;
fn expect(self: *Parser, message: []const u8);
fn next(self: *Parser);
fn current(self: *const Parser) TokenKind;
};
Odin이나 Hare에서는, 결국 다음과 같은 C와 동등한 코드를 쓰게 될 거라고 생각합니다:
typedef struct Parser {
// fields
} Parser;
Statement parser_parse_statement(Parser *p);
Expression parser_parse_expression(Parser *p);
void parser_expect(Parser *p, const char *message);
void parser_next(Parser *p);
TokenKind parser_current(const Parser *p);
이런 경우라면, 이런 코드(타입 정의와 타입 관련 함수들)를 코드베이스의 나머지로부터 분리된 단위로 묶는 것이 자연스럽다고 느낍니다. 이를테면 parser.h와 parser.c처럼요. 이제 우리는 주변 시스템과 독립적인 코드 모듈을 갖게 됩니다. 그 모듈은 데이터 타입 하나와, 그 데이터 타입 위에서 동작하는 어떤 행동을 포함합니다. 그렇다면 여기서는 “데이터는 ‘행동’을 가져서는 안 된다”가 위배되는 것 아닌가요? 아니라면, 이 모듈과 클래스의 차이는 무엇인가요?
self.next() 대신 parser_next(p)를 쓰도록 강제한다거나, clients.push(client) 대신 array_push(clients, client)를 쓰도록 강제한다고 해서, 데이터와 코드의 분리가 이루어진다고는 저는 생각하지 않습니다.
Eskil Steenberg의 의견은 고전적인 C 스타일의 object_method(o) 메서드가 실제로 일어나는 일을 솔직하게 드러내기 때문에 더 낫다는 것입니다:
사람들은 C에서는 객체 지향을 할 수 없다고 생각하는데, C가 객체 지향 언어가 아니기 때문이죠. 하지만 저는 C에서 객체 지향은 매우 직관적이라고 생각하고, 실제로 다른 언어들보다 C가 더 낫다고 생각합니다. 왜냐하면 다른 언어들은 컴퓨터에 실제로는 존재하지도 않는 객체 지향이라는 것이 있는 것처럼 여러분을 속이려고 하기 때문입니다.
그들은 코드와 데이터가 둘 다 들어 있는 무언가가 있다고 말하려 하지만, 사실은 전혀 그렇지 않습니다.
저는 원칙적으로 이런 주장에 동의하지 않습니다. 예를 들어 struct라는 것도 실제로는 존재하지 않습니다. 결국 특정 오프셋에 특정 데이터가 놓여 있는 바이트들의 자루(bag of bytes)일 뿐이니까요. 언어 설계자들이 만들어내는 각종 추상화는 기술적으로는 존재하지 않지만, 그렇다고 해서 유용할 수 없다는 뜻은 아닙니다.
정말로
uint32_t length = *(uint32_t *)((uint8_t *)(&parser) + 8);
대신
uint32_t length = parser->length;
을(를) 치고 싶지 않다고 해서, “struct는 존재하지 않는다”는 이유로 전자를 택하겠다는 건가요?
반면, 불필요한 언어 복잡성과 추상화를 없애고 싶다는 마음에는 전적으로 공감합니다. 메서드 타이핑을 줄여 주는 (미미한) 이득이, 메서드가 추가하는 (미미한) 복잡성을 상쇄하는지는 논쟁의 여지가 있습니다.
제 생각에 메서드는 _악_이 아닙니다. 때로는 같은 데이터 묶음에 대해 동작하는 함수들을 한 무더기로 갖게 되고, 그런 경우 그것들은 논리적인 그룹에 속하는 것이 맞습니다. 그런 상황에서는 데이터 구조에 메서드 몇 개가 붙어 있는 형태가 저에게는 완전히 괜찮아 보입니다.
저는 Rust, Nim, Crystal, Carbon, D 같은 언어들을 Zig, Hare 등과는 다른 바구니에 넣습니다. 제 머릿속에서 전자는 더 많은 기능, 더 정교한 런타임, 그리고 미니멀리즘에 대한 더 적은 집중을 갖습니다. (이 포인트들 전부 가 모든 언어에 적용되는 것은 아니지만, 전반적인 경향은 그렇습니다.) 이들은 C보다는 C++의 대체재로 더 가깝습니다. 이를 뒷받침하듯, 제가 나열한 그 C++ 대체재들은 모두 메서드를 가지고 있습니다. 따라서 이 글에는 이런 언어들을 포함하지 않았습니다.
Luna Razzaghipour
24 April 2023