여전히 C++에서 작동하지 않는 C 구조 — 그리고 변경된 몇 가지
🔗원문 출처

알려주신 게시물의 원문을 조건에 맞춰 한국어로 번역한 내용은 다음과 같습니다. 표와 코드 블록은 요청하신 대로 원문 그대로 유지했습니다.
여전히 C++에서 작동하지 않는 C 구조 — 그리고 변경된 몇 가지 (C Constructs That Still Don’t Work in C++ — and a Few That Changed)
'C++에서 작동하지 않는 C 구조'의 2026년 후속작: 여전히 망가지는 것들, C++20에서 변경된 점, 그리고 C23에서 변경된 점.
2019년에 저는 C++에서 작동하지 않는 C 언어의 구조들에 대한 짧은 글을 썼습니다. 핵심은 C가 엉성하다거나 C++이 우월하다는 것이 아니었습니다. 핵심은 C++이 C의 상위 집합(superset)이 아니며, 경계를 넘어가는 C 프로그래머들이 어디서 검문이 이루어지는지 알아야 한다는 것이었습니다.
그 조언은 여전히 유효합니다. 하지만 그 경계선이 이동했습니다.
C++20은 지정된 초기화자(designated initializers)의 한 형태를 채택했습니다. C++20은 또한 과거에 잘못 설명하기 쉬웠던 malloc 주변의 일부 저수준 객체 수명(object-lifetime) 사례들을 수정했습니다. 한편, C23은 void f()가 C와 C++에서 위험할 정도로 다른 의미를 갖게 만들었던 오래된 빈 매개변수 목록 규칙을 변경했습니다.
실질적인 교훈은 같지만 더욱 명확해졌습니다. C/C++ 호환성을 논의할 때는 언어 모드를 명시하세요. "유효한 C"와 "유효한 C++"이라는 말은 더 이상 충분히 정밀하지 않습니다. 이제는 종종 C17, C23, C++17, C++20 또는 C++23이라고 명확히 말해야 합니다.
또한 이 게시물의 예제들을 작은 보조 저장소(repository)에 올려두었습니다. 이 저장소는 반복적인 확인을 위한 것이며, 포함된 Compiler Explorer 링크는 빠른 진단을 돕습니다.
호환성 매트릭스 (Compatibility matrix)
다음은 간단한 요약 맵입니다. 자세한 내용은 뒤에 이어집니다. 핵심은 단순히 C++이 C의 상위 집합이 아니라는 것만이 아닙니다. 사람들이 여전히 반복해서 말하는 일부 대표적인 예시들이 C++20이나 C23에서 변경되었기 때문에, 이제 정답은 언어 모드에 따라 달라진다는 점입니다. 이 표는 일종의 지도일 뿐 예제를 대체하지는 않습니다. 좁은 화면에서는 전체 매트릭스보다 아래 섹션들을 읽는 것이 더 수월할 것입니다.
첫 번째 요청하신 조건(표 안은 원문 그대로)을 제가 너무 엄격하게 적용하여 표 전체를 영어로 남겨두었었네요!
지적해 주신 대로 코드 키워드(void*, malloc, const 등)나 표준 명칭을 제외한 표 내부의 해설과 실질적인 조언들을 모두 매끄러운 한국어로 번역하여 다시 정리해 드립니다.
호환성 매트릭스 (번역본)
| 상태 (Status) | 구조 (Construct) | C17 | C23 | C++17 | C++20 / C++23 | 실질적인 조언 (Practical advice) |
| 여전히 다름 | void*에서 객체 포인터로 | malloc으로부터의 암시적 변환은 관용적인 C 방식임. | 동일함. | 암시적 변환 없음. | 동일함. | C++에서는 malloc을 기본 할당 전략으로 삼지 마세요. 굳이 사용해야 한다면 의도적으로 캐스팅하고 수명을 신중하게 다루세요. |
| 2019년 이후 변경됨 | malloc과 객체 수명 | C 할당은 C 규칙에 따라 객체로 사용되는 저장 공간을 생성함. | 동일한 광범위한 C 모델. | 캐스팅으로 컴파일은 되지만 C++ 객체 수명이 없는 코드를 작성하기 쉬움. | 일부 암시적 수명 사례가 복구됨. 생성자는 여전히 호출되지 않음. | 저장 공간, 수명, 초기화, 소유권, 파괴를 명확히 구분하세요. |
| 여전히 다름 | const 무시하기 | 제약 조건 위반; 컴파일러가 종종 경고함. | 동일한 기본 문제. | 캐스팅 없이는 잘못된 형식(ill-formed). | 동일함. | 캐스팅이 컴파일될 수는 있습니다. 그렇다고 실제로 const인 객체에 값을 쓰는 동작이 정의되는 것은 아닙니다. |
| C23에서 변경됨 | 열거형 (Enums) | 열거자 상수는 정수와 유사함; 열거형 객체는 C++ 프로그래머를 놀라게 할 정도로 자유롭게 변환됨. | C23은 고정된 기본 타입과 열거자에 대한 더 명시적인 타이핑 규칙을 추가함. | 열거형 타입은 구별됨; int에서 열거형으로의 변환은 암시적이지 않음. | 동일함; enum class는 여전히 더 엄격함. | C++ API에는 enum class를 사용하세요. ABI나 C 상호 운용성이 요구할 때만 일반 열거형을 사용하세요. |
| C23에서 변경됨 | void f() | 예전 의미의 프로토타입이 없음; 일치하지 않는 호출이 컴파일될 수는 있으나 정의되지는 않음. | void로 선언된 것처럼 동작함. 매개변수가 없음을 의미함. | 매개변수가 없음을 의미함. | 동일함. | 공유 헤더의 경우, 언어 모드를 완전히 통제할 수 없다면 C와 맞닿은 API에서는 여전히 void f(void)로 작성하세요. |
| 2019년 이후 변경됨 | 지정된 초기화자 | 순서 이탈, 배열, 중첩, 혼합 형태를 포함한 완전한 C 스타일 지정 초기화. | 동일한 계열이며, 다른 부분에서 C23 진화가 있음. | 표준 C++이 아님. | 표준이지만 C보다 범위가 좁음. C++20에서 유용하지만 집합체, 직접 멤버, 선언 순서, 모두 지정된 절에만 해당됨. | (원문 비어있음) |
| 확장 기능의 함정 | restrict | 표준 C99 한정자. | 여전히 표준 C이며, C23 문구 업데이트가 있음. | 표준 C++이 아님. | 표준 C++이 아님. | 이식성 경계 뒤에서만 컴파일러 확장 기능을 사용하세요. |
| 여전히 다름 | 유연한 배열 멤버 | 표준 C99 후행 배열 패턴. | 여전히 표준 C. | 표준 C++이 아님. | 표준 C++이 아님. | ABI 경계에서 C 레이아웃을 유지하고, span, vector 또는 명시적인 헤더/페이로드 표현으로 변환하세요. |
지정된 초기화자(Designated initializers): 지원되지만 C의 방식은 아닙니다
2019년 글에서는 지정된 초기화자가 C++에서 지원되지 않지만, C++20에 도입될 가능성이 높다고 언급했습니다. 그 예측은 정확히 맞았습니다.
C++20은 집합체 초기화(aggregate initialization)를 위한 지정된 초기화자를 추가했습니다. 다음은 유효한 C++20 코드입니다:
struct Address {
const char* street;
const char* city;
const char* state;
int zip;
};
Address white_house{
.street = "1600 Pennsylvania Avenue NW",
.city = "Washington",
.state = "District of Columbia",
.zip = 20500,
};
하지만 이는 C 프로그래머들에게 익숙한 그 기능과 완전히 같지는 않습니다.
C++ 지정자(designator)는 직접적인 비정적(non-static) 데이터 멤버만 지정해야 하며, 선언된 순서대로 나타나야 합니다. 즉, 다음과 같이 순서를 벗어난 형태는 여전히 C++에서 유효하지 않습니다:
struct Options {
int timeout_ms;
bool verbose = false;
int retries = 0;
};
Options o{
.retries = 3, // invalid C++20: out of declaration order
.timeout_ms = 5000,
};
C는 배열 지정자 및 중첩 지정자를 포함하여 C++이 여전히 거부하는 패턴들도 허용합니다:
int table[4] = { [2] = 99 }; // valid C, invalid C++
struct Inner { int value; };
struct Outer { struct Inner inner; };
struct Outer o = { .inner.value = 7 }; // valid C, invalid C++
또한 C에서는 동일한 초기화자 내에서 위치 기반 절과 지정된 절을 혼합해서 사용할 수 있습니다:
struct Triple {
int first;
int second;
int third;
};
struct Triple t = { 1, .third = 3 }; // valid C, invalid C++
C++20에서는 이와 유사한 형태의 집합체 초기화가 잘못된 형식(ill-formed)으로 간주됩니다:
struct Triple {
int first;
int second;
int third;
};
Triple t{1, .third = 3}; // invalid C++20: mixed designated and positional clauses
이는 자의적인 결정이 아닙니다. C++에는 생성자, 소멸자, 기본 멤버 초기화자, 참조(references), 그리고 코드로 관찰할 수 있는 초기화 순서 모델이 존재합니다. C 스타일의 자유로움은 이러한 C++ 객체 모델과 충돌할 수밖에 없습니다.
C++ 규칙을 완화하려는 제안들(순서를 벗어난 지정된 초기화자 및 기본 클래스 지정 초기화 포함)이 존재합니다. 하지만 이들은 단지 제안일 뿐입니다. 이 기능들이 이미 확정되었다고 가정하고 이식성 있는 C++ 코드를 작성하지 마십시오.
- 규칙: C++20의 지정된 초기화자는 단순한 집합체 설정 객체에 매우 유용합니다. 하지만 C99의 지정된 초기화자를 그대로 대체할 수 있는 것은 아닙니다. C++20의 형태는 "C 지정자가 이제 C++에 들어왔다"가 아닙니다. 그것은 제한된 집합체 초기화 기능입니다. 유용한 멘탈 모델은 다음과 같습니다: 직접 멤버, 선언 순서 유지, 지정된 절과 지정되지 않은 절 혼합 불가.
빈 매개변수 목록: C가 C++ 방향으로 이동함
이것은 과거 C와 C++의 차이를 보여주는 가장 깔끔한 예시 중 하나였습니다.
C++의 경우:
void fn();
fn(42); // invalid C++: fn takes no arguments
C17 및 그 이전 버전에서는 void fn();이 프로토타입을 제공하지 않았습니다. void fn() {}으로 작성된 정의는 매개변수가 없음을 명시했지만, 프로토타입이 없는 선언을 통해 이루어진 호출은 C++ 프로그래머가 기대하는 방식으로 검사되지 않았습니다. 이러한 호출은 기본 인수 승격을 거친 후 컴파일될 수는 있지만, 제공된 인수의 수가 매개변수의 수와 일치하지 않으면 동작이 정의되지 않습니다(undefined behavior):
void fn() { }
int main(void) {
fn(42); // may compile in C17 mode; undefined for this definition
}
C23은 이러한 과거의 분열을 제거했습니다. 매개변수 타입 목록이 없는 함수 선언자는 void를 사용한 것처럼 동작하며, 프로토타입을 제공하고, 인수 개수가 일치해야만 합니다.
이는 진정한 호환성 개선입니다. 하지만 마이그레이션 상의 문제를 만들어내기도 합니다. 오래된 C 코드는 C17 모드에서는 컴파일되지만 C23 모드에서는 실패할 수 있습니다. 이는 좋은 실패이지만, 어쨌든 실패는 실패입니다.
- 규칙: 공유 헤더의 경우, 언어 모드를 완벽히 통제할 수 없다면 C와 맞닿아 있는 API에서는 여전히
void fn(void)가 가장 놀라움이 적은 작성 방식입니다. C++ 전용 코드에서는void fn()으로 충분합니다.
void*, malloc, 그리고 객체 수명(object-lifetime)의 함정
단순한 비호환성은 변하지 않았습니다. C에서는 다음과 같이 작성할 수 있습니다:
int* values = malloc(100 * sizeof *values);
C++은 void*를 int*로 암시적으로 변환하지 않습니다:
int* values = std::malloc(100 * sizeof *values); // invalid C++
캐스팅을 사용할 수 있습니다:
auto* values = static_cast<int*>(std::malloc(100 * sizeof(int)));
하지만 캐스팅이 흥미로운 부분은 아닙니다. 진짜 흥미로운 부분은 수명(lifetime)입니다.
현재의 C++에서 주의해야 할 핵심은 더 이상 "malloc은 절대 객체를 반환할 수 없다"가 아닙니다. C++20에서 이 부분이 좁혀졌습니다. C 할당 함수를 포함한 특정 작업들은, 만약 그렇게 하는 것이 프로그램을 정의된 상태로 만든다면 암시적 수명 타입(implicit-lifetime types)의 객체를 암시적으로 생성하도록 지정되었습니다. 표준 초안의 예시는 본질적으로 std::malloc으로 반환된 후 그 멤버들을 통해 할당되는 단순한 집합체입니다. 이 수정 사항은 의도적으로 제한되어 있습니다. 생성자를 실행하지 않으며, 스칼라 값을 초기화하거나 불변성을 확립하지 않고, 그 자체로 암시적 수명 타입이 아닌 하위 객체들의 수명을 시작하지도 않습니다. 암시적 수명 타입이 아닌 경우, 생성(construction)이 일어나기 전까지 메모리 공간은 그저 메모리 공간일 뿐입니다.
따라서 다음과 같은 코드는 더 이상 C++20에서 사람들을 겁주기 위한 최고의 예시가 아닙니다:
#include <cstdlib>
struct X {
int a;
int b;
};
X* make_x() {
auto* p = static_cast<X*>(std::malloc(sizeof(X)));
p->a = 1;
p->b = 2;
return p;
}
X와 같은 암시적 수명 타입의 경우 C++20은 수명 문제를 해결해 줍니다. 그렇다고 해서 malloc이 관용적인 C++ 코드가 되는 것은 아닙니다.
이러한 수명 문제는 해결되었더라도 생성자를 호출하지는 않습니다. 값을 초기화하지도 않고, 예외 안정성을 제공하지도 않습니다. 소유권과 파괴(destruction)를 짝지어 주지도 않으며, 다음과 같은 코드를 유효하게 만들어 주지도 않습니다:
#include <cstdlib>
#include <string>
void bad() {
auto* s = static_cast<std::string*>(std::malloc(sizeof(std::string)));
*s = "hello"; // undefined behavior: no std::string object was constructed
}
안전한 저수준 C++ 작성법은 다음과 같이 명시적이어야 합니다:
#include <memory>
#include <new>
#include <string>
void* storage = ::operator new(sizeof(std::string));
auto* s = new (storage) std::string("hello");
std::destroy_at(s);
::operator delete(storage);
더 나은 고수준 작성법은 일반적으로 훨씬 간단합니다:
auto s = std::make_unique<std::string>("hello");
- 규칙:
void*로부터의 캐스팅이 모든 문제를 해결해 주지 않습니다. 다음 다섯 가지 질문을 던져보세요: 누가 메모리 공간을 소유하는가, 객체의 수명은 언제 시작되는가, 객체는 어떻게 초기화되는가, 누가 그것을 파괴하는가, 실패 시 어떤 일이 발생하는가?
const_cast: 컴파일된다는 것이 정의된(defined) 동작이라는 뜻은 아닙니다
예전 게시물에서는 C++이 const를 무시할 때 명시적인 방법을 강제한다는 점을 지적했습니다:
const int x = 100;
int* p = &x; // invalid C++
명시적 캐스팅을 작성할 수는 있습니다:
const int x = 100;
int* p = const_cast<int*>(&x);
하지만 이는 단지 타입 시스템의 장벽을 제거할 뿐입니다. 객체 자체를 바꾸지는 않습니다. x는 실제로 const 객체이기 때문에 p를 통해 값을 쓰는 것은 정의되지 않은 동작(undefined behavior)입니다.
하지만 유효한 사용 사례도 있습니다:
int x = 100;
const int* view = &x;
int* p = const_cast<int*>(view);
*p = 101; // defined: the original object is not const
이러한 구분은 레거시 코드와의 통합에서 중요합니다. 때로는 C API가 버퍼를 수정하지 않겠다고 약속하면서도 인자로 char*를 받는 경우가 있습니다. 이러한 경계에서 const_cast는 가장 덜 나쁜 선택지일 수 있습니다. 그것을 경계에 배치하고 문서화하며, 핵심 로직에서는 배제하십시오.
문자열 리터럴, 메모리 매핑된 읽기 전용 저장소, 또는 원래 const로 선언된 객체에 이 트릭을 사용하지 마십시오. 만약 레거시 함수가 실제로 쓰기 작업을 수행한다면, 그 캐스팅은 그저 버그의 위치를 옮기는 것일 뿐입니다.
- 규칙:
const_cast는 허가증이 아닙니다. 국소적인 탈출구일 뿐입니다.
Enum (열거형): "C는 int를 사용한다"는 것보다 덜 단순합니다
과거의 축약된 표현인 "C enum 값은 int에 의해 뒷받침된다"는 2026년 버전의 이 글에는 너무 압축적입니다. C17의 경우 더 안전한 설명은 다음과 같습니다: 열거자 상수(enumerator constants)는 정수 타입을 가지는 반면, 열거형(enumerated type) 자체는 그 값들을 표현할 수 있는 구현 정의(implementation-defined) 정수 타입과 호환됩니다. C23은 이 모델을 더 명시적으로 만듭니다. 모든 열거형은 기본 타입(underlying type)을 가지며, 고정된 기본 타입을 명시할 수 있고, 완성된 열거자 상수의 타입은 열거형이 고정된 기본 타입을 갖는지, 그리고 그 값들이 int에 들어맞는지에 따라 달라집니다.
이는 여전히 C++의 모델과는 다릅니다. C++에서 열거형은 완전히 구별되는(distinct) 타입입니다. 범위가 지정되지 않은(unscoped) 열거자 또는 그러한 열거형 타입의 객체는 정수 승격(integral promotion)이나 변환에 참여할 수 있지만, 임의의 정수를 캐스팅 없이 해당 열거형에 할당할 수는 없습니다. 범위가 지정된(scoped) 열거형은 int나 bool로 암시적으로 변환되지 않습니다.
enum Mode { off = 0, on = 1 };
int x = on; // OK: unscoped enum to int
Mode m = 1; // invalid C++: int to Mode is not implicit
만약 정수 표현에서 넘어와야 한다면 명시해야 합니다:
Mode m = static_cast<Mode>(1);
C++ API의 경우, 범위가 지정된(scoped) 열거형을 선호하십시오:
enum class Mode : unsigned {
off = 0,
on = 1,
};
int x = Mode::on; // invalid C++
auto y = static_cast<unsigned>(Mode::on); // explicit
- 규칙: C ABI의 일부이거나 의도적으로 이전의 열거형 동작을 원할 때만 일반 열거형(plain enums)을 사용하십시오. C++ 내의 도메인 타입인 경우
enum class를 사용하십시오.
restrict: C의 약속일 뿐, C++의 제약은 아닙니다
C99는 프로그래머가 실행 기간 동안 특정 포인터가 객체에 대한 유일한 접근 경로임을 약속할 수 있도록 restrict를 도입했습니다. 이러한 약속은 유용한 앨리어싱(aliasing) 최적화를 가능하게 합니다. 만약 이 약속이 거짓이라면 그 동작은 정의되지 않습니다.
표준 C++에는 restrict 키워드가 없습니다. GCC와 Clang은 확장 기능으로 __restrict__와 __restrict를 지원합니다. MSVC는 변수에 대해 __restrict를, 함수 선언 및 정의에 대해 반환값 앨리어싱 의미론을 가진 __declspec(restrict)를 지원합니다. 이 모든 것들을 이식성 있는 C++ 인터페이스 설계가 아니라 특정 툴체인의 제약으로 취급하십시오.
- 규칙: 만약 C++에서
restrict와 유사한 의미론이 필요하다면, 그 확장 기능을 작은 경계 내에 격리시키고, 실제로 배포할 컴파일러들로 테스트하며, 앨리어싱 전제 조건을 절대 놓칠 수 없게 명확히 하십시오.
유연한 배열 멤버(Flexible array members): 경계에 유지하십시오
C99는 유연한 배열 멤버 또한 표준화했습니다:
struct Packet {
unsigned length;
unsigned char payload[];
};
이것은 고정된 헤더와 뒤따르는 데이터가 있는 가변 길이 객체에 대해 훌륭한 C 패턴입니다. 하지만 표준 C++은 아닙니다.
일부 C++ 컴파일러들은 유연한 배열 멤버를 확장 기능으로 허용합니다. 그렇다고 해서 그 코드가 이식성 있는 C++이 되는 것은 아닙니다. 또한 C++이 겉으로 드러내고자 하는 수명 및 소유권 문제를 해결해 주지도 않습니다.
C++에서는 일반적으로 다음 중 하나를 선택하십시오:
struct Packet {
unsigned length;
std::vector<std::byte> payload;
};
혹은 데이터의 소유권이 다른 곳에 있는 경우:
struct PacketView {
unsigned length;
std::span<const std::byte> payload;
};
ABI 경계에서는 C의 표현 방식을 유지해야 할 수도 있습니다. 괜찮습니다. 하지만 격리된 상태로 유지하십시오. C의 레이아웃을 파싱하고, 길이를 검증한 다음, 명시적 소유권을 가지거나 범위가 지정된 뷰를 가진 C++ 표현으로 변환하십시오.
- 규칙: 유연한 배열 멤버는 C의 레이아웃 도구입니다. 이식성 있는 C++ 객체 모델이 아닙니다.
마이그레이션 규칙 (Migration rules)
C의 습관을 C++로 가져올 때, 저는 다음과 같은 규칙들을 사용합니다:
- 주장을 제기하기 전에 언어 모드를 명시할 것.
- "C에서 작동한다"는 것이 "C++에서 경고와 함께 작동한다"는 것을 의미한다고 가정하지 말 것.
- "캐스팅으로 컴파일된다"는 것과 "정의된 동작(defined behavior)을 가진다"는 것을 혼동하지 말 것.
malloc을 생성(construction)이 아닌 단순한 저장 공간(storage) 확보로 취급할 것.- ABI 경계에서 C 레이아웃을 유지한 다음 C++ 타입으로 변환할 것.
- 소유권과 수명을 명확하게 보여주는 C++ 구조를 선호할 것.
- 컴파일러 확장은 명명되고 테스트된 이식성 경계 뒤에서만 사용할 것.
과거의 교훈은 여전히 유효합니다: C++은 C의 상위 집합이 아닙니다. 업데이트된 교훈은 더 명확합니다: 두 언어는 점점 더 구문(syntax)을 공유하고 있지만, 동일한 객체 모델, 초기화 모델, 또는 불변성(invariants)을 공유하지는 않습니다.
그곳이 바로 버그가 숨어 있는 곳입니다.
참고 문헌 (References)
- 원문: C Constructs That Don’t Work in C++
- 동반 예제 저장소: C Constructs That Still Don’t Work in C++ repository
- C23 working draft: WG14 N3220
- C++ aggregate initialization and designated initializers: C++ draft, dcl.init.aggr
- C++ C allocation functions: C++ draft, c.malloc
- C++ object model and implicit object creation: C++ draft, intro.object
- C++ object lifetime: C++ draft, basic.life
- C++ cv-qualification and const modification: C++ draft, dcl.type.cv
- C++ const_cast: C++ draft, expr.const.cast
- C++ enumeration declarations: C++ draft, dcl.enum
- C17-era no-prototype call behavior: WG14 N1570, 6.5.2.2 Function calls
- C23 empty parameter list proposal: WG14 N2841, No function declarators without prototypes
- Implicit object creation proposal: WG21 P0593R6
- C++ designated initialization proposal: WG21 P0329R4
- GCC C++ restricted pointer extension: GCC docs, Restricted Pointers
- MSVC
__restrict: Microsoft Learn,__restrict - Out-of-order designated initializer proposal: WG21 P3405R0
- Base-class designated initializer proposal: WG21 P2287R5
- Secondary reference, C enumerations: cppreference, C enum
- Secondary reference, C restrict: cppreference, restrict type qualifier
- Secondary reference, flexible array members: cppreference, struct declaration