C언어 (8)
1. 객체 배열
1-1. 객체 배열이란?
C에서 int arr[5]로 정수 5개를 묶었듯이, C++에서는 객체 여러 개를 배열로 묶을 수 있다. 학생 100명의 정보를 관리해야 한다면 Student 객체를 100개 일일이 선언하는 대신 배열로 만들면 된다.
1-2. 객체 배열의 선언 형태
class Student {
private:
string name;
int score;
public:
Student() : name("없음"), score(0) {}
Student(string n, int s) : name(n), score(s) {}
void print() {
cout << name << ": " << score << "점" << endl;
}
};
int main() {
Student students[3]; // 기본 생성자가 3번 호출됨
return 0;
}
객체 배열을 선언하면 각 요소마다 기본 생성자(매개변수 없는 생성자) 가 호출된다. 기본 생성자가 없으면 컴파일 에러가 발생하므로, 객체 배열을 사용하려면 반드시 기본 생성자를 정의해야 한다.
초기화 리스트로 선언과 동시에 값을 넣을 수도 있다.
Student students[3] = {
Student("홍길동", 90),
Student("김철수", 85),
Student("이영희", 95)
};
for (int i = 0; i < 3; i++) {
students[i].print();
}
배열의 각 요소는 독립적인 객체다. students[0], students[1], students[2] 각각이 자신만의 name과 score를 가진다. 일반 배열과 마찬가지로 인덱스는 0부터 시작하고, 점(.) 연산자로 멤버에 접근한다.
동적으로 객체 배열을 만들 수도 있다. 이때는 new[]로 할당하고 delete[]로 해제한다.
Student *students = new Student[5]; // 기본 생성자 5번 호출
for (int i = 0; i < 5; i++) {
students[i].print();
}
delete[] students; // 반드시 delete[]로 해제
delete가 아니라 delete[]를 써야 한다. delete[]는 배열의 각 객체에 대해 소멸자를 호출한 뒤 메모리를 해제한다. delete를 쓰면 첫 번째 객체의 소멸자만 호출되어 메모리 누수나 정의되지 않은 동작이 발생한다.
2. 객체 포인터
2-1. 객체 포인터란?
객체 포인터는 객체의 메모리 주소를 저장하는 포인터 다. C에서 구조체 포인터를 사용했던 것과 같은 개념이다.
Student s1("홍길동", 90);
Student *p = &s1;
p->print(); // 화살표 연산자로 멤버 접근
(*p).print(); // 역참조 후 점 연산자 (같은 의미)
구조체 포인터에서 배웠던 것처럼, 포인터로 멤버에 접근할 때는 화살표(->) 연산자 를 사용한다. p->print()는 (*p).print()와 같다.
new로 힙에 객체를 동적 생성하면 포인터로 받아야 한다.
Student *p = new Student("홍길동", 90);
p->print();
delete p; // 사용 후 반드시 해제
객체 포인터 배열을 사용하면 각 객체를 독립적으로 동적 생성하고, 다형성을 활용할 수도 있다.
Student *students[3];
students[0] = new Student("홍길동", 90);
students[1] = new Student("김철수", 85);
students[2] = new Student("이영희", 95);
for (int i = 0; i < 3; i++) {
students[i]->print();
delete students[i];
}
3. this 포인터
3-1. this 포인터가 필요한 이유
클래스의 멤버함수는 여러 객체가 공유한다. Student 객체가 100개 있어도 print 함수의 코드는 메모리에 하나만 존재한다. 그러면 print 함수가 호출될 때 "지금 나를 호출한 객체가 누구인지"를 어떻게 알까?
바로 this 포인터 가 그 역할을 한다. this는 자기 자신(현재 객체)을 가리키는 포인터 로, 멤버함수가 호출될 때 컴파일러가 자동으로 전달한다.
class Student {
private:
string name;
int score;
public:
void setName(string name) {
this->name = name; // this->name은 멤버, name은 매개변수
}
};
매개변수 이름과 멤버변수 이름이 같을 때 this가 특히 유용하다. this->name은 "이 객체의 name 멤버"를 명확히 가리키고, 그냥 name은 매개변수를 가리킨다. this가 없으면 둘 다 매개변수를 가리키게 되어 멤버변수에 값이 대입되지 않는다.
JavaScript의 this와 개념적으로 같다. JS에서 this.name = name 하는 것과 C++에서 this->name = name 하는 것은 같은 맥락이다. 다만 JS의 this는 호출 방식에 따라 바인딩이 달라지는 반면, C++의 this는 항상 해당 멤버함수를 호출한 객체를 가리킨다.
this는 포인터이므로 this-> 또는 (*this).으로 멤버에 접근한다. *this는 객체 자체를 의미하며, 메서드 체이닝에 활용할 수 있다.
class Builder {
private:
int width;
int height;
public:
Builder() : width(0), height(0) {}
Builder& setWidth(int w) {
width = w;
return *this; // 자기 자신을 반환
}
Builder& setHeight(int h) {
height = h;
return *this;
}
};
int main() {
Builder b;
b.setWidth(100).setHeight(200); // 메서드 체이닝
return 0;
}
return *this로 자기 자신의 참조를 반환하면, 반환된 객체에서 다시 메서드를 호출할 수 있다. JavaScript에서 jQuery의 $('#id').css('color', 'red').show() 체이닝과 같은 원리다.
4. 전달인자가 객체인 함수
4-1. 객체 전달 방식
함수에 객체를 넘기는 방법은 크게 값 전달, 포인터 전달, 레퍼런스 전달 세 가지가 있다.
4-2. 객체에 대한 값 전달 방식
값으로 전달하면 객체의 복사본 이 만들어진다. C에서 구조체를 함수에 넘길 때와 같은 원리다.
void printStudent(Student s) { // 복사본이 생성됨
s.print();
}
int main() {
Student s1("홍길동", 90);
printStudent(s1); // s1의 복사본이 함수에 전달됨
return 0;
}
함수 안에서 s를 수정해도 원본 s1에는 영향이 없다. 하지만 문제가 있다.
복사 비용이 크다. 객체가 크면(멤버가 많거나 문자열 등을 포함하면) 복사하는 데 시간과 메모리가 든다.
복사 생성자가 호출된다. 값으로 넘기면 복사 생성자가 호출되어 새 객체가 만들어지고, 함수가 끝나면 소멸자가 호출된다. 동적 메모리를 가진 객체라면 깊은 복사/얕은 복사 문제까지 신경 써야 한다.
4-3. 레퍼런스 형식
레퍼런스(참조)로 전달하면 복사 없이 원본 객체를 직접 사용 한다. 포인터와 비슷하지만 문법이 더 깔끔하다.
void printStudent(const Student &s) { // 복사 없이 원본 참조
s.print();
}
&를 붙이면 레퍼런스로 전달된다. 내부적으로는 포인터와 비슷하게 동작하지만, 사용할 때는 일반 객체처럼 .으로 멤버에 접근한다. -> 연산자를 쓸 필요가 없다.
const를 붙이면 함수 안에서 객체를 수정할 수 없다. 읽기만 하는 함수에서는 const &로 받는 것이 가장 효율적이고 안전하다.
원본을 수정해야 하는 경우에는 const 없이 레퍼런스로 받는다.
void addBonus(Student &s, int bonus) { // 원본 수정 가능
s.addScore(bonus);
}
정리하면, 객체를 함수에 넘길 때는 값 전달보다 레퍼런스 전달이 낫다. 수정이 필요 없으면 const &로, 수정이 필요하면 &로 받는다. 값 전달은 불필요한 복사가 발생하므로 특별한 이유가 없는 한 피하는 것이 좋다.
포인터로 전달하는 것도 가능하지만, C++에서는 레퍼런스가 더 자연스러운 선택이다. 포인터는 NULL이 될 수 있고 -> 연산자를 써야 하지만, 레퍼런스는 반드시 유효한 객체를 가리키고 .으로 접근할 수 있기 때문이다.
5. const 멤버함수와 const 객체
5-1. const 멤버함수
const 멤버함수는 객체의 멤버변수를 수정하지 않겠다고 약속하는 함수 다. 함수 선언 뒤에 const를 붙인다.
class Student {
private:
string name;
int score;
public:
Student(string n, int s) : name(n), score(s) {}
void print() const { // const 멤버함수
cout << name << ": " << score << "점" << endl;
}
void setScore(int s) { // 일반 멤버함수 (멤버 수정)
score = s;
}
};
print() 뒤에 const가 붙어있다. 이 함수 안에서는 멤버변수의 값을 바꿀 수 없다. 만약 const 함수 안에서 멤버를 수정하려 하면 컴파일 에러가 발생한다.
읽기 전용 함수
const 멤버함수는 곧 읽기 전용 함수 다. 값을 읽기만 하고 변경하지 않는 함수에는 const를 붙이는 것이 좋다. getScore(), print(), getName() 같은 함수들이 여기에 해당한다.
const를 붙이는 것이 중요한 이유는 const 객체 때문이다. const로 선언된 객체는 const 멤버함수만 호출할 수 있다.
const Student s1("홍길동", 90);
s1.print(); // OK — const 멤버함수
// s1.setScore(95); // 에러! — const 객체에서 non-const 함수 호출 불가
함수 매개변수로 const &를 받을 때도 마찬가지다. const로 받은 객체에서는 const 멤버함수만 호출할 수 있다.
void display(const Student &s) {
s.print(); // OK — print()이 const 함수이므로
// s.setScore(100); // 에러!
}
print() 함수에 const를 붙이지 않았다면, const &로 받은 객체에서 print()를 호출할 수 없다. 실제로 값을 수정하지 않더라도 컴파일러는 const가 없으면 수정할 수도 있다고 판단하기 때문이다. 읽기 전용 함수에는 반드시 const를 붙이는 습관을 들이는 것이 좋다.
6. static 멤버
6-1. 은행 예금 계좌의 예
은행 시스템을 생각해보자. 계좌 객체가 여러 개 있는데, "총 계좌 수"나 "전체 이자율"같은 정보는 어디에 저장해야 할까?
class Account {
private:
string owner;
int balance;
public:
Account(string name, int money)
: owner(name), balance(money) {}
};
owner와 balance는 계좌마다 다르다. 하지만 "총 계좌 수"는 특정 계좌에 속하는 정보가 아니라, 모든 계좌가 공유하는 정보다.
6-2. 전역변수의 문제점
간단한 해결책은 전역변수를 쓰는 것이다.
int totalCount = 0; // 전역변수
class Account {
public:
Account(string name, int money) {
totalCount++;
}
};
동작은 하지만 문제가 있다. totalCount는 Account 클래스와 논리적으로 관련된 데이터인데, 클래스 바깥에 따로 떨어져 있다. 누구든 totalCount = -999 같이 마음대로 바꿀 수 있고, 캡슐화가 깨진다.
6-3. 우리가 원하는 것
"모든 객체가 공유하면서도, 클래스 안에 묶여 있는 변수"가 필요하다. 이것이 바로 static 멤버 다.
static 멤버변수는 클래스에 속하지만 모든 객체가 공유하는 변수 다. 객체를 아무리 많이 만들어도 static 변수는 메모리에 하나만 존재한다.
class Account {
private:
string owner;
int balance;
static int totalCount; // static 멤버변수 선언
static double interestRate;
public:
Account(string name, int money)
: owner(name), balance(money) {
totalCount++;
}
~Account() {
totalCount--;
}
static int getTotalCount() { // static 멤버함수
return totalCount;
}
static void setInterestRate(double rate) {
interestRate = rate;
}
};
// static 멤버변수는 클래스 외부에서 반드시 초기화해야 함
int Account::totalCount = 0;
double Account::interestRate = 0.05;
int main() {
cout << Account::getTotalCount() << endl; // 0
Account a1("홍길동", 10000);
Account a2("김철수", 20000);
cout << Account::getTotalCount() << endl; // 2
Account::setInterestRate(0.03);
return 0;
}
static 멤버변수는 클래스 안에 선언하지만, 실제 메모리 할당과 초기화는 클래스 외부에서 해야 한다. int Account::totalCount = 0; 이 줄이 없으면 링크 에러가 발생한다.
static 멤버함수는 객체 없이 클래스 이름으로 직접 호출 할 수 있다. Account::getTotalCount()처럼 사용한다. static 멤버함수 안에서는 this 포인터가 없으므로 일반 멤버변수에 접근할 수 없고, static 멤버변수만 사용할 수 있다.
C에서 정적변수가 함수 안에서만 접근 가능하면서 값이 유지되었던 것처럼, static 멤버변수는 클래스 안에서만 접근 가능하면서(private이면) 모든 객체가 공유한다. 전역변수의 공유 기능과 캡슐화의 보호 기능을 동시에 얻을 수 있다.
7. 프렌드 (friend)
C++에서 private 멤버는 클래스 외부에서 접근할 수 없다. 하지만 특정 함수나 클래스에게만 예외적으로 접근을 허용하고 싶을 때가 있다. 이때 사용하는 것이 friend다.
friend로 지정된 함수나 클래스는 해당 클래스의 private 멤버에 접근할 수 있다.
class Student {
private:
string name;
int score;
public:
Student(string n, int s) : name(n), score(s) {}
friend void printInfo(const Student &s); // friend 함수 선언
};
void printInfo(const Student &s) {
// Student의 private 멤버에 직접 접근 가능
cout << s.name << ": " << s.score << "점" << endl;
}
printInfo는 Student의 멤버함수가 아니라 외부 함수 다. 그런데 friend로 선언되었기 때문에 private 멤버인 name과 score에 직접 접근할 수 있다.
클래스끼리도 friend를 선언할 수 있다.
class Engine {
private:
int horsepower;
public:
Engine(int hp) : horsepower(hp) {}
friend class Car; // Car 클래스에게 접근 허용
};
class Car {
public:
void showEngine(const Engine &e) {
// Engine의 private 멤버에 접근 가능
cout << "마력: " << e.horsepower << endl;
}
};
friend class Car로 선언하면, Car 클래스의 모든 멤버함수가 Engine의 private 멤버에 접근할 수 있다.
friend는 편리하지만 캡슐화를 약화시킨다 는 점을 알아야 한다. private으로 보호한 데이터를 외부에 열어주는 것이므로, 남용하면 데이터 은닉의 의미가 퇴색된다. friend는 두 클래스가 밀접하게 협력해야 하거나, 연산자 오버로딩에서 외부 함수가 private 멤버에 접근해야 할 때 등 명확한 이유가 있을 때만 사용하는 것이 좋다.
friend 관계의 특징을 정리하면 이렇다. 단방향 이다 — A가 B를 friend로 선언해도, B가 A의 private에 접근할 수 있는 것이지 그 반대는 아니다. 비전이적 이다 — A가 B의 friend이고 B가 C의 friend여도 A가 C의 friend가 되지는 않는다. 상속되지 않는다 — 부모가 friend라고 자식까지 friend가 되지는 않는다.
마무리
이번 글에서 다룬 내용들은 C++에서 객체를 실제로 다루는 실전적인 기법들이다. 객체 배열과 포인터로 여러 객체를 관리하고, this로 자기 자신을 참조하고, const로 읽기 전용 보장을 하고, static으로 클래스 전체가 공유하는 데이터를 관리하고, friend로 필요한 곳에만 접근을 열어준다. 이 개념들은 이후 상속, 연산자 오버로딩, 복사 생성자를 배울 때 기반이 된다.