Proven C BookEnglish GitHub

56 이름 충돌을 다루는 법 — 접두어에서 namespace까지

먼저 알아야 할 것

54장 여러 파일 — 분할과 링크 · 외부 연결과 내부 연결
55장 이름의 세계 · 네 이름 공간과 세 축

돌아보기

55장에서 C의 이름 공간은 넷뿐이고 새로 만들 수 없다고 했다. 그러면 서로 모르는 두 라이브러리가 init이라는 함수를 각각 내보내면 어떻게 되는가?

답. 둘 다 외부 연결이면 링커가 중복 정의로 거절한다 — 운이 좋은 경우다. 운이 나쁘면 더 고약하다. 한쪽이 정적 라이브러리 안에 있으면 링커가 먼저 찾은 쪽만 쓰고 조용히 넘어간다. 컴파일도 링크도 성공하는데 엉뚱한 함수가 불린다.

그래서 이 문제의 해법은 「충돌하면 고친다」가 아니라 「애초에 충돌할 수 없게 만든다」여야 한다. 이 장이 그 방법들이다.

이 장의 필요성과 맥락

55장이 「무엇이 부딪히는가」였다면 이 장은 「그래서 어떻게 하는가」다. 원리와 처방을 굳이 두 장으로 가른 것은, 처방(접두어·가시성·이름 줄이기)이 원리 없이는 미신이 되기 때문이다. 큰 프로젝트들이 실제로 무엇을 골랐는지도 여기서 함께 본다.

이 장이 끝나면

이름 충돌을 막는 네 가지 무기를 순서대로 세운다 — 내보내지 않기, 접두어 규약, 심볼 가시성, 그리고 이름 자체를 줄이기. 큰 프로젝트들이 실제로 무엇을 택했는지도 함께 본다. 마지막으로 C++ 가 이 자리를 namespace로 어떻게 풀었는지, 그리고 두 언어를 섞을 때 무엇을 조심해야 하는지까지 간다.

이 장에서 답할 질문

  1. multiple definition 은 오히려 반가운 오류인가?
  2. 내가 실제로 무엇을 내보내고 있는지 어떻게 확인하는가?
  3. 그러면 C도 이름 공간을 언어에 들여와야 하는가?

56.1 충돌은 언제 어디서 터지는가

같은 철자가 부딪히는 자리는 셋이고, 뒤로 갈수록 진단이 어렵다.

언제무엇이 말해 주는가얼마나 다루기 쉬운가
컴파일 시같은 스코프에 두 번 선언 — 컴파일러가 즉시 거절쉽다. 그 자리에서 보인다
링크 시multiple definition of 'init'보통. 어느 파일인지 메시지가 알려 준다
조용히아무도 말해 주지 않는다어렵다. 엉뚱한 함수가 불리는데 증상만 보인다

표 56.1

세 번째가 이 장이 존재하는 이유다. 조용한 충돌이 생기는 자리를 셋만 들어 둔다.

문. multiple definition 은 오히려 반가운 오류인가?

답. 그렇다. 들리는 충돌이니까. 진짜 위험한 것은 링커가 아무 말 없이 한쪽을 고르는 경우다.

그래서 규율의 방향은 「충돌을 잘 고치자」가 아니라 「충돌이 생기면 반드시 들리게 하자」가 된다. 내보내는 이름을 최소로 줄이면 (a) 부딪힐 확률이 줄고 (b) 부딪히면 링커가 소리를 낸다. 아래 네 무기가 전부 그 방향이다.

56.2 첫째 무기 — 내보내지 않기(static)

가장 확실한 방어는 이름을 바깥 마당에 내놓지 않는 것이다. 파일 수준 선언에 static을 붙이면 내부 연결이 되어(54장) 그 번역 단위 밖에서는 존재하지 않는다.

규율은 한 줄로 요약된다 — 기본을 static으로 두고, 헤더에 적을 것만 푼다. 덤도 있다. 컴파일러가 「이 이름은 다른 파일에서 불릴 리 없다」를 알기에 더 공격적으로 최적화할 수 있다(인라인, 미사용 함수 제거).

