C: 절차지향 언어, 함수 중심, 구조체로 데이터 구조화 가능.
C++: 객체지향 언어, 클래스, 상속, 다형성, 캡슐화, 템플릿 제공, C의 기능 대부분 포함.
C와 C++ 은 각자 절차 중심, 객체 중심으로 지향하는 방향이 다르다.
추가로 C++은 함수/연산자 오버로딩, 네임스페이스, 예외 처리 등 더욱 많은 기능이 있다.
또한 메모리 관리 방식에서도 C는 malloc/free, C++은 new/delete 와 같은 기능으로 관리한다.
C++ 에서 클래스와 구조체는 둘 다 변수와 함수 멤버를 포함하고 있다.
하지만 차이점으로는 기본적으로 접근 제어가 클래스는 private, 구조체는 public 이다. 게다가 클래스는 상속 다형성 지원이 일반적이기 때문에 구조체와는 다른점이 많다.
즉, 작은 데이터 집합과 단순 구조는 구조체, 캡슐화 및 객체지향은 클래스이다.
| 구분 | 포인터 | 레퍼런스 |
|---|---|---|
| 선언 | int* p | int& p |
| NULL 가능 | O | X |
| 재할당 가능 | O | X |
| 연산 | O, 포인터 연산 | X, 항상 참조 대상 고정 |
| 사용 | *p = 10 | r = 10 |
이미 해제된 메모리를 가리키는포인터이다.
ex)
int* p = new int(5);
delete p;
// p는 존재하지만 메모리는 해제됨 -> 댕글링
타입이 없는 포인터로, 어떤 타입이든 가리킬 수 있다.
대신 사용 전에 반드시 타입 캐스팅을 해주어야 한다.
ex)
void* vp;
int x = 10;
vp = &x
int* ip = static_cast<int*>(vp);
런타임에 메모리 크기를 결정하고 할당하는 것이다.
아까 말했던 C와 C++ 의 동적 메모리 관리에서 말했듯이 C는 malloc/free, C++ 은 new/delete 를 통해 관리한다.
상세한 내용은 5번에 적혀있다.
포인터가 가리키는 실제 데이터까지 새로 복사하는 것이다.
클래스 내 동적 배열 멤버가 있는 경우 필요하다.
포인터 값만 복사하여 같은 메모리를 참조하는 것이다.
| 구분 | malloc/free | new/delete |
|---|---|---|
| 타입 검사 | X | O |
| 생성자/소멸자 호출 | X | O |
| 연산자 | 함수 | 연산자 |
| 반환 타입 | void* | 실제 타입 포인터 |
static_cast<T>(expr) -> 컴파일 타임 타입 변환
dynamic_cast<T*>(expr) -> 런타임 안전 형변환. 주로 상속 구조
const_cast<T>(expr) -> const 제거/추가
reinterpret_cast<T>(expr) -> 포인터 타입의 비트 단위 변환
AActor* 를 특정 캐릭터 클래스 포인터로 변환해야 할 때 같은 계층 구조에서 변환할 때는 static_cast 가 더 명확하고 안전하다는 점 때문에 사용했다.
AI 시스템에서 UObject* 또는 AActor* 를 다룰 때, 런타임에 해당 AI 가 Enemy 인지 NPC 인지 구분해야하는 경우가 있었기에, dynamic_cast 를 사용해 잘못된 캐스팅을 방지하고 nullptr 체크로 안전하게 처리했다.
반드시 다형성 클래스에서 사용해야 한다.
캐스팅 실패 시 포인터는 nullptr 을 반환하고, 참조 시에는 예외가 발생한다.
런타임에 객체의 실제 타입을 확인하는 기능으로, dynamic_cast 와 typeid 사용 시 필요하다.
전역/지역 변수에 붙이면 프로그램 전체에서 공유되는 저장소를 사용한다.
클래스 멤버 함수/변수에 붙이면 객체마다가 아닌 클래스 단위로 존재한다.
함수 내부에서 붙으면 정적 지역 변수로 함수 호출 시 그 함수가 종료되어도 프로그램이 끝날 때까지 정적 지역 변수는 계속해서 유지된다.
constant. 상수라는 뜻이다.
변하지않는 수, 즉 수정을 할 수 없게 만들 변수에 붙이는 키워드이다.
포인터와 조합해 사용할 수도 있다.
ex)
const int* p // 값 변경 불가
int* const p // 포인터 자체 변경 불가
const int* const p // 둘 다 불가
부모 클래스에서 virtual 키워드를 붇인 멤버 함수를 가상 함수라고 한다.
다형성을 구현하고 런타임에 호출할 함수를 결정하기 위해 C++ 에서 제공하는 기능이다.
런타임에 동일 타입의 포인터나 참조라도 실제 객체의 타입에 따라 다른 함수를 호출하기 위해서 사용된다.
상속 받은 클래스에서 함수를 오버라이드하여 유연하게 동작 변경이 가능해 자주 쓰인다.
동작 원리는 클래스에 vtavle(virtual table) 이라는 가상 테이블이 만들어지고, 가상 함수 호출 시 이 테이블을 통해 다른 함수를 호출하는 방식이다.
가상 소멸자는 상속 구조에서 delete 시 자식 객체도 안전하게 소멸하기 위해 사용하는 기능이다.
C++ 에서 기본 소멸자는 정적 바인딩이 되기 때문에 컴파일러가 포인터 타입만 보고 어떤 소멸자를 호출할지 결정한다.
따라서 delete 시 실제 객체가 자식 클래스여도 타입 포인터가 부모 클래스면 그것만 보고 부모 클래스 소멸자만 호출하게 되는 것이다.
그러므로 부모 클래스의 소멸자에 virtual 키워드를 붙여 가상 소멸자로 만들어주면 컴파일러가 자식 소멸자를 호출하고 부모 소멸자를 호출하기 때문에 부모 포인터로 자식 객체를 관리해도 안전하게 메모리가 해제되게 할 수 있다.
함수 선언부 끝에 '= 0' 을 붙여 만든 가상 함수이다.
자식 클래스에서 이 함수를 반드시 구현하도록 강제하려는 목적이다.
즉, 인터페이스 역할을 한다.
자식 클래스가 이 함수를 반드시 오버라이드해야 구체적 객체가 생성이 가능하다.
만약 이 순수 가상 함수를 가지고 있는 클래스는 추상 클래스가 된다.
추상 클래스는 인스턴스 생성이 불가하고, 포인터나 참조로만 사용이 가능하다.
컴파일 시점에 함수 호출을 결정한다.
런타임 시점에 함수 호출을 결정한다.
같은 이름, 다른 매개변수의 함수를 여러개 만드는 것이다.
컴파일 시점에 어떤 함수를 호출할지 결정하는 정적 바인딩을 사용한다.
ex)
#include <iostream>
using namespace std;
class Print {
public:
void Show(int x) { cout << int: " << x << endl; }
void Show(double x) { cout << double: " << x << endl; }
void Show(int x, int y) { cout << int, int: " << x << ", " << y << endl; }
};
int main() {
Print p;
p.Show(5); // int: 5
p.Show(3.14); // double: 3.14
p.Show(1, 2); // int, int: 1, 2
return 0;
}
위의 예시처럼 같은 이름의 함수이지만 다른 매개변수들로 이루어져있다면 그에 맞춰서 컴파일 타임에 어떤 함수를 호출할지 결정하는 것이다.
상속이 필요 없고 정적 바인딩이기에 런타임 비용이 없다.
상속 구조에서 부모 클래스의 가상 함수를 자식 클래스에서 재정의하는 것이다.
런타임에서 어떤 함수를 호출할지 결정하는 동적 바인딩을 사용한다.
ex)
#include <iostream>
using namespace std;
clss Base {
public:
virtual void Say() { cout << "Base Say" << endl; }
};
class Derived : public Base {
public:
void Say() override { cout << "Derived Say" << endl; }
};
int main() {
Base* b = new Derived();
b->Say(); // Derived Say (런타임 결정)
delete b;
return 0;
}
위의 예시를 보면 알겠지만 부모 클래스인 Base 에서 가상 함수로 Say() 를 정의해두었고, 자식 클래스인 Derived 에서 Say() 함수를 재정의하였는데, 이걸 오버라이딩이라고 한다.
override 를 붙이는 것은 컴파일러에게 이 함수를 재정의했다고 알려주는 역할을 할 뿐, 필수는 아니다.
키워드를 붙이지 않아도 오버라이딩은 진행된다.
STL 은 재사용 가능한 자료구조와 알고리즘을 제공하는 C++ 라이브러리이다.
| 주요 구성 | 기능 |
|---|---|
| 컨테이너 (Containers) | 자료를 저장하는 구조 |
| 알고리즘 (Algorithms) | 정렬, 탐색 등 |
| 이터레이터 (Iterators) | 컨테이너 탐색 |
| 종류 | 예시 | 특징 |
|---|---|---|
| 연속형 | vector, array, string | 메모리 연속, 임의 접근 O |
| 연결형 | list, forward_list | 포인터 연결, 삽입/삭제 O, 임의 접근 X |
| 연관형 | set, map, multiset, multimap | 키 기반, 자동 정렬, 이진 검색 트리(BST) 기반 |
| 해시 기반 | unordered_set, unordered_map | 키 기반, 해시 테이블 기반, 평균 O(1) 탐색 |
| 항목 | Vector | List |
|---|---|---|
| 저장 방식 | 메모리 연속 | 노드 + 포인터 연결 |
| 임의 접근 | O(1) | X, 순차적 탐색 필요, O(n) |
| 삽입/삭제 | 끝: O(1) amortized, 중간: O(n) | 어느 위치든 O(1) (노드만 연결 변경) |
| 메모리 사용 | 상대적으로 적음 | 노드 포인터 추가 시 메모리 더 사용 |
| 구분 | push_back | emplace_back |
|---|---|---|
| 동작 | 객체를 만들어 복사/이동 후 삽입 | 객체를 바로 생성 후 삽입 (in-place) |
| 효율 | 이동/복사 비용 발생 | 이동/복사 비용 없음 -> 생성자 직접 호출 |
| 사용 시점 | 이미 존재하는 객체 삽입 | 생성자 매개변수를 전달하여 직접 삽입 |
즉, push_back 은 객체 생성 > 복사/이동 > 삽입 순이고
emplace_back 은 백터 내부에서 객체 생성 및 삽입을 하기 때문에 더 효율적이다.
하지만 만약에 이미 생성된 객체를 넣어야 한다면 emplace_back 은 사용할 수 없다.
따라서 상황에 맞게 사용해주어야 한다.
| 항목 | map | unordered_map |
|---|---|---|
| 내부 구조 | Red-Black Tree(BST) | 해시 테이블 |
| 정렬 여부 | 자동 오름차순 정렬 | 없음 |
| 검색/삽입/삭제 | O(log n) | 평균 O(1), 최악 O(n) |
| 메모리 사용량 | 상대적으로 적음 | 해시 테이블 + 버킷 (많음) |
키를 버킷 인덱스로 변환한다.
좋은 해시 함수는 키가 균등하게 분포되어 충돌을 최소화한다.
탐색 시간 또한 평균적으로 O(1) 을 유지한다.
두 개 이상의 키가 동일한 버킷에 매핑되는 현상이다.
이를 해결하기 위해서는 동일 버킷에 연결 리스트로 저장하는 체이닝(Chaining) 이나 다른 빈 버킷을 탐색하는 오픈 어드레싱(Open Addressing) 을 사용해야한다.
STL 구현은 보통 체이닝을 사용한다.
주요 컨테이너들의 시간 복잡도 표이다.
| 컨테이너 | 임의 접근 | 검색 | 중간 삽입/삭제 |
|---|---|---|---|
| vector | O(1) | O(n) | O(n) |
| list | X (임의 접근 불가) | O(n) | O(1) |
| map | O(log n) | O(log n) | O(log n) |
| unordered_map | 평균 O(1), 최악 O(n) | 평균 O(1), 최악 O(n) | 평균 O(1), 최악 O(n) |
L-value: 메모리 위치가 있는 값 -> 주소(&)를 가질 수 있음.
R-value: 임시로 계산된 값 -> 주소 없음.
| 범주 | 예시 | 특징 |
|---|---|---|
| lvalue | 변수 x, *ptr, 배열 요소 | 메모리 주소 있음, 재사용 가능 |
| prvalue | 42, "hi", 1 + 2 | 임시 값, 메모리 없음 |
| xvalue | std::move(x), std::unique_ptr().release() | 객체는 있지만 곧 버려질 값 |
| glvalue | lvalue + xvalue | 실제 객체가 있는 값 |
| rvalue | prvalue + xvalue | 오른쪽에만 올 수 있는 값 |
이전에는 객체를 다른 객체에 대입할 때 복사 말고는 방법이 없었다.
하지만 C++ 11 로 넘어가면서 해결책이 생겨났다.
복사대신 자원의 소유권을 이전하는 최적화 방식이다.
큰 버퍼/핸들(파일, 소켓, 힙 메모리 등)을 복사 없이 포인터만 넘기고 원본은 빈 껍데기 상태로 만든다.
std::move(x) 는 이동을 수행하지 않는다.
단지 x 를 rvalue 참조로 캐스팅(xvalue)하여 컴파일러에게 이동이 가능하다고 알리기만 한다.
실제 이동은 무브 생성자/무브 대입 연산자가 수행한다.
즉, std::move() 는 캐스트일 뿐이고, 이동이 가능하다는 신호만 줄 뿐이다.
유효한 상태(소멸자 호출 가능)이지만 값은 미정이다.
컨테이너의 크기를 0, 포인터는 nullptr 등 비어있는 상태로 만들어둔다.
이동된 객체를 파괴, 재할당, 재사용(재초기화) 하는 건 괜찮지만, 내용을 기대하고 사용하면 위험하다.
객체를 새로 생성하면서 다른 객체로 초기화할 때, 복사 생성자와 무브 생성자를 통해 자원을 옮긴다.
이미 존재하는 객체에 값을 대입할 때는 대입 연산자를 사용한다.
또한 함수 반환과 같이 임시가 쓰이는 경우에는 컴파일러 최적화가 개입할 수 있다.
| 구분 | 매크로 함수 | inline 함수 |
|---|---|---|
| 처리 시점 | 전처리기 단계 (컴파일 전) | 컴파일러 단계 |
| 동작 방식 | 단순 문자열 치환 | 함수 호출 대신 코드 삽입 |
| 타입 검사 | X | O |
| 디버깅 | 어려움 | 용이함 |
| 확장성 | 오버로딩, 템플릿 불가 | 오버로딩, 템플릿 가능 |
| 최적화 | 항상 치환됨 | 컴파일러가 결정 |
inline 함수 최적화 부분은 코드가 간단하다면 함수 호출 과정이 사라져서 더 빠르지만,
길고 복잡하다면 컴파일러가 inline 을 무시하고 일반 함수처럼 처리한다.
스마트 포인터는 RAII 를 편리하게 적용한 도구이다.
| 항목 | 특징 |
|---|---|
| unique_ptr | 단독 소유 |
| shared_ptr | 공유 소유 |
| weak_ptr | 비소유 관찰자 |
기본 원칙으로는 unique_ptr 를 우선 사용하고, 여러 주체가 소유해야 하면 shared_ptr 을 쓰되 순환 참조를 weak_ptr 로 끊는 것이다.
자원(메모리, 파일, 핸들, 락, GPU 리소스 등)을 객체의 생성자에서 획득하고 소멸자에서 반환하는 패턴이다.
스마트 포인터는 포인터를 RAII 객체로 감싸 예외가 발생해도 자원 해제를 자동으로 해제해 누수를 방지한다.
하지만 직접 쓰기보다는 unique_ptr + 커스텀 삭제자나 std::ifstream 등 표준 RAII 타입을 사용하는 걸 권장하는 듯하다.
특징
사용 시기
예시
auto ptr = std::make_unique<Player>(100, "User");
std::unique_ptr<Player> ptr2 = std::move(ptr); // 소유권 이전
특징
사용 시기
예시
std::shared_ptr<Texture> tex = std::make_shared<Texture>("grass.png");
std::shared_ptr<Texture> tex2 = tex; // 참조 카운트 +1
특징
사용 시기
예시
struct Node {
std::shared_ptr<Node> child;
std::weak_ptr<Node> parent; // weak_ptr로 선언
};
std::shared_ptr<Node> parent = std::make_shared<Node>();
std::shared_ptr<Node> child = std::make_shared<Node>();
parent->child = child;
child->parent = parent; // 만약 그냥 썼을 경우 순환 참조 → 메모리 해제 안 됨