56 이름 충돌을 다루는 법 — 접두어에서 namespace까지
먼저 알아야 할 것
돌아보기
55장에서 C의 이름 공간은 넷뿐이고 새로 만들 수 없다고 했다. 그러면 서로 모르는 두 라이브러리가 init이라는 함수를 각각 내보내면 어떻게 되는가?
답. 둘 다 외부 연결이면 링커가 중복 정의로 거절한다 — 운이 좋은 경우다. 운이 나쁘면 더 고약하다. 한쪽이 정적 라이브러리 안에 있으면 링커가 먼저 찾은 쪽만 쓰고 조용히 넘어간다. 컴파일도 링크도 성공하는데 엉뚱한 함수가 불린다.
그래서 이 문제의 해법은 「충돌하면 고친다」가 아니라 「애초에 충돌할 수 없게 만든다」여야 한다. 이 장이 그 방법들이다.
이 장의 필요성과 맥락
이 장이 끝나면
namespace로 어떻게 풀었는지, 그리고 두 언어를 섞을 때 무엇을 조심해야 하는지까지 간다.이 장에서 답할 질문
multiple definition은 오히려 반가운 오류인가?- 내가 실제로 무엇을 내보내고 있는지 어떻게 확인하는가?
- 그러면 C도 이름 공간을 언어에 들여와야 하는가?
56.1 충돌은 언제 어디서 터지는가
같은 철자가 부딪히는 자리는 셋이고, 뒤로 갈수록 진단이 어렵다.
| 언제 | 무엇이 말해 주는가 | 얼마나 다루기 쉬운가 |
|---|---|---|
| 컴파일 시 | 같은 스코프에 두 번 선언 — 컴파일러가 즉시 거절 | 쉽다. 그 자리에서 보인다 |
| 링크 시 | multiple definition of 'init' | 보통. 어느 파일인지 메시지가 알려 준다 |
| 조용히 | 아무도 말해 주지 않는다 | 어렵다. 엉뚱한 함수가 불리는데 증상만 보인다 |
표 56.1
세 번째가 이 장이 존재하는 이유다. 조용한 충돌이 생기는 자리를 셋만 들어 둔다.
- 정적 라이브러리의 링크 순서.
libfoo.a와libbar.a가 같은 이름을 담고 있으면, 링커는 명령줄에서 먼저 만난 쪽을 쓰고 멈춘다. 라이브러리 순서를 바꾸면 프로그램의 동작이 바뀐다. - 약한 심볼(weak symbol). 구현이 「누가 덮어써도 좋다」고 표시해 둔 이름은 경고 없이 대체된다.
malloc을 자기 것으로 갈아 끼우는 무늬가 이것이다. - 실행 시 가로채기. 유닉스의
LD_PRELOAD는 먼저 적재된 이름이 이긴다. 진단 도구가 쓰는 정당한 기법이지만, 우연히 같은 이름을 쓰면 사고가 된다.
문. multiple definition 은 오히려 반가운 오류인가?
답. 그렇다. 들리는 충돌이니까. 진짜 위험한 것은 링커가 아무 말 없이 한쪽을 고르는 경우다.
그래서 규율의 방향은 「충돌을 잘 고치자」가 아니라 「충돌이 생기면 반드시 들리게 하자」가 된다. 내보내는 이름을 최소로 줄이면 (a) 부딪힐 확률이 줄고 (b) 부딪히면 링커가 소리를 낸다. 아래 네 무기가 전부 그 방향이다.
56.2 첫째 무기 — 내보내지 않기(static)
가장 확실한 방어는 이름을 바깥 마당에 내놓지 않는 것이다. 파일 수준 선언에 static을 붙이면 내부 연결이 되어(54장) 그 번역 단위 밖에서는 존재하지 않는다.
규율은 한 줄로 요약된다 — 기본을 static으로 두고, 헤더에 적을 것만 푼다. 덤도 있다. 컴파일러가 「이 이름은 다른 파일에서 불릴 리 없다」를 알기에 더 공격적으로 최적화할 수 있다(인라인, 미사용 함수 제거).
반례. 헤더에 쓸 것이 아닌데도 전역으로 두기
/* util.c */
int helper(int x) { … } /* 헤더에 없는데 외부 연결이다 */이 함수는 아무도 부르지 않는데도 링커의 마당에 이름을 하나 차지한다. 언젠가 다른 파일이 우연히 같은 이름을 만들면 그때야 문제가 드러난다. static을 붙이는 데 드는 비용은 낱말 하나다.
56.3 둘째 무기 — 접두어 규약
내보내야 하는 이름에는 담장을 손으로 세운다. C 라이브러리의 보편적 관행이고, 큰 프로젝트일수록 예외가 없다.
| 프로젝트 | 접두어 | 무엇에까지 붙이는가 |
|---|---|---|
| SQLite | sqlite3_ | 함수·타입·상수 전부. 버전 번호까지 접두어에 넣었다 |
| libcurl | curl_, CURL | 함수는 소문자, 상수·타입은 대문자 계열 |
| zlib | z, inflate/deflate | 역사가 오래된 만큼 접두어가 짧다 — 그래서 가끔 부딪힌다 |
| OpenSSL | SSL_, EVP_, X509_ | 모듈마다 다른 접두어를 둔 형태 |
| GLib·GTK | g_, gtk_, G_, GTK_ | 타입은 GtkWidget처럼 낙타등으로 |
| SDL | SDL_ | 함수·타입·상수·매크로 전부 |
| 리눅스 커널 | 서브시스템 이름 | kmalloc, vfs_, sock_ 등 — 층을 접두어로 |
표 56.2
읽어 낼 수 있는 설계 규칙이 넷 있다.
- 짧게, 그러나 유일하게. zlib 의
z는 짧아서 편하지만 그만큼 부딪히기 쉽다. 오늘 새로 짓는다면 서너 글자가 무난하다. - 타입과 매크로에도 붙인다. 함수만 가리는 접두어는 절반짜리 담장이다 — 타입 이름과 열거 상수가 같은 마당에 산다는 것을 55장에서 보았다.
- 대소문자로 층을 나눈다. 소문자는 함수, 대문자는 매크로·상수라는 관행을 접두어와 함께 쓰면 이름만 보고도 정체가 드러난다.
- 문서에 적는다. 「우리 프로젝트의 접두어는 이것이다」를 규약 문서에 한 줄 적어 두는 것이, 나중에 코드 검토에서 백 번 지적하는 것보다 싸다.
실제 사례. 접두어가 없던 시절의 유산
표준 라이브러리 자체가 접두어 없는 이름의 박물관이다 — open, read, write, time, index, link. 1970년대에는 프로그램 하나가 쓰는 라이브러리가 몇 개 되지 않았으니 문제가 아니었다.
그 결과가 오늘의 흔한 사고다. 자기 코드에 read나 time이라는 함수를 만들면 표준 함수를 가려 버리고, 그 함수를 부르는 다른 코드까지 조용히 다른 데로 간다. 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 # 같은 일을 다른 도구로이 장의 시연을 이 방식으로 재어 보면 내보낸 이름이 main과 textbuf 둘뿐이다 — 안쪽 함수들은 전부 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 와 같은 내부 연결 |
익명 이름 공간의 hidden | t — 지역 | 같은 효과. 이름은 (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를 모르는 층이 이름을 갈아치운다는 뜻이었다. 다음 장에서 그 층을 정면으로 열고, 소스 코드가 프로그램이 되기까지의 정식 단계까지 본다.