반례. 헤더에 쓸 것이 아닌데도 전역으로 두기

/* util.c */
int helper(int x) {}         /* 헤더에 없는데 외부 연결이다 */

이 함수는 아무도 부르지 않는데도 링커의 마당에 이름을 하나 차지한다. 언젠가 다른 파일이 우연히 같은 이름을 만들면 그때야 문제가 드러난다. static을 붙이는 데 드는 비용은 낱말 하나다.

56.3 둘째 무기 — 접두어 규약

내보내야 하는 이름에는 담장을 손으로 세운다. C 라이브러리의 보편적 관행이고, 큰 프로젝트일수록 예외가 없다.

프로젝트접두어무엇에까지 붙이는가
SQLitesqlite3_함수·타입·상수 전부. 버전 번호까지 접두어에 넣었다
libcurlcurl_, CURL함수는 소문자, 상수·타입은 대문자 계열
zlibz, inflate/deflate역사가 오래된 만큼 접두어가 짧다 — 그래서 가끔 부딪힌다
OpenSSLSSL_, EVP_, X509_모듈마다 다른 접두어를 둔 형태
GLib·GTKg_, gtk_, G_, GTK_타입은 GtkWidget처럼 낙타등으로
SDLSDL_함수·타입·상수·매크로 전부
리눅스 커널서브시스템 이름kmalloc, vfs_, sock_ 등 — 층을 접두어로

표 56.2

읽어 낼 수 있는 설계 규칙이 넷 있다.

  1. 짧게, 그러나 유일하게. zlib 의 z는 짧아서 편하지만 그만큼 부딪히기 쉽다. 오늘 새로 짓는다면 서너 글자가 무난하다.
  2. 타입과 매크로에도 붙인다. 함수만 가리는 접두어는 절반짜리 담장이다 — 타입 이름과 열거 상수가 같은 마당에 산다는 것을 55장에서 보았다.
  3. 대소문자로 층을 나눈다. 소문자는 함수, 대문자는 매크로·상수라는 관행을 접두어와 함께 쓰면 이름만 보고도 정체가 드러난다.
  4. 문서에 적는다. 「우리 프로젝트의 접두어는 이것이다」를 규약 문서에 한 줄 적어 두는 것이, 나중에 코드 검토에서 백 번 지적하는 것보다 싸다.

실제 사례. 접두어가 없던 시절의 유산

표준 라이브러리 자체가 접두어 없는 이름의 박물관이다 — open, read, write, time, index, link. 1970년대에는 프로그램 하나가 쓰는 라이브러리가 몇 개 되지 않았으니 문제가 아니었다.

그 결과가 오늘의 흔한 사고다. 자기 코드에 readtime이라는 함수를 만들면 표준 함수를 가려 버리고, 그 함수를 부르는 다른 코드까지 조용히 다른 데로 간다. 55장의 예약 이름 표가 그래서 있는 것이고, 「짧고 흔한 낱말은 이미 누군가 가져갔다고 가정하라」가 실무의 첫 수칙이다.

56.4 셋째 무기 — 심볼 가시성

접두어는 사람이 지키는 규약이라 새는 곳이 생긴다. 빌드가 강제하게 만드는 층이 하나 더 있다 — 공유 라이브러리의 심볼 가시성이다.

플랫폼기본값을 감추는 법내보낼 것만 표시하는 법
ELF (리눅스·BSD)-fvisibility=hidden__attribute__((visibility("default")))
macOS-fvisibility=hidden같은 속성, 또는 내보낼 심볼 목록 파일
윈도기본이 이미 감춤__declspec(dllexport) 또는 .def 파일

표 56.3

관행은 매크로 하나로 감싸는 것이다.

#if defined(_WIN32)
#  define MYLIB_API __declspec(dllexport)
#elif defined(__GNUC__)
#  define MYLIB_API __attribute__((visibility("default")))
#else
#  define MYLIB_API
#endif

MYLIB_API int mylib_open(const char *path);

