C++ 클래스 입문
퀵 소트(Quick Sort) — 빠른 정렬 알고리즘
퀵 소트는 대표적인 분할 정복(Divide and Conquer) 기반의 정렬 알고리즘입니다. 평균적으로 매우 빠른 성능을 보이며, 실무에서도 널리 사용됩니다.
왜 퀵 소트가 빠른가?
- 메모리 접근 패턴이 효율적: 대부분의 작업이 배열 내에서 이루어져, 캐시 친화적입니다.
- 추가 메모리 필요 없음: 배열을 제자리(in-place)에서 정렬하므로, 별도의 큰 메모리 할당이 필요하지 않습니다.
- 평균 시간 복잡도 O(n log n): 분할이 잘 이루어지면 매우 빠릅니다.
머지 소트(Merge Sort)와의 비교
머지 소트는 항상 O(n log n)의 시간 복잡도를 보장하지만, 추가 메모리 할당이 필요합니다. 정렬 과정에서 임시 배열을 만들어 데이터를 복사해야 하므로, 대용량 데이터에서는 메모리 사용량이 증가하고, 캐시 효율도 떨어질 수 있습니다.
| 알고리즘 | 평균 시간 복잡도 | 추가 메모리 | 캐시 효율성 |
|---|---|---|---|
| Quick Sort | O(n log n) | 거의 없음 | 높음 |
| Merge Sort | O(n log n) | O(n) | 낮음 |
참고 자료
이 글은 C 언어만 알고 있는 사람이 C++ 클래스(class)와 객체지향을 처음 배울 때 이해할 수 있도록 매우 자세하게 설명합니다. 가능한 한 많은 예제와 C와의 비교를 포함했습니다.
0) C와 C++ — 구조체에서 클래스로
C에서는 구조체(struct)로 데이터를 묶고, 함수 포인터나 별도 함수로 동작을 정의합니다.
/* C 스타일 */
struct Point {
int x, y;
};
void Point_move(struct Point *p, int dx, int dy) {
p->x += dx;
p->y += dy;
}
int main() {
struct Point pt = {0, 0};
Point_move(&pt, 3, 4);
}
C++의 클래스는 이를 한 단계 더 진화시켰습니다: 데이터와 함수(메서드)를 함께 정의할 수 있고, 함수가 객체 자신을 자동으로 알 수 있습니다.
/* C++ 스타일 */
class Point {
public:
int x, y;
void move(int dx, int dy) {
x += dx;
y += dy;
}
};
int main() {
Point pt; // 더 간단한 선언
pt.move(3, 4); // 메서드 직접 호출
}
핵심 차이:
- 데이터(멤버 변수)와 함수(메서드)가 같은 클래스 안에 정의됩니다.
- 메서드는 호출할 때 자동으로 자신의 객체를 알고 있습니다(뒤에서 설명).
- C++은 더 강한 타입 안전성과 접근 제어를 제공합니다.
1) 클래스의 기본 구조
클래스는 다음 같은 구조입니다:
class ClassName {
public: // 공개 영역: 외부에서 접근 가능
int data;
void method();
private: // 비공개 영역: 클래스 내부에서만 접근 가능
int secret;
protected: // 보호됨 영역: 클래스 내부 + 상속받은 클래스에서만 접근(상속에서 다룰 예정)
int protectedData;
};
1.1) public vs private
예제로 이해하기:
class BankAccount {
public:
string accountHolder;
double getBalance() const { return balance; } // 밖에서 읽기 가능
void deposit(double amt) { balance += amt; } // 밖에서 입금 가능
private:
double balance = 0.0; // 밖에서 직접 접근 불가능
};
int main() {
BankAccount acc;
acc.accountHolder = "Alice";
acc.deposit(100); // OK
// acc.balance = -1000; // ERROR: private 멤버는 접근 불가
cout << acc.getBalance(); // OK: public 메서드로 읽기
}
왜 private이 필요한가?
- 캡슐화(encapsulation): 외부에서 임의로 내부 상태를 바꾸지 못하도록 보호합니다.
- 은행 계좌를 생각하면,
balance를 공개하면 누구나 마음대로 바꿀 수 있어 위험합니다.deposit/withdraw같은 메서드를 통해서만 변경하도록 강제할 수 있습니다.
2) 메서드(Method) — 클래스의 함수
메서드는 클래스에 속하는 함수입니다. 메서드는 같은 클래스의 멤버 변수와 다른 메서드에 자유롭게 접근할 수 있습니다.
class Counter {
private:
int count = 0; // 멤버 변수 (초기값 0)
public:
void increment() {
count++; // 메서드에서 멤버 변수 접근 (this 생략 가능)
}
int getValue() {
return count;
}
};
int main() {
Counter c;
c.increment();
c.increment();
cout << c.getValue(); // 2
}
메서드가 여러 객체에서 호출될 때, 각 객체의 멤버 변수를 구분하려면 이 포인터(this pointer)를 이해해야 합니다.
3) this 포인터 — “나 자신”을 가리키는 포인터
메서드가 호출되면 컴파일러는 자동으로 현재 객체를 가리키는 this 포인터를 전달합니다(마치 C에서 구조체 함수에 *p 포인터를 전달하는 것처럼).
class Student {
public:
string name;
int age;
void printInfo() {
// this는 현재 객체를 가리키는 포인터
cout << this->name << " age " << this->age << '\n';
// 또는 (*this).name 처럼 쓸 수도 있음
}
};
int main() {
Student s1;
s1.name = "Alice"; s1.age = 20;
s1.printInfo(); // s1의 this가 전달됨
Student s2;
s2.name = "Bob"; s2.age = 25;
s2.printInfo(); // s2의 this가 전달됨
}
C의 관점에서 이해하면:
/* 뒷단에서 C++ 컴파일러가 하는 일(의사 코드) */
void Student_printInfo(Student *this) {
printf("%s age %d\n", this->name, this->age);
}
// s1.printInfo(); 는 실제로 Student_printInfo(&s1); 처럼 변환됨
this는 보통 멤버 변수와 지역 변수 이름이 같을 때 명시적으로 쓸 필요가 있습니다:
class Vector {
public:
double x, y;
void set(double x, double y) {
this->x = x; // 멤버 x = 매개변수 x (구분 필요)
this->y = y;
}
};
4) 생성자(Constructor) — 객체 초기화
생성자는 객체가 만들어질 때 자동으로 호출되는 특수한 메서드입니다. 멤버 변수를 초기화하거나 필요한 설정을 합니다.
class Person {
public:
string name;
int age;
/* 생성자: 클래스명과 같은 이름, 반환값 없음 */
Person() {
name = "Unknown";
age = 0;
}
/* 다른 생성자 (매개변수 있음) */
Person(string n, int a) {
name = n;
age = a;
}
};
int main() {
Person p1; // 첫 번째 생성자 호출
cout << p1.name; // "Unknown"
Person p2("Alice", 25); // 두 번째 생성자 호출
cout << p2.name; // "Alice"
}
생성자가 없으면?
- C++은 기본 생성자(매개변수 없는)를 자동으로 생성합니다(컴파일러가 제공).
- 하지만 명시적으로 정의하는 것이 안전합니다(초기화를 명확히 하기 위해).
4.1) 멤버 이니셜라이저(Member Initializer) — 생성자에서 멤버를 초기화하는 방법
멤버 이니셜라이저(initializer list)는 생성자 선언 뒤에 콜론(:)을 쓰고 멤버를 괄호로 초기화하는 문법입니다. 생성자 본문에서 대입(assignment)하는 것과 달리, 멤버 이니셜라이저는 객체가 생성되는 시점에 직접 초기화합니다. 특히 다음 경우에 필수적이거나 권장됩니다:
- const 멤버나 레퍼런스 멤버를 초기화할 때 (반드시 필요)
- 멤버가 클래스 타입이고 생성 비용이 클 때(성능상 유리)
- 기본 클래스(base class)를 초기화할 때
- 멤버의 초기화 순서를 제어하려고 할 때(단, 실제 초기화 순서는 선언 순서에 따름)
문법:
ClassName(Type1 a, Type2 b)
: member1(a), member2(b) // 여기서 초기화
{
// 본문: 이미 초기화된 멤버에 추가 작업 가능
}
예제 1 — 기본 사용 (간단한 값 초기화)
class Point {
public:
int x;
int y;
// 멤버 이니셜라이저 사용
Point(int x_, int y_) : x(x_), y(y_) { }
};
위처럼 하면 멤버 x,y가 생성될 때 바로 값으로 초기화됩니다. 생성자 본문에서 x = x_; 처럼 대입하는 것보다 멤버 타입이 클래스일 때 불필요한 복사/대입을 피할 수 있습니다.
예제 2 — const / reference 멤버 (필수)
class Person {
const std::string name; // const는 생성자에서 직접 초기화해야 함
int &idRef; // 레퍼런스도 초기화 필요
public:
Person(const std::string &n, int &r) : name(n), idRef(r) { }
};
const 멤버와 레퍼런스는 기본 생성 이후에 값을 대입할 수 없기 때문에 멤버 이니셜라이저로 반드시 초기화해야 합니다.
예제 3 — 멤버가 클래스인 경우(성능적 이점)
class BigBuffer {
public:
BigBuffer(size_t n) { /* 큰 버퍼 할당 등 */ }
};
class Container {
BigBuffer buf;
public:
// 멤버 이니셜라이저로 직접 생성자 호출 -> 불필요한 임시 객체/대입 없음
Container(size_t n) : buf(n) { }
};
만약 생성자 본문에서 buf = BigBuffer(n); 처럼 했다면, buf의 기본 생성(또는 이동)이 일어나고 다시 대입될 수 있습니다. 이니셜라이저를 쓰면 바로 원하는 생성자로 초기화됩니다.
예제 4 — 기본 클래스 초기화 및 멤버 순서
class Base {
public:
Base(int v) { /* ... */ }
};
class Derived : public Base {
int a;
int b;
public:
// Base는 반드시 초기화해야 함
Derived(int v, int x, int y) : Base(v), b(y), a(x) { }
};
중요: 멤버의 실제 초기화 순서는 클래스 내부에서 선언된 순서(위에서 아래)입니다. 위 예제에서 a가 b보다 먼저 선언되어 있다면, 실제로는 a가 먼저 초기화됩니다. 이니셜라이저 목록의 순서는 초기화 순서에 영향을 주지 않습니다. 따라서 경고나 버그를 피하려면 이니셜라이저 순서를 멤버 선언 순서와 일치시키는 것이 좋습니다.
예제 5 — 멤버 객체와 예외 안전 / 이동 생성자 활용
class Resource {
public:
Resource() { /* ... */ }
Resource(const Resource&) = delete;
Resource(Resource&&) noexcept { /* 이동 */ }
};
class Holder {
Resource res;
public:
// res는 이동 또는 복사 생성자에 의해 초기화됨
Holder(Resource &&r) : res(std::move(r)) { }
};
멤버가 복사 불가능하지만 이동 가능한 경우, 이니셜라이저로 std::move를 이용해 이동 생성자를 호출할 수 있습니다.
추가 팁
- 생성자 본문에서 대입하는 것보다 멤버 이니셜라이저를 우선적으로 사용하라(특히 클래스 타입, const, reference).
- 멤버 초기화 순서를 항상 멤버 선언 순서와 맞추면 가독성과 안전성이 높아진다.
- C++11 이후에는 멤버를 클래스 정의에서 초기화(in-class initializer)할 수도 있습니다:
class C { int x = 5; std::string s = "hi"; };하지만 생성자에서 특정 값으로 초기화해야 하면 이니셜라이저 목록을 사용한다.
요약:
- 문법: 생성자 선언 뒤에 “: member(arg), …” 형태로 작성
- 필수 사용: const, reference, 기본 클래스 초기화
- 권장 사용: 성능·명확성 때문에 대부분의 멤버 초기화에 사용 -주의: 실제 초기화는 선언 순서에 따라 이루어짐 — 이니셜라이저 순서를 선언 순서에 맞추자.
5) 소멸자(Destructor) — 객체 정리
소멸자는 객체가 메모리에서 제거될 때 자동으로 호출되는 메서드입니다. 동적으로 할당한 메모리나 파일을 정리할 때 사용합니다.
class FileHandler {
private:
FILE *file;
public:
/* 생성자 */
FileHandler(const char *filename) {
file = fopen(filename, "r");
}
/* 소멸자: ~클래스명 */
~FileHandler() {
if (file) fclose(file); // 정리 작업
}
};
int main() {
{
FileHandler fh("data.txt");
// 파일 사용
} // 이 중괄호를 벗어날 때 자동으로 ~FileHandler() 호출됨
}
C의 관점:
- 생성자 =
malloc+ 초기화 - 소멸자 = 정리 +
free
6) 멤버 변수와 메서드의 메모리 관점
매우 중요: 각 객체는 자신의 멤버 변수 복사본을 가지지만, 메서드는 공유됩니다.
class Dog {
public:
string name; // 객체마다 다름
void bark() {
cout << name << " barks!\n";
}
};
int main() {
Dog d1, d2;
d1.name = "Rex";
d2.name = "Max";
// 메모리 배치:
// d1: [name="Rex"] (8바이트 또는 그 이상)
// d2: [name="Max"] (같은 크기)
// bark() 코드: 모든 Dog 객체가 공유하는 하나의 코드 (텍스트 영역)
}
7) static 멤버 — 모든 객체가 공유하는 데이터/메서드
static 키워드가 붙은 멤버는 객체마다가 아니라 클래스 전체에서 하나만 존재합니다.
class Counter {
public:
static int totalCount; // 모든 Counter 객체가 공유
Counter() {
totalCount++; // 객체 생성될 때마다 증가
}
};
/* static 멤버는 클래스 밖에서 초기화해야 함 */
int Counter::totalCount = 0;
int main() {
Counter c1, c2, c3;
cout << Counter::totalCount; // 3
}
static 메서드:
class MathUtil {
public:
static int add(int a, int b) {
return a + b; // this를 사용할 수 없음
}
};
int main() {
int result = MathUtil::add(3, 5); // 객체 생성 없이 호출 가능
}
8) const 메서드 — “나는 멤버 변수를 바꾸지 않습니다”
const가 붙은 메서드는 멤버 변수를 수정할 수 없습니다(읽기만 가능).
class BankAccount {
private:
double balance;
public:
double getBalance() const { // const 메서드
return balance; // 읽기만 가능
// balance = 0; // ERROR
}
void deposit(double amt) { // 일반 메서드
balance += amt; // 수정 가능
}
};
왜 필요한가?
- 의도 명확화: 이 메서드는 상태를 바꾸지 않는다는 약속.
- const 객체에서만 호출 가능:
const BankAccount acc;
double bal = acc.getBalance(); // OK (const 메서드)
// acc.deposit(100); // ERROR (const 메서드 아님)
9) 객체의 생명주기(스택 vs 힙)
스택에 할당:
{
Counter c; // 생성자 호출
c.increment();
} // 블록을 벗어남 → 소멸자 호출 → 메모리 자동 해제
힙에 할당:
Counter *pc = new Counter(); // 생성자 호출, 힙에 할당
pc->increment();
delete pc; // 명시적으로 해제 → 소멸자 호출
주의: 힙에 할당한 객체는 반드시 delete로 해제해야 합니다(메모리 누수 방지).
10) 실무 예제: 동적 배열을 관리하는 클래스
C에서는 번거로운 작업을 C++에서 깔끔하게:
class IntArray {
private:
int *data;
int size;
public:
/* 생성자 */
IntArray(int n) {
size = n;
data = new int[n];
for (int i = 0; i < n; ++i) data[i] = 0;
}
/* 소멸자 */
~IntArray() {
delete[] data; // 할당한 배열 정리
}
int get(int i) const {
if (i < 0 || i >= size) return -1;
return data[i];
}
void set(int i, int v) {
if (i >= 0 && i < size) data[i] = v;
}
int getSize() const { return size; }
};
int main() {
IntArray arr(5);
arr.set(0, 10);
arr.set(4, 50);
cout << arr.get(0); // 10
cout << arr.get(4); // 50
} // 여기서 arr의 소멸자가 자동 호출 → delete[] 실행
11) 실습: 간단한 학생 정보 관리 클래스
#include <iostream>
#include <string>
using namespace std;
class Student {
private:
string name;
int studentID;
double gpa;
public:
/* 생성자 */
Student(string n, int id, double g) {
name = n;
studentID = id;
gpa = g;
}
/* 정보 출력 */
void printInfo() const {
cout << "Name: " << name << ", ID: " << studentID;
cout << ", GPA: " << gpa << '\n';
}
/* 성적 업데이트 */
void updateGPA(double newGPA) {
if (newGPA >= 0 && newGPA <= 4.0) {
gpa = newGPA;
}
}
/* 성적 조회 */
double getGPA() const { return gpa; }
};
int main() {
Student s1("Alice", 1001, 3.5);
Student s2("Bob", 1002, 3.8);
s1.printInfo();
s2.printInfo();
s1.updateGPA(3.7);
s1.printInfo();
}
요약: 클래스의 핵심 개념
- 클래스 = 데이터(멤버 변수) + 함수(메서드)를 묶은 단위
- public/private로 접근 제어 (캡슐화)
- 생성자: 객체 생성 시 초기화
- 소멸자: 객체 정리 시 정리 작업
- this: 현재 객체를 가리키는 포인터 (자동으로 전달)
- static: 클래스 전체에서 공유하는 데이터/메서드
- const: 상태를 바꾸지 않는 메서드
- 메모리: 멤버 변수는 객체마다 복사, 메서드는 공유
- 스택/힙: 할당 방식에 따라 생명주기가 결정됨
다음 단계: 상속(inheritance), 다형성(polymorphism) 등을 배우면 더 강력한 객체지향 프로그래밍이 가능합니다.