C 스타일 C++이란?
C 스타일 C++은 C++ compiler를 사용하지만, 코드 작성 방식은 C에 가깝게 제한하는 방식이다.
즉 C++의 class, template, exception, STL, RAII 같은 기능을 적극적으로 쓰기보다 다음 요소를 중심으로 코드를 작성한다.
struct
function
pointer
array
manual memory management
explicit initialization / cleanup
이 방식은 현대적인 C++ 스타일과는 다르다. 하지만 C library와 연동하거나, 임베디드 환경에서 예측 가능한 코드를 작성하거나, C에 익숙한 팀에서 C++ compiler만 사용하는 경우에는 이런 스타일이 쓰이기도 한다.
기본 방향
C 스타일 C++의 핵심은 C++ 기능을 가능한 한 얇게 사용하는 것이다.
| C++ 기능 | C 스타일 대체 |
|---|---|
class | struct + 함수 |
| constructor/destructor | init / destroy 함수 |
std::vector | 배열 + size/capacity |
std::string | char* 또는 fixed buffer |
| exception | error code 반환 |
| smart pointer | raw pointer + 명시적 해제 |
| method | 첫 번째 인자로 object pointer를 받는 함수 |
예를 들어 C++에서는 보통 다음처럼 class를 만든다.
class Counter {
private:
int value;
public:
void increment() {
value++;
}
};
C 스타일로 쓰면 data와 function을 분리한다.
struct Counter {
int value;
};
void counter_increment(Counter* counter) {
counter->value++;
}
struct 중심으로 데이터 표현
C 스타일에서는 struct를 데이터 묶음으로 사용한다.
함수는 struct 밖에 둔다.
struct Vec2 {
float x;
float y;
};
float vec2_length_squared(const Vec2* v) {
return v->x * v->x + v->y * v->y;
}
함수 이름에는 어떤 타입에 대한 함수인지 prefix를 붙이는 경우가 많다.
vec2_add
vec2_sub
vec2_scale
vec2_normalize
이렇게 하면 C의 namespace 부재 문제를 완화할 수 있다. C++에서는 실제 namespace를 사용할 수도 있지만, C 스타일을 강하게 유지하려면 prefix naming만으로도 충분하다.
객체지향을 어떻게 관리할 것인가
C++을 C 스타일로 작성한다고 해서 객체지향 개념을 완전히 버려야 하는 것은 아니다.
다만 객체지향을 class hierarchy나 virtual function 중심으로 풀기보다, data와 function의 관계를 명시적으로 관리한다.
핵심은 다음과 같다.
object = struct instance
method = 첫 번째 인자로 object pointer를 받는 function
constructor = init function
destructor = destroy function
encapsulation = header에 노출할 field를 제한하거나 opaque pointer 사용
polymorphism = function pointer table로 구현
즉 C++의 객체지향 문법을 C의 표현 방식으로 낮춰서 관리하는 것이다.
method를 함수로 분리하기
일반적인 C++ class는 data와 method를 한 곳에 둔다.
class Player {
private:
int hp;
int attack_power;
public:
void attack(Player& target) {
target.hp -= attack_power;
}
};
C 스타일에서는 data만 struct에 두고, 동작은 함수로 뺀다.
struct Player {
int hp;
int attack_power;
};
void player_attack(Player* self, Player* target) {
target->hp -= self->attack_power;
}
여기서 self는 C++ method의 this와 같은 역할을 한다.
Player a = {100, 10};
Player b = {80, 8};
player_attack(&a, &b);
이 방식의 장점은 호출 관계가 명시적이라는 것이다. 반대로 data를 직접 만질 수 있으므로 불변식이 깨지기 쉽다.
캡슐화가 필요할 때
C 스타일로 작성해도 내부 구현을 숨길 수 있다. 이때 opaque pointer를 사용한다.
header에는 struct의 내용을 공개하지 않는다.
// player.h
#pragma once
struct Player;
Player* player_create(int hp, int attack_power);
void player_destroy(Player* player);
void player_attack(Player* self, Player* target);
int player_get_hp(const Player* player);
source file에서만 실제 struct를 정의한다.
// player.cpp
#include "player.h"
struct Player {
int hp;
int attack_power;
};
Player* player_create(int hp, int attack_power) {
Player* player = new Player;
player->hp = hp;
player->attack_power = attack_power;
return player;
}
void player_destroy(Player* player) {
delete player;
}
void player_attack(Player* self, Player* target) {
target->hp -= self->attack_power;
}
int player_get_hp(const Player* player) {
return player->hp;
}
사용자는 Player 내부 field에 직접 접근할 수 없다.
Player* player = player_create(100, 10);
int hp = player_get_hp(player);
player_destroy(player);
이 방식은 C++의 private과 비슷한 효과를 낸다.
단, 모든 object를 pointer로 다루게 되므로 생성과 해제 규칙을 더 명확히 해야 한다.
상속 대신 포함 사용하기
C++에서는 공통 기능을 상속으로 표현할 수 있다. 하지만 C 스타일에서는 상속보다 composition을 사용하는 편이 단순하다.
struct Transform {
float x;
float y;
float rotation;
};
struct Enemy {
Transform transform;
int hp;
int damage;
};
struct Item {
Transform transform;
int item_id;
};
공통 데이터는 Transform으로 분리하고, 각 타입이 그것을 field로 가진다.
void transform_move(Transform* transform, float dx, float dy) {
transform->x += dx;
transform->y += dy;
}
사용할 때는 포함된 field를 넘긴다.
Enemy enemy = {};
transform_move(&enemy.transform, 1.0f, 0.0f);
이 방식은 깊은 상속 계층을 만들지 않아도 되고, memory layout도 비교적 예측하기 쉽다.
다형성이 필요할 때
C++의 다형성은 보통 virtual function으로 구현한다.
C 스타일에서는 function pointer table을 사용할 수 있다. 이 방식은 C에서 직접 vtable을 만드는 것과 비슷하다.
struct Shape;
struct ShapeOps {
float (*area)(const Shape* shape);
void (*destroy)(Shape* shape);
};
struct Shape {
const ShapeOps* ops;
};
구체 타입은 base struct를 첫 field로 둔다.
struct Circle {
Shape base;
float radius;
};
Circle의 함수를 작성한다.
float circle_area(const Shape* shape) {
const Circle* circle = (const Circle*)shape;
return 3.141592f * circle->radius * circle->radius;
}
void circle_destroy(Shape* shape) {
Circle* circle = (Circle*)shape;
delete circle;
}
function table을 만든다.
const ShapeOps CIRCLE_OPS = {
circle_area,
circle_destroy,
};
생성 함수에서 ops를 연결한다.
Shape* circle_create(float radius) {
Circle* circle = new Circle;
circle->base.ops = &CIRCLE_OPS;
circle->radius = radius;
return &circle->base;
}
호출자는 구체 타입을 몰라도 공통 interface로 사용할 수 있다.
Shape* shape = circle_create(10.0f);
float area = shape->ops->area(shape);
shape->ops->destroy(shape);
이 방식은 C++ virtual function을 직접 흉내 낸 것이다. 다만 type cast가 들어가므로 잘못된 layout을 사용하면 위험하다.
class를 제한적으로 쓰는 방식
완전히 C 스타일을 고집하지 않는다면 class를 제한적으로 사용하는 방법도 있다.
예를 들어 RAII를 위해 constructor/destructor만 사용하고, 상속과 exception은 피할 수 있다.
class Buffer {
public:
Buffer(int size) {
data = new char[size];
this->size = size;
}
~Buffer() {
delete[] data;
}
char* data;
int size;
};
이렇게 하면 사용자는 destroy 호출을 잊을 수 없다.
void run() {
Buffer buffer(1024);
// scope를 벗어나면 자동 해제
}
하지만 C 스타일 API와 섞을 때는 기준을 정해야 한다.
module 내부에서는 class/RAII를 사용해도 되는가?
public API는 C 스타일 함수로만 노출할 것인가?
exception은 boundary 밖으로 나가지 않게 막을 것인가?
STL type을 header에 노출할 것인가?
현실적인 절충안은 내부 구현에는 C++의 안전 장치를 쓰고, 외부 API는 C 스타일로 단순하게 노출하는 것이다.
초기화와 해제를 명시적으로 작성
현대 C++에서는 constructor와 destructor를 사용해 object lifecycle을 관리한다.
C 스타일에서는 init, destroy 함수를 명시적으로 만든다.
struct Buffer {
char* data;
int size;
};
bool buffer_init(Buffer* buffer, int size) {
buffer->data = new char[size];
if (buffer->data == nullptr) {
buffer->size = 0;
return false;
}
buffer->size = size;
return true;
}
void buffer_destroy(Buffer* buffer) {
delete[] buffer->data;
buffer->data = nullptr;
buffer->size = 0;
}
사용하는 쪽에서는 반드시 destroy를 호출해야 한다.
Buffer buffer;
if (!buffer_init(&buffer, 1024)) {
return 1;
}
// use buffer
buffer_destroy(&buffer);
이 방식은 단순하지만 실수하기 쉽다.
destroy 호출을 빼먹으면 memory leak이 발생한다.
error code로 실패 처리
C++에서는 실패 상황을 exception으로 표현할 수 있다. C 스타일에서는 보통 return value로 성공/실패를 전달한다.
enum ErrorCode {
ERROR_OK = 0,
ERROR_INVALID_ARGUMENT = 1,
ERROR_OUT_OF_MEMORY = 2,
};
함수는 결과 코드를 반환한다.
ErrorCode read_value(const char* text, int* out_value) {
if (text == nullptr || out_value == nullptr) {
return ERROR_INVALID_ARGUMENT;
}
*out_value = atoi(text);
return ERROR_OK;
}
호출자는 반환값을 확인한다.
int value = 0;
ErrorCode error = read_value("123", &value);
if (error != ERROR_OK) {
return 1;
}
핵심은 실패 가능성이 있는 함수의 반환값을 무시하지 않는 것이다.
output parameter 사용
C 스타일에서는 함수가 여러 값을 반환해야 할 때 output parameter를 자주 사용한다.
bool divide(int a, int b, int* out_result) {
if (b == 0 || out_result == nullptr) {
return false;
}
*out_result = a / b;
return true;
}
사용 예시는 다음과 같다.
int result = 0;
if (divide(10, 2, &result)) {
// use result
}
이 방식은 명확하지만, pointer가 null인지 항상 확인해야 한다.
배열과 크기를 함께 전달
C 스타일에서 배열은 pointer로 전달된다. 따라서 배열의 길이를 별도로 넘겨야 한다.
int sum_array(const int* values, int count) {
int sum = 0;
for (int i = 0; i < count; i++) {
sum += values[i];
}
return sum;
}
호출은 다음처럼 한다.
int values[] = {1, 2, 3, 4};
int sum = sum_array(values, 4);
배열 pointer만 받으면 길이를 알 수 없다. 따라서 다음 규칙을 지키는 것이 좋다.
array pointer를 받으면 size도 같이 받는다.
buffer pointer를 받으면 capacity도 같이 받는다.
문자열 buffer를 받으면 null termination 여부를 명확히 한다.
동적 배열 직접 관리
std::vector를 쓰지 않고 동적 배열을 직접 관리하려면 data, size, capacity를 분리해서 둔다.
struct IntArray {
int* data;
int size;
int capacity;
};
초기화 함수는 다음처럼 만들 수 있다.
bool int_array_init(IntArray* array, int capacity) {
array->data = new int[capacity];
if (array->data == nullptr) {
array->size = 0;
array->capacity = 0;
return false;
}
array->size = 0;
array->capacity = capacity;
return true;
}
추가 함수는 capacity를 확인한다.
bool int_array_push(IntArray* array, int value) {
if (array->size >= array->capacity) {
return false;
}
array->data[array->size] = value;
array->size++;
return true;
}
해제 함수는 반드시 필요하다.
void int_array_destroy(IntArray* array) {
delete[] array->data;
array->data = nullptr;
array->size = 0;
array->capacity = 0;
}
함수 pointer로 callback 표현
C++에서는 lambda, std::function, virtual function 등을 사용할 수 있다.
C 스타일에서는 function pointer로 callback을 표현한다.
typedef void (*LogCallback)(const char* message);
callback을 인자로 받는 함수는 다음처럼 작성한다.
void run_task(LogCallback logger) {
if (logger != nullptr) {
logger("task started");
}
// task logic
if (logger != nullptr) {
logger("task finished");
}
}
사용 예시는 다음과 같다.
void print_log(const char* message) {
printf("%s\n", message);
}
int main() {
run_task(print_log);
return 0;
}
상태가 필요한 callback이면 user data pointer를 함께 넘긴다.
typedef void (*LogCallback)(void* user_data, const char* message);
이 패턴은 C library API에서 자주 볼 수 있다.
header와 source 분리
C 스타일로 작성할 때는 .h에 data structure와 function declaration을 두고, .cpp에 implementation을 둔다.
예를 들어 counter.h는 다음과 같다.
#pragma once
struct Counter {
int value;
};
void counter_init(Counter* counter, int initial_value);
void counter_increment(Counter* counter);
int counter_get_value(const Counter* counter);
counter.cpp는 다음과 같다.
#include "counter.h"
void counter_init(Counter* counter, int initial_value) {
counter->value = initial_value;
}
void counter_increment(Counter* counter) {
counter->value++;
}
int counter_get_value(const Counter* counter) {
return counter->value;
}
이 구조는 C library처럼 API surface를 명확히 만들 때 유용하다.
최소한의 C++ 기능은 사용할 수 있다
C 스타일 C++이라고 해서 C++ 기능을 전부 금지해야 하는 것은 아니다. 오히려 C++ compiler를 쓰는 이상 일부 기능은 안전하게 활용할 수 있다.
| 기능 | 사용 이유 |
|---|---|
bool | C 스타일 int flag보다 명확 |
nullptr | NULL보다 type-safe |
constexpr | compile-time constant 표현 |
enum class | enum 이름 충돌 방지 |
| function overload | 타입별 함수 이름 중복 완화 |
| reference | null이 불가능한 인자 표현 |
예를 들어 pointer가 null이면 안 되는 인자는 reference로 받을 수 있다.
void counter_increment(Counter& counter) {
counter.value++;
}
다만 C와 ABI 호환이 필요한 API라면 reference, overload, enum class는 C에서 직접 호출하기 어렵다.
이 경우에는 C ABI에 맞춰야 한다.
C 호환 API가 필요할 때
C에서 호출 가능한 API를 만들려면 name mangling을 막아야 한다.
이때 extern "C"를 사용한다.
#ifdef __cplusplus
extern "C" {
#endif
int library_init(void);
void library_shutdown(void);
#ifdef __cplusplus
}
#endif
이런 header는 C compiler와 C++ compiler 양쪽에서 사용할 수 있다.
주의할 점은 C API에는 C++ 전용 타입을 노출하면 안 된다는 것이다.
std::string
std::vector
class
template
reference
exception
이런 타입은 C에서 사용할 수 없으므로 public C API에 직접 노출하지 않는 것이 좋다.
피해야 할 실수
C 스타일 C++은 단순하지만, 메모리와 lifetime을 직접 다루기 때문에 실수 비용이 크다.
자주 발생하는 문제는 다음과 같다.
| 문제 | 설명 |
|---|---|
| memory leak | destroy 또는 delete 누락 |
| dangling pointer | 해제된 메모리를 계속 참조 |
| double free | 같은 메모리를 두 번 해제 |
| buffer overflow | 배열 크기보다 많이 쓰기 |
| null pointer dereference | null pointer를 확인하지 않고 사용 |
| ownership 불명확 | 누가 메모리를 해제해야 하는지 모호 |
따라서 ownership 규칙을 명확히 정해야 한다.
생성한 쪽이 해제하는가?
호출자가 buffer를 제공하는가?
함수가 내부에서 메모리를 할당하는가?
반환된 pointer는 누가 free/delete 하는가?
이 질문에 답하지 못하면 API가 위험해진다.
언제 사용할 만한가
C 스타일 C++은 다음 상황에서 사용할 수 있다.
| 상황 | 이유 |
|---|---|
| C library와 연동 | C ABI에 맞춘 API가 필요 |
| 임베디드 개발 | runtime feature 사용을 제한하고 싶음 |
| 알고리즘 문제 풀이 | 단순한 배열과 함수 중심 코드가 빠름 |
| legacy C codebase | 기존 C 코드와 스타일을 맞춰야 함 |
| low-level module | allocation, layout, ownership을 직접 제어 |
반대로 일반 application 개발에서는 현대 C++ 스타일이 더 안전한 경우가 많다.
std::vector, std::string, std::unique_ptr, RAII를 사용하면 직접 메모리를 해제할 일이 줄어든다.
정리
C++을 C 스타일로 작성하려면 다음 기준을 따르면 된다.
- data는
struct로 표현한다. - 동작은
struct밖의 함수로 분리한다. - constructor/destructor 대신
init/destroy를 둔다. - exception 대신 error code를 반환한다.
- 배열은 pointer와 size를 함께 넘긴다.
- callback은 function pointer로 표현한다.
- memory ownership 규칙을 문서화한다.
- C ABI가 필요하면
extern "C"를 사용한다.
이 방식은 C++의 장점을 많이 포기하는 대신, C와 비슷한 단순한 실행 모델을 얻는다. 따라서 목적이 명확할 때만 선택하는 것이 좋다.