효과가 셋이다 — 이름 충돌이 줄고, 적재가 빨라지며(풀어야 할 심볼이 적으니), 바꿔도 되는 내부 함수와 못 바꾸는 공개 함수의 경계가 코드에 적힌다.

문. 내가 실제로 무엇을 내보내고 있는지 어떻게 확인하는가?

답. 세어 보면 된다. 목적 파일이나 라이브러리에서 정의된 전역 심볼을 뽑는다.

nm -g --defined-only mylib.o      # 목적 파일
nm -D --defined-only libmy.so     # 공유 라이브러리(동적 심볼)
objdump -T libmy.so               # 같은 일을 다른 도구로

이 장의 시연을 이 방식으로 재어 보면 내보낸 이름이 maintextbuf 둘뿐이다 — 안쪽 함수들은 전부 static이라 목록에 없다.

좋은 습관 하나: 공개 심볼 목록을 파일로 떠 두고 빌드에서 비교한다. 새 이름이 뜻하지 않게 새어 나가면 빌드가 알려 준다. 12장 사다리의 3단(빌드가 알리게)을 이름에 적용한 형태다.

56.5 넷째 무기 — 이름 자체를 줄이기

담장을 잘 세우는 것보다 나은 것은 내놓을 이름이 애초에 적은 것이다. 모듈이 함수 스무 개를 내보내는 대신, 함수 포인터를 담은 구조체 하나만 내보내는 무늬가 있다.

examples/ch56/prefix.c

/* 「모듈 하나 = 외부 심볼 하나」 — 접두어 규약과 함수 포인터 표.
   C 에는 사용자가 만드는 이름 공간이 없으니, 내보내는 이름을 줄이는 것이
   가장 확실한 방어다. */
#include <stdio.h>
#include <string.h>

/* ── 안쪽 구현은 전부 내부 연결(static) — 바깥 마당에 나가지 않는다 ── */
static int  buf_len;
static char buf[64];

static void impl_reset(void)                 { buf_len = 0; buf[0] = '\0'; }
static bool impl_push(const char *s)
{
    size_t n = strlen(s);
    if ((size_t)buf_len + n + 1 > sizeof buf) return false;   /* 잘림은 실패다(43장) */
    memcpy(buf + buf_len, s, n + 1);
    buf_len += (int)n;
    return true;
}
static const char *impl_text(void)           { return buf; }

/* ── 밖에 내보내는 것은 이것 하나 ──
   표(vtable) 하나에 접두어를 붙여 두면, 이 번역 단위가 링커에 내미는
   이름은 `textbuf` 단 하나다. 나머지 철자는 다른 파일과 부딪힐 수 없다. */
struct textbuf_api {
    void        (*reset)(void);
    bool        (*push)(const char *);
    const char *(*text)(void);
};

const struct textbuf_api textbuf = {
    .reset = impl_reset,
    .push  = impl_push,
    .text  = impl_text,
};

/* ── 쓰는 쪽 ── */
int main(void)
{
    textbuf.reset();
    puts("[this module exposes exactly one external name: `textbuf`]");
    printf("  push(\"hello \") -> %s\n", textbuf.push("hello ") ? "ok" : "failed");
    printf("  push(\"world\")  -> %s\n", textbuf.push("world")  ? "ok" : "failed");
    printf("  text()         -> \"%s\"\n", textbuf.text());

    char big[80];
    memset(big, 'A', sizeof big - 1);
    big[sizeof big - 1] = '\0';
    printf("  pushing a long string -> %s (truncation does not count as success)\n",
           textbuf.push(big) ? "ok" : "failed");

    puts("\n[the prefix convention - what large projects do]");
    puts("  sqlite3_ / curl_ / SSL_ / g_ / SDL_ ... the same prefix goes on types and macros");
    puts("  too. A prefix is a name space that C lacks, supplied by hand.");
    return 0;
}

실행 결과

[this module exposes exactly one external name: `textbuf`]
  push("hello ") -> ok
  push("world")  -> ok
  text()         -> "hello world"
  pushing a long string -> failed (truncation does not count as success)

