A Tour of C++ 를 읽는 와중에는 다양한 C++ 의 기능을 스리슬~쩍 훑어보는 느낌이 강하다.
그래서 각 섹션의 마지막을 보면 별도로 "조언" 이라는 섹션을 두는 것을 보면 알 수 있다.
그렇다보니 해당 언어의 핵심을 제외하고는 뭔가 상세한 점을 알고자 하면 직접 찾아봐야 한다.
이번 글도 마찬가지로 필자의 궁금증을 한 페이지에 다 해결하고자 이 글을 정리한다.
목표
1. C++에서 제공하는 초기화 방식을 이해한다.
2. C++에서 제공하는 캐스팅 방식을 이해한다.
3. C++에서 제공하는 포인터와 포인터 생성자에 대해 알아본다.
1. C++에서의 초기화 방식과 캐스팅
C++에서 초기화를 어떻게 하는지 그리고 캐스팅은 어떻게 하는지 알아보자.
우선 비교군 위해 Java에서 어떻게 초기화하는지 떠올려보자.
자바 예시 코드
class Robot {
private int id;
private String name;
// 생성자 내부에서 값을 '대입(Assignment)'하여 초기화
public Robot(int id, String name) {
this.id = id;
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
// 1. 원시 타입 초기화
int number = 10;
// 2. 객체 초기화 (무조건 new 키워드를 사용하여 힙에 할당)
Robot r1 = new Robot(1, "Optimus");
// r1은 실제 객체가 아니라, 힙 메모리의 주소를 가리키는 '참조(리모컨)'입니다.
}
}
C++ 예시 코드
#include <iostream>
#include <string>
class Robot {
private:
int id;
std::string name;
public:
// [핵심 1] 멤버 초기화 리스트 (Member Initializer List)
// 생성자 몸체({}) 안에서 대입하는 것이 아니라, 콜론(:) 뒤에서 객체 생성과 '동시에' 초기화합니다.
Robot(int id, std::string name) : id(id), name(name) {
// 몸체는 비워둡니다.
}
};
int main() {
// ---------------------------------------------------------
// 1. 원시 타입 초기화 (3가지 방식)
// ---------------------------------------------------------
int a = 10; // 복사 초기화 (C 언어 스타일)
int b(10); // 직접 초기화 (구형 C++ 스타일)
// [핵심 2] 균일 초기화 (Uniform Initialization) - C++11 이후 강력 권장
int c{10}; // 중괄호를 사용합니다.
// ---------------------------------------------------------
// 2. 객체 초기화
// ---------------------------------------------------------
// [핵심 3] 스택(Stack) 할당 (Java에는 없는 방식)
// new 키워드 없이 선언하면 스택에 즉시 생성되며, 함수가 끝나면 빛의 속도로 자동 소멸합니다.
Robot r1(1, "Optimus"); // 구형 스타일
Robot r2{2, "Wall-E"}; // 모던 C++ 스타일 (중괄호 사용)
// 동적 할당 (힙 할당 - Java의 new와 비슷하지만 직접 delete로 지워야 함)
// 모던 C++에서는 메모리 누수를 막기 위해 가급적 new 대신 스마트 포인터를 씁니다.
Robot* r3 = new Robot(3, "R2D2");
delete r3;
return 0;
}
cppreference를 보면 초기화 방식이 버전에 따라서 또 많다.
그런데 여기서 {} 로 초기화 하는거에 왠만하면 다 된다! 라고 기억하면 된다. 물론 {} 안에 여러개 list를 넣을 수 도 있다.
이것은 Java에서도 마찬가지다. Map.of() 선언에서 초기화시에 중괄호로 엮어서 데이터를 넣어 초기화가 가능하다.
사실 이걸 보면서 초기화 하는 방법 바로 옆에서 찾아서 하면 되겟지~ 싶은 생각이 들었다.
c++에서 초기화는 정적 초기화와 동적 초기화 2개로 나뉜다.
다음 예시 코드를 또 보자.
정적초기화, 동적 초기화, SIOF 회피
#include <iostream>
#include <string>
// =====================================================================
// 1. 정적 초기화 (컴파일러가 구워버림)
// =====================================================================
constexpr double MAX_SPEED = 60.0;
// =====================================================================
// 2. 동적 초기화 (런타임이 main 전에 실행하여 값 세팅)
// =====================================================================
int readSensorConfig() {
std::cout << "[동적 초기화] 1. 센서 버퍼 설정값 로드 중...\n";
return 1024;
}
int globalBufferSize = readSensorConfig();
// =====================================================================
// 3. SIOF 해결책 (Meyers Singleton 로거)
// =====================================================================
class Logger {
public:
Logger() { std::cout << "[동적 초기화] -> Logger 시스템 부팅 완료!\n"; }
void log(const std::string& msg) { std::cout << " [LOG] " << msg << "\n"; }
};
Logger& getLogger() {
static Logger instance;
return instance;
}
// =====================================================================
// 4. 전역 객체 (런타임이 main 전에 생성자 호출)
// =====================================================================
class LidarSensor {
public:
LidarSensor() {
std::cout << "[동적 초기화] 2. LidarSensor 하드웨어 예열 중...\n";
getLogger().log("LidarSensor 예열 완료 (대기 상태)");
}
// [추가됨] main 함수에서 호출할 실제 비즈니스 로직
void scanEnvironment(int bufferSize) {
getLogger().log("주변 3D 포인트 클라우드 스캔 시작... (할당 버퍼: " + std::to_string(bufferSize) + "MB)");
}
};
LidarSensor globalLidar;
// =====================================================================
// 5. 메인 함수 (실제 비즈니스 로직 실행)
// =====================================================================
int main() {
std::cout << "\n========== [ main() 비즈니스 로직 시작 ] ==========\n";
getLogger().log("자율주행 제어 시스템 부팅 완료");
// 활용 1: main 시작 전에 동적 초기화로 구해온 설정값(globalBufferSize)을
// 동적 초기화된 전역 객체(globalLidar)의 메서드에 파라미터로 넘겨 사용합니다.
globalLidar.scanEnvironment(globalBufferSize);
// 활용 2: 차량의 현재 속도가 65.5km/h 라고 가정하고,
// 정적 초기화된 상수(MAX_SPEED)와 비교하여 제한 속도 초과 여부를 검사합니다.
double currentSpeed = 65.5;
if (currentSpeed > MAX_SPEED) {
getLogger().log("경고: 최대 제한 속도(" + std::to_string(MAX_SPEED) + "km/h) 초과! 감속합니다.");
} else {
getLogger().log("정상 속도로 주행 중입니다.");
}
std::cout << "========== [ main() 비즈니스 로직 종료 ] ==========\n";
return 0;
}
아니 여기서 갑자기 SIOF 는 또 뭐야?
정적 초기화 순서 문제는 서로 다른 변환 단위에 있는 정적 저장 기간을 가진 객체들이 초기화되는 순서에 대한 모호성을 말합니다. 한 변환 단위의 객체가 다른 변환 단위의 객체가 이미 초기화되어 있어야 작동하는 경우, 컴파일러가 잘못된 순서로 초기화를 진행하면 크래시가 발생할 수 있습니다.
예를 들어, 명령줄에서 .cpp 파일을 지정하는 순서에 따라 이 순서가 달라질 수 있습니다. '최초 사용 시 생성' 관용구를 사용하면 정적 초기화 순서 문제를 방지하고 모든 객체가 올바른 순서로 초기화되도록 할 수 있습니다.
출처 : https://en.cppreference.com/cpp/language/siof
정리하면 전역 변수들이 main() 함수가 시작되기도 전에 OS 마음대로(순서 보장 없이) 한꺼번에 만들어지기 때문에 SIOF 가 발생한다고 한다. (SIOF : Static Initialization Order Fiasco)
이를 어떻게 해결하는지 AI한테 물어봐고 코드를 본건데 뭔가 이상해서 더 나은 방법이 없는지 물어보니 unique_ptr를 사용한다.
#include <iostream>
#include <memory>
class Logger {
public:
Logger() { std::cout << "로거 부팅\n"; }
~Logger() { std::cout << "로거 종료\n"; }
void log() { std::cout << "로그 쓰기\n"; }
};
// [완성형 SIOF 해결책]
Logger& getLogger() {
// 1. static이 SIOF를 해결 (최초 호출 시점에 초기화 보장)
// 2. unique_ptr가 힙(Heap) 할당과 자동 메모리 해제를 담당
static std::unique_ptr<Logger> instance = std::make_unique<Logger>();
// 포인터가 가리키는 실제 객체(Reference)를 반환
return *instance;
}
int main() {
getLogger().log();
return 0;
}
static이 SIOF를 해결한다고 한다. static 키워드르 함수 내부에 사용하면 C++ 컴파일러는 이 변수의 생성 방식을 완전히 다르게 처리한단다.
static 는 최초 호출 순간에만 만들어지며 c++11 표준부터 static변수 초기화는 무조건 thread-safe차기 동작하게 만들었다.
뭔가 자바에서늬 singleton pattern과 비슷하다. 초기 호출시 생성하고, 생성이 되었으면 그걸 반환하는 방식 말이다.
Java - Logger 코드 예시
public class Logger {
private Logger() {}
// 내부 클래스는 Logger 클래스가 로드될 때 바로 메모리에 안 올라감
private static class InstanceHolder {
// 이 static 변수는 getInstance()가 최초로 호출될 때 비로소 생성됨 (Lazy)
// JVM의 클래스로더가 스레드 안전성을 완벽히 보장함
private static final Logger INSTANCE = new Logger();
}
public static Logger getInstance() {
return InstanceHolder.INSTANCE;
}
}
2. 캐스팅
C++ 에는 캐스팅을 하는데도 키워드가 여러개 있어서 필자가 이걸 찾아보고자 이 목차를 만들었다.
Java에서는 그냥 하면 되지 않나? 많이 단순화된 형식이다.
Java에서의 캐스팅(형변환)은 원시타입과 참조타입 두가지로 나뉜다.
Java 캐스팅 예시
// 부모 클래스
class Sensor {
public void identify() {
System.out.println("일반 센서입니다.");
}
}
// 자식 클래스
class Lidar extends Sensor {
@Override
public void identify() {
System.out.println("Lidar 센서입니다.");
}
public void scan3D() {
System.out.println("3D 포인트 클라우드 매핑 시작...");
}
}
public class Main {
public static void main(String[] args) {
System.out.println("========== [1. 원시 타입 캐스팅] ==========");
// 1-1. 묵시적 캐스팅 (Widening: int -> double)
// 잃어버리는 데이터가 없으므로 컴파일러가 자동으로 캐스팅해 줍니다.
int distance = 150;
double exactDistance = distance;
System.out.println("자동 캐스팅 (int -> double): " + exactDistance); // 150.0 출력
// 1-2. 명시적 캐스팅 (Narrowing: double -> int)
// 소수점이 잘려나가는 데이터 손실이 발생하므로, 개발자가 (int)로 서명해야 합니다.
double temperature = 36.5;
int intTemp = (int) temperature;
System.out.println("강제 캐스팅 (double -> int): " + intTemp); // 36 출력 (0.5 손실)
System.out.println("\n========== [2. 참조 타입 캐스팅] ==========");
// 2-1. 업캐스팅 (Upcasting: 자식 객체 -> 부모 타입)
// Lidar는 언제나 Sensor이므로 100% 안전하게 자동 변환됩니다.
Sensor sensorNode = new Lidar();
// 자식 클래스에서 오버라이딩한 메서드가 실행됩니다. (다형성)
sensorNode.identify();
// 하지만 타입이 Sensor이므로 Lidar 고유의 기능은 호출할 수 없습니다.
// sensorNode.scan3D(); // 컴파일 에러 발생!
// 2-2. 다운캐스팅 (Downcasting: 부모 타입 -> 자식 타입)
// 부모 리모컨으로는 못 쓰던 고유 기능을 쓰기 위해 원래 타입으로 되돌립니다.
// 실제 메모리에 있는 객체가 Lidar가 맞는지 instanceof로 확인 후 강제 변환해야 안전합니다.
if (sensorNode instanceof Lidar) {
Lidar originalLidar = (Lidar) sensorNode; // (Lidar)로 명시적 기입 필수
originalLidar.scan3D(); // 이제 자식 고유 메서드 호출 가능
}
// [참고] Java 16 이후의 모던 패턴 매칭 (가장 권장되는 방식)
// 확인과 동시에 다운캐스팅된 변수(l)를 할당해 주어 코드가 간결해집니다.
if (sensorNode instanceof Lidar l) {
System.out.println("[모던 자바 문법 적용]");
l.scan3D();
}
}
}
솔직히 java는 종류가 뭐 없고 바로 보면 이해가 된다.
그렇지만 C++에서는 이를 4개의 캐스팅 도구를 이용해서만 하도록 강제한다.
static_cast, dynamic_cast, const_cast, reinterpret_cast 가 있다.
#include <iostream>
#include <vector>
// ------------------------------------------
// 기본 클래스 구성
// ------------------------------------------
class Sensor {
public:
virtual ~Sensor() = default; // dynamic_cast를 쓰려면 반드시 virtual이 하나 이상 있어야 함 (RTTI 생성 조건)
virtual void ping() { std::cout << "센서 확인\n"; }
};
class Lidar : public Sensor {
public:
void scan3D() { std::cout << "Lidar: 3D 매핑 수행 중...\n"; }
};
class Radar : public Sensor {
public:
void measureSpeed() { std::cout << "Radar: 도플러 속도 측정 중...\n"; }
};
// ------------------------------------------
// 레거시 C 라이브러리 함수 (const를 지원하지 않음)
// ------------------------------------------
void legacyUpdateConfig(char* configData) {
configData[0] = 'X'; // 강제로 데이터 수정
}
int main() {
std::cout << "========== [1. static_cast] ==========\n";
double rawDistance = 150.7;
// 소수점을 버리고 정수로 변환 (Java의 명시적 캐스팅과 동일)
int distance = static_cast<int>(rawDistance);
std::cout << "변환된 거리: " << distance << "\n";
std::cout << "\n========== [2. dynamic_cast] ==========\n";
Sensor* currentSensor = new Lidar(); // 업캐스팅은 안전함
// 부모 포인터(currentSensor)가 진짜 Lidar인지 런타임에 확인하고 캐스팅
Lidar* lidarPtr = dynamic_cast<Lidar*>(currentSensor);
if (lidarPtr != nullptr) {
// 캐스팅 성공! 실제 객체가 Lidar가 맞음
lidarPtr->scan3D();
}
// currentSensor는 Radar가 아니므로 변환 실패 -> nullptr 반환
Radar* radarPtr = dynamic_cast<Radar*>(currentSensor);
if (radarPtr == nullptr) {
std::cout << "경고: 해당 센서는 Radar가 아닙니다. 변환 실패.\n";
}
std::cout << "\n========== [3. const_cast] ==========\n";
const char config[] = "Default"; // 절대 변경 불가 선언
// 외부 C 라이브러리가 const 형식을 받지 못할 때, 일시적으로 const 해제
legacyUpdateConfig(const_cast<char*>(config));
std::cout << "수정된 설정: " << config << "\n";
std::cout << "\n========== [4. reinterpret_cast] ==========\n";
// 하드웨어 메모리에 매핑된 로우레벨 데이터(바이트 스트림)라고 가정
int packetData = 0x12345678;
// 정수형 데이터를 아예 센서 포인터 주소로 억지 해석 (Java는 절대 불가)
// "이 int 값은 사실 객체의 메모리 주소니까, 포인터로 취급해!"
Sensor* fakeSensor = reinterpret_cast<Sensor*>(&packetData);
std::cout << "패킷 데이터를 메모리 주소로 강제 변환 완료 (실행은 위험함)\n";
// fakeSensor->ping(); // 이 코드를 실행하면 거의 100% 확률로 프로그램 즉사
delete currentSensor;
return 0;
}
하... 뭘 이렇게 복잡하게 만들었어 또 ㅠㅠ...
솔직히 봐도 와닿지가 않는다.
Java기준으로 어떻게 변환되는지 정리했다.
- (int) 3.14 => static_cast(3.14)
- (Parent) child => static_cast<Parent*>(child)
- if (parent instanceof Child) { ... } => if (Child* c = dynamic_cast<Child*>(parent))
- Java의 Unsafe 클래스 조작 => reinterpret_cast
const_cast는 특이한게 const기능을 없애기 위해 쓴다고 한다.
reinterpret_cast는 다른 타입의 pointer로 변환하기 위해 사용한다.
이렇게 어무마시하게 긴 casting 도구들이 왜 생긴건지 Gemini에게 물어보니 요약하면 다음과 같다.
타이핑이 길어지고 귀찮아지더라도, 개발자의 정확한 의도를 코드에 명시하게 만들고, 위험한 짓을 검색하기 쉽게 만들자.
3. 동적 메모리 할당과 스마트 포인터 그리고 스마트 포인터 생성자
동적 메모리 할당과 스마트 포인터!? 이름이 무섭게 생긴거지(?) 그냥 똑같다.
런타임 시에 메모리를 할당하는 것이다.
Java에서의 메모리 할당과 C++에서의 메모리 할당을 비교하는 표를 그렸다.
| 특징 | C++ (수동/반자동) | Java(완전 자동) |
| 할당 방식 | new (Heap), 선언(Stack) | 무조건 new (Heap 할당 위주) |
| 해제 방식 | delete 또는 RAII(소멸자) | GC(가비지 컬렉터) |
| 제어권 | 개발자가 메모리 주소를 직접 관리 | JVM이 관리(주소 접근 불가) |
| 오버헤드 | 없음(zero-overhead) | 객체 헤더(header) 및 gc 추적 비용 발생 |
동적 메모리는 C++도 동일하게 new 키워드를 가지고 한다.
RAII 패턴을 통해서 메모리를 잘 관리해야함을 잊지 말자.
이를 위해서 스마트 포인터가 굉장히 중요해진다.
#include <iostream>
#include <memory> // 스마트 포인터 헤더
#include <string>
class Robot {
public:
Robot(std::string name) : name(name) { std::cout << name << " 부팅\n"; }
~Robot() { std::cout << name << " 종료\n"; }
void run() { std::cout << name << " 작동 중!\n"; }
private:
std::string name;
};
int main() {
// 1. unique_ptr: 소유권 독점
std::cout << "--- unique_ptr ---\n";
std::unique_ptr<Robot> uptr = std::make_unique<Robot>("u-Robot");
uptr->run();
// std::unique_ptr<Robot> uptr2 = uptr; // 에러! 복사 불가
// 2. shared_ptr: 소유권 공유
std::cout << "\n--- shared_ptr ---\n";
std::shared_ptr<Robot> sptr1 = std::make_shared<Robot>("s-Robot");
{
std::shared_ptr<Robot> sptr2 = sptr1; // 카운트 2로 증가
std::cout << "공유 카운트: " << sptr1.use_count() << "\n";
} // sptr2 사라짐, 카운트 1로 감소
std::cout << "카운트 1로 복귀 완료\n";
// 3. weak_ptr: 순환 참조 방지용 참조자
std::cout << "\n--- weak_ptr ---\n";
std::weak_ptr<Robot> wptr = sptr1; // 카운트에 영향 안 줌
if (auto sharedFromWeak = wptr.lock()) { // 진짜 살아있는지 확인 후 사용
sharedFromWeak->run();
} else {
std::cout << "이미 파괴되었습니다.\n";
}
return 0;
} // 여기서 sptr1이 사라지면서 s-Robot도 자동 delete 됨
std::unique_ptr
포인터를 통해 다른 객체를 관리하고 소유하고 unique_ptr이 스코프에서 나갈 때 객체를 없애주는 스마트 포인터
std::shared_ptr
객체의 공유 소유권(Shared Ownership)을 관리하는 스마트 포인터.
std::weak_ptr
std::shared_ptr가 관리하는 객체를 소유하지 않고 참조(Observe)만 하는 스마트 포인터.
명확하게 어느 포인터를 사용할지 정하는것은 초심자 입장에서 쉽지 않다.
그래서 이를 어떻게하면 쉽게 할지 다시 정리햇다.
- unique_ptr: "내가 주인이다. 내 맘대로 한다." (가장 성능 좋음)
- shared_ptr: "우리가 주인이다. 다 같이 관리한다." (카운팅 비용 발생)
- weak_ptr: "참조는 하지만, 주인은 아니다. 너희끼리 알아서 해라." (순환 참조 고리 끊기)
솔직히 이게 와닿지 않는다. 아무리봐도 뭔가 직접 사용하면서 문제가 생기지 않는 이상 말이다.
여기서 make_unique같은 키워드가 보인다.
이것은 스마트 포인터 생성 도구로 정리하면 다음과 같다.
| 생성 함수 | 대상 스마트 포인터 | 역할 및 특징 |
| std::make_unique | unique_ptr | 단일 객체 생성. 가장 권장됨. |
| std::make_unique<T[]> | unique_ptr | 배열 형태의 객체 생성. |
| std::make_shared | shared_ptr | 객체 + 제어블록을 한 번에 할당 (성능 최적화). |
| std::allocate_shared | shared_ptr | 메모리 풀 등 커스텀 할당자를 사용할 때. |
| std::make_shared_for_overwite | shared_ptr | 생성자 호출 없이 메모리만 할당 (C++20). |
A tour of C++를 읽으면서 해당 글들을 정리하고 있는데, 이게 정말 쉽지가 않다.
해당 책은 정말 뭐가 뭐가 있는지 알아보는 정도로 무엇이 있는지 대강 알아보는 수준이고 단편적으로 설명을 해서 쉽지 않다.
참고:
https://en.cppreference.com/cpp/language/initialization
https://en.cppreference.com/cpp/language/siof
https://www.geeksforgeeks.org/cpp/casting-operators-in-cpp/
https://cplusplus.com/doc/tutorial/dynamic/
'programming language > C++' 카테고리의 다른 글
| [C++ 기초] 템플릿과 콘셉트, 가상함수 (0) | 2026.05.22 |
|---|---|
| [C++ 기초] enum class, union, struct와 variant 비교 (0) | 2026.05.19 |
| [C++ 기초] Java와 비교한 C++의 특징 5가지 정리 (2) | 2026.05.18 |