티스토리 뷰
※ 주의 사항 ※
- 이 글의 목적은 '지식의 전달'이 아닌 '학습의 기록'입니다.
- 따라서 제가 이해하는 그대로의 내용이 포함됩니다.
- 따라서 이 글은 사실과는 다른 내용이 포함될 수 있습니다.
이 글을 읽기 전 객체 지향 언어란 무엇인지, 객체란 또 무엇인지에 대한 개념 이해가 필요하다면 아래에 링크해 둔 글을 먼저 읽고 오길 바란다.
2022.02.08 - [C++ 공부 일지] - C++17 객체 지향 언어와 객체(Object)의 개념 이해
객체(Object)와 클래스(class)의 관계
클래스는 객체의 설계도다. 위에 링크된 글에서는 '개발자가 필요한 프로시저들을 객체에 전달하고 이를 사용할 객체를 만든다'는 표현을 했지만 사실 개발자가 만드는 건 객체 그 자체가 아니라 객체의 설계도인 클래스다.
클래스의 기본 형태
간단한 클래스의 정의를 보면서 클래스의 기본 형태를 설명해보겠다.
class Person {
public: //이 녀석은 일단은 무시하자
int age;
std::string name;
void showInfo() {
std::cout << "name : " << name << ", age : " << std::endl;
}
};
클래스는 class라는 키워드를 사용하여 정의한다. class 다음에 오는 Person은 클래스의 이름이다. 우리는 '사람'이라는 객체를 생성할 설계도를 정의하는 것이다. 일단 public은 나중에 설명할 기회가 있으니 일단 넘어가자.
클래스 안에는 int 타입의 age 변수와 string 타입의 name 변수, showInfo() 함수가 선언 및 정의되어 있다. 이들은 클래스 Person의 멤버이고, 멤버 변수, 멤버 함수라고 부른다. C언어를 해봤다면 클래스의 형태가 C언어의 구조체와 거의 같다는 것을 알 수 있을 것이다. 다만 C언어의 구조체는 함수를 멤버로 가질 수는 없었다. 클래스의 멤버 변수는 선언만 가능하고 초기화는 할 수 없다. 다만 멤버 함수는 선언뿐만 아니라 정의까지 가능하다. 이게 왜 이렇게 되는지 생각해보자.
클래스는 객체의 설계도라고 했다. 설계도 하나로 제품을 여러 개 찍어 내듯이, 클래스 하나로 객체를 여러 개 찍어낼 수 있다. 위에 정의된 Person 클래스로 객체를 생성하면 그 객체들은 모두 age, name, showInfo()를 멤버로 가지게 된다. 그런데 사람들은 제각각 나이도 다르고 이름도 다르지 않은가? 만약 클래스에서 age를 20, name을 "홍길동"으로 초기화한다면 저 클래스에서 생성되는 모든 객체(사람)들은 20살 홍길동이 된다(그림자 분신술인가?). 왜 클래스에서 멤버 변수를 초기화하면 안 되는지 이해했는가? 클래스에서 멤버 변수의 초기화를 시도한다고 해도 컴파일러가 이를 에러로 막기 때문에 애초에 불가능한 방법이지만 그래도 왜 안 되는 것인지는 이해하고 넘어가는 게 좋다.
그럼 멤버 함수는 왜 정의까지 가능한가? 모든 사람의 행동은 쉽게 통일시킬 수 있다. 실제로는 디테일이 다들 다르지만 하려고 하면 걸을 때는 어떻게 걷도록 통일시키고, 숨 쉴 때는 어떻게 쉬도록 통일시키고, 자기소개 방식도 하나로 통일시킬 수 있지 않은가?
객체 생성, 객체 초기화, 멤버 함수 호출
클래스는 설계도이고, 클래스로 객체를 생성한다고 했다. 아래의 Person 클래스로 객체를 생성하는 코드를 보자.
#include<iostream>
#include<string>
class Person {
public: //이 녀석은 일단은 무시하자
int age;
std::string name;
void showInfo() {
std::cout << "name : " << name << ", age : " << age << std::endl;
}
};
int main() {
Person p1;
p1.age = 32;
p1.name = "홍길동";
Person* p2 = new Person(); //동적 할당으로 객체 생성
p2->age = 22;
p2->name = "김수지";
p1.showInfo();
p2->showInfo();
delete p2;
return 0;
}
/*
실행결과
name : 홍길동, age : 32
name : 김수지, age : 22
*/
클래스 이름 Person은 데이터 타입이 된다. p1과 p2를 객체라고 부른다. C언어의 구조체였다면 구조체 변수라고 불렸을 녀석들이다. p2는 new 연산자를 활용해 생성한 객체다. 앞으로 객체 지향 프로그래밍을 하게 되면 p1보다는 p2와 같이 동적 할당으로 객체를 생성하는 방법을 더 많이 사용하게 될 것이다. new 연산자로 객체를 생성할 때 클래스 이름에 () 연산자가 붙었는데 이에 대해서는 나중에 설명하겠다. new 연산자로 생성한 객체는 반드시 delete로 소멸시켜줘야 함을 잊지 말자.
객체를 생성하고 각각의 멤버 변수들을 초기화해주었다. 멤버에 접근할 때는.(멤버 접근 연산자)를 사용하는데 포인터를 통해 접근할 때는 ->연산자를 이용해 접근한다. 두 객체 p1, p2의 멤버 함수 showInfo()를 호출했을 때 실행결과를 보면 각자 다른 이름과 나이를 가지고 있음을 알 수 있다. p1객체는 32살 홍길동이라는 사람 객체이고, p2객체는 22살 김수지라는 사람 객체이다.
위의 예시로 우리는 간단한 클래스를 정의했고, 이를 이용해 객체를 생성했으며, 객체의 멤버 변수에 직접 접근하여 서로 다른 값을 가지도록 초기화했고, 각 객체의 멤버 함수를 호출했다. 이로써 클래스를 작성하고, 객체를 생성하여 객체를 다루는 일련의 과정을 경험했다.
잘 만들어진 클래스
앞서 우리는 간단한 클래스 Person을 만들었지만 사실 이 클래스는 '잘 만들어진 클래스'는 아니다. 클래스면 클래스고 아니면 아닌 거지 잘 만들어진 클래스가 아니라니 이게 무슨 말인지 싶을 것 같다. 우선 우리가 왜 클래스를 배우는지 생각해보자.
우리가 클래스를 만드는 이유는 이를 통해 객체를 생성하기 위함이다. 그리고 우리가 객체를 필요로 하는 이유는 객체 지향 프로그래밍을 하기 위함이다. 그런데 아무렇게나 일단 클래스를 만들고 객체를 생성했다고 해서 이게 객체 지향 프로그래밍이 되는 것은 아니다. 사실 기껏 클래스를 만들어 놓고 절차 지향 프로그래밍과 다름없는 프로그래밍을 하고 있는 사람도 허다하다. 객체와 클래스는 객체 지향 프로그래밍에 필요한 준비물일 뿐이며, 이 준비물은 객체 지향 프로그래밍의 핵심 요소들을 잘 반영하도록 설계되어야 한다.
잘 만들어진 클래스란 '객체 지향의 핵심 5요소'가 반영되어 설계된 클래스를 말한다.
객체 지향 핵심 요소 1. 캡슐화
캡슐이라 하면 알약이 쉽게 떠오르지 않은가? 클래스에서의 캡슐화도 알약에 쉽게 비유할 수 있다. 캡슐화에는 2가지 특징이 존재한다.
- 서로 연관된 기능만 하나의 캡슐에 담기
- 캡슐 안의 내부 구현을 전혀 모르더라도 아무런 문제 없이 사용이 가능
우리가 감기 몸살에 걸리면 타이레놀을 먹는다. 타이레놀은 해열작용을 하고 진통효과를 가져오는 기능을 가지고 있으며, 실제로 이를 섭취하면 열이 내리고 통증이 한결 줄어든다. 그렇다고 타이레놀이 소화를 촉진하거나 비타민이 들어 있어 비타민의 섭취를 돕는다든가 하는 등의 기능은 없다. 이는 소화제와 비타민제가 각각 할 일이다. 물론 타이레놀에 소화 촉진제나 비타민이 첨가되어 있다고 해도 딱히 문제가 될 일은 없어 보이지만 비용만 증가하고 사용만 까다롭게 할 뿐이다. 이것이 캡슐화의 첫 번째 특징, '서로 연관된 기능만 하나의 캡슐에 담기'의 예다.
우리는 타이레놀이라는 알약 안에 어떤 성분이 얼마나 들어 있고, 해당 성분들이 체내에서 어떤 작용을 하는지 모른다. 만약 약사가 환자에게 타이레놀을 처방하면서 "이 약은 ㅇㅇㅇ성분이 30%, ㅁㅁㅁ 성분이 12% 들어 있으며, ㅇㅇㅇ 성분이 체내에서 ~~~ 작용을 하고, ㅁㅁㅁ성분이 체내에서 ^^^작용합니다."라고 설명하면, 환자가 뭐라고 할까? "그래서 이거 먹으면 뭐가 좋은데요?". 클래스도 마찬가지다 이 클래스를 사용하면 우리가 무엇을 할 수 있는지에 관심이 있지 이 클래스가 내부적으로 어떻게 구현되었는지는 관심도 없고 알 필요도 없다. 이렇게 클래스의 내부 구현을 외부에 숨기면서 필요한 기능을 제공하는 것이 캡슐화의 두 번째 특징이다.
캡슐화의 예시를 보자.
#include <iostream>
class TV {
public: //일단 이건 무시
int chanel;
int volume;
void chanelUp() {
std::cout << "채널값을 증가시킵니다." << std::endl;
}
void chanelDown() {
std::cout << "채널값을 감소시킵니다." << std::endl;
}
void volumeUp() {
std::cout << "볼륨을 높입니다." << std::endl;
}
void volumeDown() {
std::cout << "볼륨을 낮춥니다." << std::endl;
}
//TV와는 어울리지 않은 기능
void windOn() {
std::cout << "시원한 바람이 나옵니다." << std::endl;
}
void windOff() {
std::cout << "시원한 바람이 나오지 않습니다." << std::endl;
}
};
int main() {
TV* tv = new TV();
tv->chanelUp();
tv->volumeDown();
//TV에서 바람이 나오는 기능 사용
tv->windOn();
tv->windOff();
delete tv;
return 0;
}
/*
실행결과
채널값을 증가시킵니다.
볼륨을 낮춥니다.
시원한 바람이 나옵니다.
시원한 바람이 나오지 않습니다.
*/
위 예시에서 TV 클래스를 정의하고 있는데 TV에서 시원한 바람이 나오는 기능까지 구현되어 있다. 물론 무더운 여름 TV 앞에 앉아 시원한 바람을 맞으면서 예능을 보는 상상을 하면 행복하긴 하지만 시원한 바람이 나오는 건 TV의 본래 역할과는 거리가 멀다. 이는 에어컨이나 선풍기가 대신할 일이다. 따라서 위 예시는 캡슐화가 제대로 이루어졌다고 보기 어렵다.
객체 지향 핵심 요소 2. 정보 은닉
정보 은닉이란, 정보를 감추어 숨긴다는 것을 말한다. 그럼 클래스에서의 정보 은닉이란 어떤 정보를 누구로부터 숨긴다는 말일까? 우선 아래의 예제를 보자.
#include <iostream>
class TV {
public: //일단 이건 무시
int chanel;
int volume;
void chanelUp() {
if (++chanel > 100) {
chanel = 100;
std::cout << "채널값을 더 증가시킬 수 없습니다. (채널 : " << chanel << ")" << std::endl;
}
else {
std::cout << "채널값을 증가시킵니다. (채널 : " << chanel << ")" << std::endl;
}
}
void chanelDown() {
if (--chanel < 1) {
chanel = 1;
std::cout << "채널값을 더 감소시킬 수 없습니다. (채널 : " << chanel << ")" << std::endl;
}
else {
std::cout << "채널값을 감소시킵니다. (채널 : " << chanel << ")" << std::endl;
}
}
void volumeUp() {
if (++volume > 100) {
volume = 100;
std::cout << "볼륨을 더 높일 수 없습니다. (볼륨 : " << volume << ")" << std::endl;
}
else {
std::cout << "볼륨을 낮춥니다. (볼륨 : " << volume << ")" << std::endl;
}
}
void volumeDown() {
if (--volume < 0) {
volume = 0;
std::cout << "볼륨을 더 낮출 수 없습니다. (볼륨 : " << volume << ")" << std::endl;
}
else {
std::cout << "볼륨을 낮춥니다. (볼륨 : " << volume << ")" << std::endl;
}
}
};
int main() {
TV* tv = new TV();
tv->chanel = 1;
tv->volume = 1;
tv->chanelUp();
tv->volumeDown();
tv->volumeDown();
delete tv;
return 0;
}
/*
실행결과
채널값을 증가시킵니다. (채널 : 2)
볼륨을 낮춥니다. (볼륨 : 0)
볼륨을 더 낮출 수 없습니다. (볼륨 : 0)
*/
위 예제를 보면 TV의 채널과 볼륨을 변경하는 것을 멤버 함수의 호출로 구현하고 있는데, 사실은 chanel과 volume 변수에 직접 접근하여 다음과 같이 변경하는 것이 가능하다.
int main() {
TV* tv = new TV();
tv->chanel = 1;
tv->volume = 1;
//chanel과 volume에 직접 접근하여 값 변경
tv->chanel += 1;
tv->volume -= 1;
tv->volume -= 1;
std::cout << "chanel : " << tv->chanel << ", volume : " << tv->volume << std::endl;
delete tv;
return 0;
}
/*
실행결과
chanel : 2, volume : -1
*/
그런데 chanel과 volume 변수는 TV객체에게 있어 핵심적인 데이터인데 이런 핵심 데이터에 직접 접근하여 값을 변경할 수 있다는 것은 상당히 문제가 된다. 당장 위 예시만 봐도 volume이 음수로 떨어지는 있어서는 안 될 일이 일어났지 않은가? 클래스가 감추어야 할 정보란 바로 이런 핵심적인 데이터를 의미한다. 클래스는 핵심 데이터를 외부에서 직접 접근하는 것이 불가능하도록 막음과 동시에 해당 데이터에 안전하게 접근할 수 있는 가이드라인(멤버 함수)을 제공해야 한다. 그럼 이를 어떻게 실제 코드로 구현할 수 있을까?
접근 제어 지시자
클래스의 멤버에 대한 직접 접근 허용 범위를 설정하기 위해 접근 제어 지시자를 사용한다. 접근 제어 지시자에는 아래의 세 가지가 존재한다.
- private
- protected
- public
이 중 protected는 클래스의 상속과 관계되어 있기 때문에 protected에 대한 자세한 설명은 클래스의 상속을 배운 이후로 미루고, private과 public에 대해서만 설명하겠다.
어느 클래스의 멤버가 public으로 선언되었다면 모든 곳에서 그 멤버에 직접 접근하는 것이 가능하다. 앞선 TV 클래스의 예시에서 멤버 변수 chanel과 volume에 외부에서의 직접 접근이 가능했던 이유는 이들이 public으로 선언되었기 때문이다.
반면 private으로 선언된 멤버에는 해당 클래스 자신을 제외한 그 어느 곳에서도 직접 접근할 수 없다. TV 클래스의 멤버 변수 chanel과 volume을 private으로 바꾸면 외부에서 이들에 직접 접근하는 것이 불가능해진다.
#include <iostream>
class TV {
private: //여기서부터 다른 접근 제어 지시자가 사용되기 전까지 선언되는 멤버들은 모두 private
int chanel;
int volume;
public: //여기서부터 다른 접근 제어 지시자가 사용되기 전까지 선언되는 멤버들은 모두 public
void chanelUp() {
if (++chanel > 100) {
chanel = 100;
std::cout << "채널값을 더 증가시킬 수 없습니다. (채널 : " << chanel << ")" << std::endl;
}
else {
std::cout << "채널값을 증가시킵니다. (채널 : " << chanel << ")" << std::endl;
}
}
void chanelDown() {
if (--chanel < 1) {
chanel = 1;
std::cout << "채널값을 더 감소시킬 수 없습니다. (채널 : " << chanel << ")" << std::endl;
}
else {
std::cout << "채널값을 감소시킵니다. (채널 : " << chanel << ")" << std::endl;
}
}
void volumeUp() {
if (++volume > 100) {
volume = 100;
std::cout << "볼륨을 더 높일 수 없습니다. (볼륨 : " << volume << ")" << std::endl;
}
else {
std::cout << "볼륨을 낮춥니다. (볼륨 : " << volume << ")" << std::endl;
}
}
void volumeDown() {
if (--volume < 0) {
volume = 0;
std::cout << "볼륨을 더 낮출 수 없습니다. (볼륨 : " << volume << ")" << std::endl;
}
else {
std::cout << "볼륨을 낮춥니다. (볼륨 : " << volume << ")" << std::endl;
}
}
};
int main() {
TV* tv = new TV();
//chanel과 volume에 직접 접근이 불가하여 초기화 불가
//tv->chanel = 1;
//tv->volume = 1;
//chanel과 volume에 직접 접근하여 값 변경 불가
//tv->chanel += 1;
//tv->volume -= 1;
//tv->volume -= 1;
//chanel과 volume에 직접 접근이불가하여 값 출력 불가
//std::cout << "chanel : " << tv->chanel << ", volume : " << tv->volume << std::endl;
delete tv;
return 0;
}
chanel과 volume이 private 지정되었기 때문에 외부에서 초기화도, 값 변경도, 출력도 불가능해졌다. 이제 chanel과 volume을 초기화, 값 변경, 출력하기 위해서는 해당 기능을 가지는 멤버 함수를 클래스 내부에 정의하여 이를 호출해야 한다.
#include <iostream>
class TV {
private:
int chanel;
int volume;
public:
//멤버 변수를 초기화하는 함수 추가
void initialization() {
//클래스 내부에서는 private 멤버에 직접 접근하는 것이 가능
chanel = 1;
volume = 1;
}
//chanel과 volume을 출력하는 함수 추가
void showInfo() {
std::cout << "chanel : " << chanel << ", volume : " << volume << std::endl;
}
void chanelUp() {
if (++chanel > 100) {
chanel = 100;
std::cout << "채널값을 더 증가시킬 수 없습니다. (채널 : " << chanel << ")" << std::endl;
}
else {
std::cout << "채널값을 증가시킵니다. (채널 : " << chanel << ")" << std::endl;
}
}
void chanelDown() {
if (--chanel < 1) {
chanel = 1;
std::cout << "채널값을 더 감소시킬 수 없습니다. (채널 : " << chanel << ")" << std::endl;
}
else {
std::cout << "채널값을 감소시킵니다. (채널 : " << chanel << ")" << std::endl;
}
}
void volumeUp() {
if (++volume > 100) {
volume = 100;
std::cout << "볼륨을 더 높일 수 없습니다. (볼륨 : " << volume << ")" << std::endl;
}
else {
std::cout << "볼륨을 낮춥니다. (볼륨 : " << volume << ")" << std::endl;
}
}
void volumeDown() {
if (--volume < 0) {
volume = 0;
std::cout << "볼륨을 더 낮출 수 없습니다. (볼륨 : " << volume << ")" << std::endl;
}
else {
std::cout << "볼륨을 낮춥니다. (볼륨 : " << volume << ")" << std::endl;
}
}
};
int main() {
TV* tv = new TV();
tv->initialization();
tv->chanelUp();
tv->volumeDown();
tv->volumeDown();
tv->showInfo();
delete tv;
return 0;
}
/*
실행결과
채널값을 증가시킵니다. (채널 : 2)
볼륨을 낮춥니다. (볼륨 : 0)
볼륨을 더 낮출 수 없습니다. (볼륨 : 0)
chanel : 2, volume : 0
*/
이렇게 핵심 데이터를 숨기고, 이 핵심 데이터를 초기화하거나, 값을 변경하거나, 값을 가져오는 등의 가이드라인(멤버 함수)을 제공하는 것을 정보 은닉이라고 한다. 접근 제어 지시자의 사용 방법은 따로 설명하지 않아도 위의 예시들을 통해 얼추 이해했으리라고 생각한다. 그 얼추 이해한 게 맞다. 별로 어려울 게 없다.
this 포인터
TV 클래스의 멤버 변수들을 초기화하는 멤버 함수를 정의했었는데 만약 이 함수가 매개 변수를 받는 함수라면 매개 변수명과 멤버 변수명이 같게 되는 일이 쉽게 일어난다. 아래의 예제를 보자.
void initialization(int chanel, int volume) {
chanel = chanel;
volume = volume;
}
그런데 위 예제에서 어느 chanel이 멤버 변수를 말하는 것이고, 어느 chanel이 매개 변수를 말하는 것인지 구분할 수 있는가? 물론 개발자인 우리는 위 코드의 의도를 이해할 수 있다. 하지만 컴파일러는 대입 연산자의 왼쪽의 chanel도 매개 변수의 것으로 본다. 즉, 매개 변수로 입력받은 chanel에 매개 변수 chanel을 대입하는 연산이 일어나게 되고, 정작 멤버 변수는 초기화하지 못하는 일이 발생한다.
이런 문제를 방지하기 위해 클래스 내부에서는 this 포인터를 사용할 수 있다. this 포인터는 클래스 자신을 가리키는 포인터다. 제대로 말하면 이 클래스로부터 생성된 객체 자신을 가리키는 포인터다. 이 멤버 함수들은 객체를 통해 호출되기 때문이다. this 포인터를 사용하면 어느 chanel이 멤버 변수이고, 매개 변수인지 명확히 구분할 수 있다.
void initialization(int chanel, int volume) {
this->chanel = chanel;
this->volume = volume;
}
자판기는 캡슐화와 정보 은닉이 잘 된 객체
나는 캡슐화와 정보 은닉이 잘 된 객체를 자판기로 생각한다. 자판기는 핵심 데이터(음료)를 외부로부터 철저히 보호하면서 자신과 상호작용할 수 있는 버튼(가이드라인, 멤버 함수)을 제공하여 사용자와 대화하고 거래를 한다. 그리고 서로 연관된 음료, 과자 등만 포함하고 있으므로 캡슐화도 잘 되어 있다. 자판기에서 에어컨 바람이 나오게 하지는 않지 않은가?
객체 생성 시 호출되는 생성자
생성자는 객체 생성 시 자동으로 호출되는 특수 목적의 함수입니다. 객체는 다음과 같은 상황에서 생성된다.
#include <iostream>
class Simple { };
int main() {
//객체를 선언을 통해 객체 생성
Simple s1;
//동적 할당을 통해 객체 생성
Simple* s2 = new Simple;
return 0;
}
생성자도 함수이기 때문에 개발자가 직접 정의할 수 있는데, 개발자가 생성자를 따로 정의하지 않으면 컴파일러는 다음과 같은 '디폴트 생성자'를 자동으로 삽입한다.
#include<iostream>
class Simple {
public:
Simple() {} //자동으로 삽입되는 디폴트 생성자의 형태
};
디폴트 생성자를 포함한 모든 생성자는 다음의 특징을 가진다.
- 반환 타입이 없으며 실제로 아무런 값도 반환하지 않음
- 함수명이 클래스명과 동일함
여기에 더해 디폴트 생성자는 다음의 특징을 추가로 가진다.
- 아무런 매개 변수를 가지지 않음
컴파일러가 자동으로 삽입하는 디폴트 생성자는 함수의 내용도 비어 있어 결과적으로 호출만 될 뿐 아무런 동작도 하지 않지만 디폴트 생성자를 직접 정의할 때는 함수의 내용을 입맛대로 채워도 상관없다. 디폴트 생성자의 함수 내용에 생성자 호출을 알리는 문구를 출력하도록 코드를 작성하여 정의해보겠다. 그리고 이를 통해 생성자의 호출을 확인해 보겠다.
#include <iostream>
class Simple {
public:
//디폴트 생성자 정의
Simple() { std::cout << "생성자 호출!" << std::endl; }
};
int main() {
//객체를 선언을 통한 객체 생성 확인
Simple s1;
//동적 할당을 통한 객체 생성 확인
Simple* s2 = new Simple;
return 0;
}
/*
실행결과
생성자 호출!
생성자 호출!
*/
생성자는 주로 객체를 초기화할 때 사용한다. 앞서 정보 은닉에 대해 배울 때, TV 클래스에 initialization() 멤버 함수를 정의하고, 객체 생성 후 이 함수를 호출하여 객체의 초기화를 진행했다. 그런데 객체를 생성하고 initialization() 함수의 호출을 깜빡하는 실수를 저지를 수 있다. 하지만 생성자를 사용하면 객체의 생성과 동시에 초기화가 가능하기 때문에 그런 실수를 할 여지가 없고, 또 편리하다. 다음 예제를 보자.
#include <iostream>
class TV {
int chanel;
int volume;
public:
TV() {
std::cout << "생성자 호출!" << std::endl;
chanel = 1;
volume = 1;
}
void showInfo() const{
std::cout << "chanel : " << chanel << ", volume : " << volume << std::endl;
}
};
int main() {
TV tv1;
tv1.showInfo();
return 0;
}
/*
실행결과
생성자 호출!
chanel : 1, volume : 1
*/
객체를 생성만 하고 바로 showInfo() 함수를 호출했는데 객체의 멤버 변수 chanel과 volume은 이미 초기화되어 있음을 확인할 수 있다. 객체 성성 시 자동으로 호출된 생성자에서 초기화를 진행했기 때문이다.
매개 변수를 가지는 생성자를 정의하면 객체 생성 시 인수를 전달하여 초기화를 진행할 수도 있다. 매개 변수를 가지는 생성자를 정의하고 이를 이용해 객체를 생성하는 방법은 어렵지 않다. 다음의 예제를 보자.
#include <iostream>
class TV {
int chanel;
int volume;
public:
TV(int chanel, int volume) {
std::cout << "생성자 호출! (인수 : " << chanel << ", " << volume << ")" << std::endl;
this->chanel = chanel;
this->volume = volume;
}
void showInfo() {
std::cout << "chanel : " << chanel << ", volume : " << volume << std::endl;
}
};
int main() {
//객체 이름 뒤에 괄호를 열어 인수를 전달
TV tv1(12, 20);
tv1.showInfo();
//new 연산자로 동적 할당 시 클래스명 뒤에 괄호를 열어 인수를 전달
TV* tv2 = new TV(33, 44);
tv2->showInfo();
return 0;
}
/*
실행결과
생성자 호출! (인수 : 12, 20)
chanel : 12, volume : 20
생성자 호출! (인수 : 33, 44)
chanel : 33, volume : 44
*/
매개 변수를 가지는 생성자를 정의할 때는 하나 주의해야 할 점이 있다. 바로 디폴트 생성자도 반드시 같이 정의해주어야 한다는 것이다. 위 예제에서는 매개 변수를 가지는 생성자를 정의했지만 디폴트 생성자를 같이 정의하지는 않았다. 이렇게 되면 인수를 전달하지 않고는 객체를 생성할 수 없게 된다. 여기서 '어차피 객체 생성할 때 인수 전달해서 쓸건대? 굳이 필요할까?'하고 생각할 수 있다. 하지만 개발자는 다른 개발자들과 협업하는 일도 많고, 또 해당 클래스를 어떻게 사용하게 될지는 전혀 알 수 없기 때문에 디폴트 생성자도 반드시 같이 정의해주는 것이 좋다.
디폴트 생성자를 별도로 정의하는 것이 귀찮으면 다음과 같이 생성자의 모든 매개 변수가 디폴트 값을 가지게 하는 것도 가능한 방법이다.
class TV {
int chanel;
int volume;
public:
TV(int chanel = 1, int volume = 1) {
std::cout << "생성자 호출! (인수 : " << chanel << ", " << volume << ")" << std::endl;
this->chanel = chanel;
this->volume = volume;
}
void showInfo() {
std::cout << "chanel : " << chanel << ", volume : " << volume << std::endl;
}
};
생성자와 초기화 리스트
생성자에서만 사용할 수 있는 '초기화 리스트'라는 녀석이 있다. 바로 예제부터 보자. 그게 이해가 더 빠를 것이다.
#include <iostream>
class TV {
int chanel;
int volume;
public:
//초기화 리스트가 사용된 생성자
TV(int chanel = 1, int volume = 1) : chanel(chanel), volume(volume) {
std::cout << "생성자 호출! (인수 : " << chanel << ", " << volume << ")" << std::endl;
}
void showInfo() {
std::cout << "chanel : " << chanel << ", volume : " << volume << std::endl;
}
};
int main() {
TV tv1(12, 20);
tv1.showInfo();
TV* tv2 = new TV(33, 44);
tv2->showInfo();
return 0;
}
/*
실행결과
생성자 호출! (인수 : 12, 20)
chanel : 12, volume : 20
생성자 호출! (인수 : 33, 44)
chanel : 33, volume : 44
*/
위의 예제에서 생성자를 잘 보면 뭔가 이상하다. 함수의 매개 변수와 본문 사이에서 다음의 코드를 확인할 수 있다.
: chanel(chanel), volume(volume)
이게 바로 초기화 리스트다. 위 코드가 어떻게 동작하는지 대충 이해했을 거라고 생각한다. 콜론(:)은 초기화 리스트의 시작을 알린다. 그리고 멤버 변수 chanel을 매개 변수 chanel로, 멤버 변수 volume을 매개 변수 volume으로 초기화하고 있다. 괄호 안의 것이 매개 변수이고 괄호 밖의 것이 멤버 변수다. 멤버 변수와 매개 변수의 이름이 동일한데도 this 포인터를 쓰지 않고 둘을 구분하는 것이 가능하다.
초기화 리스트는 성능상의 이점도 있다. 초기화 리스트를 사용하지 않고 생성자 본문에서 멤버 변수를 초기화하는 것을 아래의 코드에 비유할 수 있다.
int data; //변수 선언
data = 100; //변수에 다시 접근하여 데이터 저장
그런데 초기화 리스트를 통해 멤버 변수를 초기화하는 것은 아래의 코드에 비유할 수 있다.
int data = 100; //생성과 동시에 초기화
나도 더 자세한 설명은 못하기 때문에 대충 느낌만 이해하고 가자.
초기화 리스트를 처음 보면 어색하고 뭔가 복잡해 보여서 사용을 꺼리는 사람도 생긴다. 그런데 초기화 리스트를 적극 사용하라고 권하고 싶다. 나중에 가면 초기화 리스트를 사용하지 않고는 해결하기 어려운 문제도 존재한다.
복사 생성자
생성자는 종류가 다양하다. 복사 생성자는 그중 하나이다. 객체를 다루다 보면 다음과 같이 객체를 통해 다른 객체를 생성하는 경우도 생긴다.
TV tv1(12, 20);
TV tv2(tv1); //다른 객체를 이용해 새로운 객체 생성
이런 경우에 호출되는 생성자를 복사 생성자라고 한다. 기존의 객체의 멤버들의 값을 그대로 복사해와서 똑같이 초기화한다. 복사 생성자 역시 생성자이고, 함수이기 때문에 개발자가 직접 정의하는 것이 가능하다. 개발자가 아무런 복사 생성자를 정의하지 않을 경우 '디폴트 복사 생성자'를 자동으로 삽입한다. 복사 생성자는 다음과 같이 정의할 수 있다.
TV(TV& copy) : chanel(copy.chanel), volume(copy.volume) {
std::cout << "복사 생성자 호출! (인수 : " << copy.chanel << ", " << copy.volume << ")" << std::endl;
}
매개 변수가 같은 타입의 클래스 참조로 선언된 것뿐 전혀 어렵지 않다. 그리고 각각의 멤버 변수의 값을 가져와 초기화한다. 실제 예제를 통해 확인해보자.
#include <iostream>
class TV {
int chanel;
int volume;
public:
TV(int chanel = 1, int volume = 1) : chanel(chanel), volume(volume) {
std::cout << "생성자 호출! (인수 : " << chanel << ", " << volume << ")" << std::endl;
}
TV(TV& copy) : chanel(copy.chanel), volume(copy.volume) {
std::cout << "복사 생성자 호출! (인수 : " << copy.chanel << ", " << copy.volume << ")" << std::endl;
}
void showInfo() {
std::cout << "chanel : " << chanel << ", volume : " << volume << std::endl;
}
};
int main() {
TV tv1(12, 20);
TV tv2(tv1); //다른 객체를 이용해 새로운 객체 생성
tv2.showInfo();
return 0;
}
/*
실행결과
생성자 호출! (인수 : 12, 20)
복사 생성자 호출! (인수 : 12, 20)
chanel : 12, volume : 20
*/
복사 생성자와 얕은 복사, 깊은 복사
복사 생성자는 기본적으로 얕은 복사를 수행한다. 얕은 복사란 무엇이고 어떻게 일어나는 것인지 다음의 예제를 통해 확인해보자.
#include <iostream>
class Simple {
public:
int* num;
Simple(int* num = NULL) : num(num) {}
};
int main() {
//s1.num이 가리키고 있는 데이터는 100
Simple s1(new int(100));
//s1 객체를 이용해 s2객체 생성
Simple s2(s1);
//s2.num이 가리키는 데이터를 1로 변경
*(s2.num) = 1;
std::cout << "*(s1.num) : " << *(s1.num) << std::endl;
std::cout << "*(s2.num) : " << *(s2.num) << std::endl;
return 0;
}
/*
실행결과
*(s1.num) : 1
*(s2.num) : 1
*/
위 예제를 보면 s1의 num은 100을 가리키게 했다. 그리고 s2의 num이 가리키는 값을 1로 변경했다. 그런데 실행 결과를 보면 s2 뿐만 아니라 s1의 num이 가리키는 값까지 1로 변경되었다. s1 객체와 s2 객체는 분명 서로 다른 것이어야 한다. 하지만 s2를 통해 수행한 어떤 작업이 나도 모르게 s1에까지 적용되었다는 것은 프로그램의 심각한 문제를 야기할 수 있다.
이런 일이 생긴 원인은 Simple 클래스의 멤버 변수 num이 포인터이고, s2객체가 s1객체를 복사하여 생성되었으므로 s1의 num과 s2의 num이 가리키는 주소가 동일하기 때문이다. 이런 복사를 '얕은 복사'라고 한다.
얕은 복사로 인해 발생되는 이런 문제를 해결하려면 s2 객체를 생성할 때 s2의 num 저장하는 주소가 s1의 num이 저장하는 주소와 다르되 두 num이 각각 가리키는 데이터의 값은 같게 해주어야 한다. 이를 '깊은 복사'라고 하며, 이는 복사 생성자를 다음과 같이 정의하여 구현 가능하다.
#include <iostream>
class Simple {
public:
int* num;
Simple(int* num = NULL) : num(num) {}
//깊은 복사를 수행하도록 복사 생성자 정의
Simple(Simple& copy) : num(new int(*(copy.num))) {}
};
int main() {
//s1.num이 가리키고 있는 데이터는 100
Simple s1(new int(100));
//s1 객체를 이용해 s2객체 생성
Simple s2(s1);
//s2.num이 가리키는 데이터를 1로 변경
*(s2.num) = 1;
std::cout << "*(s1.num) : " << *(s1.num) << std::endl;
std::cout << "*(s2.num) : " << *(s2.num) << std::endl;
return 0;
}
/*
실행결과
*(s1.num) : 100
*(s2.num) : 1
*/
이제 s2의 num을 통해 값을 변경해도 s1에는 영향을 끼치지 않는다.
객체가 소멸될 때 호출되는 소멸자
객체가 생성될 때 호출되는 생성자가 있다면, 객체가 소멸될 때 호출되는 '소멸자'도 있다. 소멸자는 객체가 소멸 시 자동으로 호출하는 특수 목적의 함수이다. 이 역시 함수이기 때문에 개발자가 직접 정의할 수도 있다. 개발자가 아무런 소멸자도 정의하지 않았을 경우 컴파일러는 다음과 같은 '디폴트 소멸자'를 자동으로 삽입한다.
class Simple {
public:
~Simple() {}
};
디폴트 소멸자를 비롯해 모든 소멸자는 다음의 특징을 가진다.
- 반환 타입이 없으며 실제로 아무런 값도 반환하지 않음
- 함수명이 클래스명 앞에 '~'를 붙인 것과 같음
- 아무런 매개 변수를 가지지 않음
컴파일러가 자동으로 삽입한 디폴트 소멸자는 함수의 내용도 비어 있어 호출만 될 뿐 아무런 일도 하지 않지만 개발자가 소멸자를 직접 정의할 땐 입맛에 맞게 함수의 내용을 채울 수 있다. 그리고 생성자는 다양한 종류가 있지만 소멸자는 형태가 하나밖에 없다.
소멸자는 다음과 같이 동적 할당된 멤버 변수를 할당 해제할 때 유용하게 사용된다.
#include <iostream>
class Simple {
public:
int* num;
//생성자에서 멤버 변수 num에 동적 할당
Simple(int num) : num(new int(num)) {
std::cout << "멤버 변수 num에 메모리를 동적 할당합니다." << std::endl;
}
//소멸자에서 할당 해제
~Simple() {
std::cout << "멤버 변수 num에 할당된 메모리를 해제합니다." << std::endl;
delete num;
}
};
int main() {
Simple s1(100);
return 0;
}
/*
실행결과
멤버 변수 num에 메모리를 동적 할당합니다.
멤버 변수 num에 할당된 메모리를 해제합니다.
*/
소멸자에서 멤버 변수의 메모리 할당을 해제하면 메모리 관리와 코드 작성이 한 결 편하다. 만약 소멸자를 사용하지 않는다면 다음과 같이 객체의 소멸 전에 직접 멤버 변수를 할당 해제시켜야 한다.
#include <iostream>
class Simple {
public:
int* num;
//생성자에서 멤버 변수 num에 동적 할당
Simple(int num) : num(new int(num)) {
std::cout << "멤버 변수 num에 메모리를 동적 할당합니다." << std::endl;
}
};
int main() {
Simple s1(100);
delete s1.num;
return 0;
}
위 예제만 보면 별로 까다롭지 않아 보일 수 있지만, 개발자가 Simple 클래스의 num이 동적 할당된 것인지 어떻게 알 것이며, 해당 객체가 많을 경우 어떻게 다 직접 할당 해제할 것인지 생각하면 소멸자가 많은 도움을 주고 있음을 알 수 있다.
지금까지는 클래스와 객체, 객체 지향 프로그래밍의 큰 틀에 대해 설명했다. 때문에 지금 당장 클래스를 정의하고, 객체 지향 프로그래밍을 통해 간단한 프로그램을 작성하는 데에는 큰 무리가 없을 것이다. 하지만 역시 큰 틀일 뿐이기 때문에 지금까지 배운 내용만으로 프로그램을 작성하려고 하면 그래도 어려움이 있기는 할 것이다. 이제 디테일을 배울 차례다.
여기서 모두 설명하기에는 글이 너무 길어지므로 내용을 분리하고자 한다. 이다음의 내용을 담은 글은 아래의 링크를 통해 볼 수 있다.
2022.02.15 - [분류 전체보기] - C++17 객체 지향 언어와 객체 그리고 클래스(class)(2)
'공부 일지 > CPP 공부 일지' 카테고리의 다른 글
C++ 스마트포인터(unique_ptr, shared_ptr, weak_ptr) (0) | 2023.06.07 |
---|---|
C++17 객체 지향 언어와 객체(Object)의 개념 이해 (0) | 2022.02.08 |
C++17 Date and time utilities (0) | 2022.02.07 |
C++ | mutable 키워드 (0) | 2021.08.10 |
C++ | 범위 기반 for문 (0) | 2021.08.09 |