[the prefix convention - what large projects do]
  sqlite3_ / curl_ / SSL_ / g_ / SDL_ ... the same prefix goes on types and macros
  too. A prefix is a name space that C lacks, supplied by hand.

이 번역 단위가 링커에 내미는 이름은 textbuf 하나뿐이다(위의 nm 실측). 쓰는 쪽은 textbuf.push(...)처럼 점을 찍어 부르는데, 읽는 모습이 이름 공간을 가진 언어와 비슷해진다는 것이 이 무늬의 덤이다.

대가도 분명히 적어 둔다.

얻는 것치르는 것
외부 이름이 하나로 줄어든다호출이 함수 포인터를 거친다 — 인라인이 어려워진다
구현을 통째로 갈아 끼울 수 있다간접 호출 한 단계만큼 느리다
읽는 쪽이 이름 공간처럼 보인다디버거에서 호출 대상이 한눈에 안 보인다

표 56.4

그래서 이 무늬는 경계에 쓴다 — 플러그인, 백엔드 교체, 시험용 대역(mock). 성능이 중요한 안쪽 고리에는 쓰지 않는다.

56.6 C++ 는 이 자리를 어떻게 풀었는가

C++ 는 「사용자가 마당을 판다」는 기능을 언어에 넣었다. C 프로그래머가 알아야 할 만큼만, 그러나 정확하게 본다.

56.6.1 문법

namespace app {
    int  parse(const char *);
    namespace detail { int helper(int); }   // 중첩
}
namespace app::io { void flush(); }         // C++17 의 축약 표기

int x = app::parse("42");
int y = app::detail::helper(1);

이름 앞에 마당 이름을 붙여 부른다. C의 접두어 규약과 하는 일이 같고, 다른 점은 그것을 언어가 강제하고 도구가 이해한다는 것이다.

56.6.2 익명 이름 공간 — C의 static에 해당

namespace { int hidden(int x) { return x; } }   // 이 번역 단위 전용

실측으로 확인하면 성격이 드러난다.

선언심볼 종류(nm)
static int st(int);t — 지역C 와 같은 내부 연결
익명 이름 공간의 hiddent — 지역같은 효과. 이름은 (anonymous namespace)::hidden
보통의 int use(int);T — 전역외부 연결

표 56.5

둘의 효과는 같지만 C++ 는 익명 이름 공간을 권한다 — static과 달리 타입에도 쓸 수 있고, 템플릿 인자로 넘길 수 있기 때문이다.

56.6.3 using namespace — 편의와 그 대가

using namespace std;는 마당의 담장을 그 자리에서 허문다. 짧은 예제에서는 편하지만, 실무의 규약은 대체로 이렇다.

규약근거
헤더에는 절대 쓰지 않는다그 헤더를 포함한 모든 파일의 담장이 함께 허물어진다
소스 파일에서도 좁게 쓴다using std::string;처럼 이름 하나씩
함수 안에서 쓴다효과가 그 함수로 갇힌다
swap은 예외 — 관용구가 따로 있다아래의 ADL 때문

표 56.6

56.6.4 ADL — 인자를 보고 마당을 찾는다

C++ 에는 인자 의존 탐색(argument-dependent lookup)이 있다. 인자의 타입이 사는 마당을 자동으로 함께 뒤진다.

namespace app { struct Buf {}; void print(const Buf &); }
app::Buf b;
print(b);        // app:: 를 안 적었는데 app::print 가 불린다 — ADL

편리하지만 놀라운 자리이기도 하다. 어떤 함수가 불릴지가 인자의 타입에 따라 달라지므로, 오버로드가 많은 코드에서는 사람이 추적하기 어려워진다. C에는 이런 탐색이 없다 — 이름 하나가 곧 함수 하나다. C의 단순함이 값을 하는 자리로 기억해 두면 좋다.

56.6.5 이름 맹글링과 extern "C"

C++ 는 오버로드를 지원하므로 같은 철자라도 인자 타입이 다르면 다른 함수다. 그러려면 링커가 보는 이름에 타입 정보가 들어가야 한다 — 이것이 맹글링이다.

소스링커가 보는 이름(GCC 실측)
namespace app { int f(int); }_ZN3app1fEi
namespace app { double f(double); }_ZN3app1fEd
extern "C" int c_f(int);c_f

표 56.7

_ZN3app1fEi를 뜯어보면 마당 이름 app(세 글자)과 함수 이름 f(한 글자), 인자 타입 i가 그대로 들어 있다. 이름 공간이 심볼에 새겨지는 것이다.

그래서 C와 C++ 를 섞으려면 「이 함수는 C의 규칙으로 이름을 지어라」고 말해야 하고, 그것이 extern "C"다. C 헤더의 정석 무늬가 이것이다.

#ifdef __cplusplus
extern "C" {
#endif

int mylib_open(const char *path);

#ifdef __cplusplus
}
#endif

플랫폼 노트. C 헤더가 C++ 에서만 깨지는 자리

C에서는 멀쩡한데 C++ 에서 컴파일이 안 되는 헤더가 있다. 대개 원인은 하나다 — C++ 의 키워드를 이름으로 썼다.

int register_thing(int class, int new);   /* C: 통과. C++: 오류 */

실측하면 C 컴파일러는 아무 말이 없고, C++ 컴파일러는 expected primary-expression before 'int'로 거절한다.

그래서 C++ 에서도 쓰일 수 있는 헤더를 쓸 때는 C++ 의 예약어 (class, new, delete, template, this, namespace, try, catch, operator, private, public, virtual 등)를 매개변수 이름으로 쓰지 않는 것이 예의다. 이름 뒤에 밑줄을 붙이거나(class_) 다른 낱말을 고른다.

덧붙여 bool·true·false처럼 C23 에서 키워드가 된 낱말도 오래된 C 헤더에서 변수 이름으로 발견되곤 한다 — 같은 부류의 문제다.

문. 그러면 C도 이름 공간을 언어에 들여와야 하는가?

답. 제안은 여러 번 있었고, 매번 같은 이유로 접혔다 — ABI 와 기존 코드다. 이름 공간을 들이면 심볼 이름에 마당이 새겨져야 하는데(위의 맹글링), 그러면 40년치 라이브러리와 링크 규약이 갈라진다. C가 오늘도 「어느 언어에서나 부를 수 있는 공통분모」로 살아남은 것은 심볼 이름이 단순하기 때문이다(96장).

즉 이것은 결핍이 아니라 교환이다. C는 이름의 편의를 내주고 링크의 단순함을 얻었다. 그 단순함 덕분에 파이썬·자바·러스트가 전부 C의 ABI 로 말한다.

그래서 이 장의 무기들이 「임시방편」이 아니다. C의 설계와 짝이 맞는 방식이고, 큰 프로젝트들이 수십 년간 그 위에서 잘 지내 왔다.

56.7 좋은 습관 — 요약

습관
기본을 static으로내보내지 않은 이름은 부딪힐 수 없다
접두어를 정하고 문서에 적는다사람이 세우는 담장은 합의가 있어야 선다
타입·매크로·열거 상수에도 접두어를셋 다 같은 마당에 산다(55장)
짧고 흔한 낱말은 피한다read·time·index는 이미 임자가 있다
가시성을 빌드로 강제한다규약이 새는 곳을 도구가 막는다
공개 심볼 목록을 떠 두고 비교한다새는 순간 빌드가 알려 준다
C++ 예약어를 헤더에 쓰지 않는다쓰이는 곳을 다 알 수는 없다
-Wshadow를 켠다가림은 기본 경고로 잡히지 않는다(55장)

표 56.8

이름을 다스리는 법을 배웠다. 그런데 55장에서 「매크로는 네 이름 공간을 전부 무시한다」고 지나쳤다 — C를 모르는 층이 이름을 갈아치운다는 뜻이었다. 다음 장에서 그 층을 정면으로 열고, 소스 코드가 프로그램이 되기까지의 정식 단계까지 본다.