이번에는 C++의 함수, 참조, 복사 생성자에 대해서 공부를 해보자.
고급 프로그래밍 언어에서 인자 전달 방식(argument passing)은 다음과 같다.
- 값에 의한 호출(call by value)
호출하는 코드에서 넘겨주는 실인자 값이 함수의 매개변수에 복사되어 전달
- 주소에 의한 호출(call by address)
주소를 직접 포인터 타입의 매개변수에 전달
예를 들어 함수 호출 시 배열이 전달되는 경우 배열의 이름은 곧 포인터임으로 주소에 의한 호출이 자동으로 이루어짐
예시 코드를 살펴보자. 먼저 값에 의한 호출 이다.
#include <iostream>
using namespace std;
void swap(int a, int b){
int tmp;
tmp = a;
a = b;
b = tmp;
}
int main(){
int m=2, n=9;
swap(m, n);
cout << m <<' '<< n;
}
이렇게 호출할 경우, 매개변수 a와 b가 swap 함수의 스택 메모리 영역에 생성되고 m, n 값이 a와 b에 복사된다. 이 후 a와 b 값은 서로 교환되지만, swap() 함수가 종료되면 같이 사라지고 m, n 값은 처음부터 끝까지 전혀 변화하지 않는다.
반면, 주소에 의한 호출 은 다음과 같다.
#include <iostream>
using namespace std;
void swap(int *a, int *b){
int tmp;
tmp = a;
a = b;
b = tmp;
}
int main(){
int m=2, n=9;
swap(&m, &n);
cout << m <<' '<< n;
}
이번에는 포인터 매개변수가 swap() 함수의 스택 영역에 생성되고, m과 n의 주소가 a와 b에 전달되어 직접적인 주소를 가리킨다. 따라서 swap() 함수의 종료와 함께 a와 b가 사라지더라도 main() 스택의 m, n 변수는 swap 함수 호출 이후부터 값이 바뀐 상태로 남아있게 된다.
💡 두 호출 방식의 특징
1. 값에 의한 호출 특징 : 실인자 손상 x
2. 주소에 의한 호출 특징 : 의도적으로 함수 내 실인자 값 변경
class를 통해 생성한 객체를, 값에 의한 호출 로 전달하게 된다면, 다음과 같은 특징을 가지고 있다.
예시 코드를 살펴보자.
#include <iostream>
using namespace std;
class Circle{
int radius;
public:
Circle(){ radius=1; cout<<"생성자 실행"<<radius<<endl; }
Circle(int r){ radius = r; cout<<"생성자 실행"<< radius << endl; }
~Circle() { cout<<"소멸자 실행" << radius <<endl; }
double getArea(){ return 3.14 * radius * radius; }
int getRadius() { return radius; }
void setRadius(int radius) {this->radius = radius;}
};
void increase(Circle c) { // 객체 c의 생성자는 실행되지 않음
int r = c.getRadius();
c.setRadius(r+1);
} // 객체 c의 소멸자 실행
int main() {
Circle waffle(30);
increase(waffle);
cout << waffle.getRadius() << endl;
}
이 코드를 실행하게 되면 waffle 객체의 반지름은 변화하지 않는다.
💡 왜 매개변수 객체는 생성자를 실행하지 않는가?
이미 생성된 객체가 기본 생성자와 멤버 변수 값 등이 다를 경우, 생성자가 실행되면 매개변수 객체가 초기화 되면서 전달 받은 원본 상태를 잃어버리기 때문이다.
하지만 이러한 방식은 중대한 문제점이 있고, 후술할 것이다.
이 방식으로 호출하면 생성자가 실행되지 않는 것에서 기인하는 문제점들을 해결할 수 있다. 포인터 변수를 사용하여 주소를 통해서 호출하기 때문에 원본 객체의 값도 변화하게 된다.
💡 주소에 의한 호출로 객체 전달 특징
1. 원본 객체를 복사하는 시간 소모 없음
2. 생성자 소멸자의 비대칭 실행 문제 없음
3. 원본 객체 훼손 가능성을 가지고 있으므로 주의
= 연산자를 이용해 객체끼리 치환하게 되면 객체의 모든 데이터를 비트 단위로 복사하게 된다. 하지만 두 객체는 여전히 다른 객체이며, 단지 데이터만 같을 뿐이다.
Circle c1(30);
Circel c2(40);
c1 = c2;
// 두 객체는 다르나, c1의 내용물만 c2의 내용물과 같아졌을 뿐이다.
함수가 객체를 리턴하게 되면 어떨까?
Circle getCircle(){
Circle tmp(30);
return tmp;
}
Circle c;
c = getCircle();
이 경우에 c는 tmp 객체로 치환되면서 반지름이 30으로 증가하게 된다. 객체 간의 복사가 일어나는 것이다.
💡 참조(reference)
C언어에는 없으나, C++에서 도입된 고유한 개념으로 & 기호를 통해서 사용한다. 이미 선언된 변수에 대한 alias이다.
int n = 2;
int &refint = n;
Circle circle;
Circle &refCircle = circle;
int *p = &rdfint;
*p = 20; // n = *p = refint = 20
int &ref=n;
int& ref=n;
int & ref = n;
참조 변수를 주로 사용하는 곳이 바로 참조에 의한 호출이다.
💡 참조에 의한 호출
함수의 매개변수를 참조 변수로 선언하여, 매개변수가 함수 호출의 실인자를 참조하여 실인자와 공간을 공유하기 위한 인자 전달 방식void swap(int &a, int &b);이렇게 선언할 경우, 함수의 매개변수로 쓰인 참조 매개변수들은 함수의 스택 메모리에 별도 공간이 할당되지 않으며, main 함수의 스택에 할당되어 있는 변수들의 공간을 공유한다.
그리고 함수 호출이 종료될 경우 참조 변수들의 이름은 그대로 사라지게 된다.
참조에 의한 호출과 그렇지 않은 호출을 한번 비교해보자.
#include <iostream>
using namespace std;
bool average(int a[], int size, int& avg) {
if (size <= 0) return false;
int sum = 0;
for (int i = 0; i < size; i++)
sum += a[i];
avg = sum / size;
return true;
}
int main() {
int x[] = { 0,1,2,3,4,5 };
int avg;
if (average(x, 6, avg)) cout << "평균은 " << avg << endl;
else cout << "매개변수 오류" << endl;
if(average(x, -2, avg)) cout << "평균은 " << avg << endl;
else cout << "매개변수 오류" << endl;
}
💡 참조에 의한 호출의 장점
#include <iostream>
using namespace std;
class Circle{
int radius;
public:
Circle(){ radius=1; cout<<"생성자 실행"<<radius<<endl; }
Circle(int r){ radius = r; cout<<"생성자 실행"<< radius << endl; }
~Circle() { cout<<"소멸자 실행" << radius <<endl; }
double getArea(){ return 3.14 * radius * radius; }
int getRadius() { return radius; }
void setRadius(int radius) {this->radius = radius;}
};
void increase(Circle &c) { // 참조에 의한 객체 생성
int r = c.getRadius();
c.setRadius(r+1);
}
int main() {
Circle waffle(30);
increase(waffle);
cout << waffle.getRadius() << endl;
}
다른 예시 코드를 보자.
#include <iostream>
using namespace std;
class Circle {
int radius;
public:
Circle() { radius = 1; cout << "생성자 실행" << radius << endl; }
Circle(int r) { radius = r; cout << "생성자 실행" << radius << endl; }
~Circle() { cout << "소멸자 실행" << radius << endl; }
double getArea() { return 3.14 * radius * radius; }
int getRadius() { return radius; }
void setRadius(int radius) { this->radius = radius; }
};
void readRadius(Circle& c) {
int r;
cout << "정수 값으로 반지름을 입력하세요>>";
cin >> r;
c.setRadius(r);
}
int main() {
Circle donut;
readRadius(donut);
cout << "donut의 면적 >> " << donut.getArea() << endl;
}
C++에서는 값(value) 말고도 참조를 리턴할 수 있다.
char c = 'a';
char & find(){
return c; // 변수 c에 대한 참조를 리턴
}
char a = find(); // a = 'a'가 된다.
char &ref = fine(); // ref는 c에 대한 참조
ref = 'm'; // c = 'm'
find() = 'b'; // c = 'b'가 된다.
참조를 리턴하기 때문에 사실상 변수공간을 리턴하는 것과 동일하다.
따라서 참조 변수를 리턴하는 함수들은 치환문 왼쪽에서 마치 변수처럼 사용할 수 있으며, 반대로 오른쪽에 올 경우 다른 변수에게 원 변수의 값을 치환하게 해준다.
예시 코드를 살펴보자.
#include <iostream>
using namespace std;
char& find(char s[], int index) {
return s[index];
}
int main() {
char name[] = "Mike";
cout << name << endl;
find(name, 0) = 'S'; // name[0]을 S로 변경
cout << name << endl;
char& ref = find(name, 2);
// ref는 name[2]에 대한 참조
ref = 't'; // name = 'Site'
cout << name << endl;
}
💡 L-Value와 R-Value
C++에서 치환 연산자(=)를 기준으로 왼쪽에 있는 것을 L-Value, 오른쪽에 있는 것을 R-Value라고 부른다.
- 왼쪽에는 공간이 와야한다.
- 오른쪽에는 값이 와야한다.
그렇기 때문에 포인터 변수를 리턴하는 함수는 값을 반환하지 공간을 반환하는 것이 아니라 함수 그 자체는 왼쪽에 적을 수 없지만, 참조를 리턴하는 함수는 공간을 반환하기 때문에 왼쪽에도 함수 그 자체를 적을 수 있다.
💡 얕은 복사(shallow copy)와 깊은 복사(depp copy)
- 얕은 복사
단순히 값만 복사하고, 소유권은 복사하지 않아 충돌이 발생하는 복사
- 깊은 복사
값뿐만 아니라 소유권도 복사해서 충돌이 생기지 않는 복사
객체에 대해서도 얕은 복사와 깊은 복사를 구분할 수 있다.
객체를 복사할 때 얕은 복사를 하게 된다면 메모리를 공유하기 때문에 사본 객체를 변경해도 원본 객체도 동일하게 변경되는 문제점이 발생한다.
💡 복사 생성자
복사 생성 이란 객체가 생성될 때 원본 객체를 복사해서 생성되는 경우로, C++에서는 복사 생성시 사용되는 복사 생성자(copy constructor)가 있다. 다음과 같이 선언한다.class ClassName{ ClassName(const ClassName& c);복사 생성자의 특징은 다음과 같다.
- 복사 생성자의 매개변수는 하나만 사용한다.
- 자기 클래스에 대한 참조로 매개변수를 선언한다.
- 복사 생성자는 클래스에 오직 1개만 선언 가능하다.
💡 복사 생성자의 실행
- 복사 생성자는 치환 연산자(=)를 상정한 것이 아님
- 복사 생성자는 예를 들면 다음과 같이 호출해서 작동함
Circle src(30); Circle dest(src); // src 객체를 복사해서 dest 객체 생성 // 복사 생성자 호출 // 별도 객체 공간 할당컴파일러에서 dest 객체가 생성될 때 복사 생성자를 호출하도록 컴파일 한다.
예시 코드를 살펴보자.
#include <iostream>
using namespace std;
class Circle {
int radius;
public:
Circle(const Circle& c) { this->radius = c.radius; cout << "복사 생성자 실행 " << radius << endl; }
Circle() { radius = 1; }
Circle(int radius) { this->radius = radius; }
double getArea() { return 3.14 * radius * radius; }
};
int main() {
Circle src(30);
Circle dest(src); // 복사 생성자 실행
cout << "원본 면적 " << src.getArea() << endl;
cout << "사본 면적 " << dest.getArea() << endl;
}
일반적인 디폴트 생성자(default constructor)와 마찬가지로, 복사 생성자 역시 개발자가 별도 구문을 넣어놓지 않더라도, 컴파일러가 디폴터 복사 생성자(default copy constructor)를 묵시적으로 삽입 후 처리한다.
하지만, 컴파일러가 삽입하는 디폴트 복사 생성자 코드는 얕은 복사 를 실행하도록 작동하는 코드이다.
왜냐하면 컴파일러가 삽입한 복사 생성자는 원본 객체의 모든 멤버를 일대일로 사본(this)에 복사하도록 구성되기 때문이다.
#include <iostream>
#include <cstring>
using namespace std;
# define _CRT_SECURE_NO_WARNINGS
class Person {
char* name;
int id;
public:
Person(int id, const char* name);
~Person();
void changeName(const char* name);
void show() { cout << id << ',' << name << endl; }
};
Person::Person(int id, const char* name) {
this->id = id;
int len = strlen(name);
this->name = new char[len + 1]; // 문자열 공간 할당
strcpy(this->name, name); // name에 문자열 복사
}
Person::~Person() {
if (name)
delete[] name;
}
void Person::changeName(const char* name) {
if (strlen(name) > strlen(this->name))
return; // 현재 할당된 메모리보다 긴 문자열은 다룰 수 없음
strcpy(this->name, name);
}
int main() {
Person father(1, "KimHOUUUU");
Person daughter(father); // 디폴트 복사 생성자 삽입 및 실행
cout << "daughter 객체 실행 직후 ---" << endl;
father.show();
daughter.show();
daughter.changeName("Grace");
cout << "daughter 이름을 Grace로 변경한 후- --- " << endl;
father.show();
daughter.show();
}
위 코드를 살펴보면
따라서 깊은 복사 생성자 를 만들어야 이러한 문제점을 피할 수 있다.
예시 코드를 통해 알아보겠다.
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <cstring>
using namespace std;
class Person {
char* name;
int id;
public:
Person(int id, const char* name);
Person(const Person& person);
~Person();
void changeName(const char* name);
void show() { cout << id << ',' << name << endl; }
};
Person::Person(int id, const char* name){
this->id = id;
int len = strlen(name);
this->name = new char[len + 1]; // 문자열 공간 할당
strcpy(this->name, name); // name에 문자열 복사
}
Person::Person(const Person& person) {
this->id = person.id; // id값 복사
int len = strlen(person.name);
this->name = new char[len + 1]; // name을 위한 공간 할당
strcpy(this->name, person.name); // name의 문자열 복사
cout << "복사 생성자 실행, 원본 객체의 이름 " << this->name << endl;
}
Person::~Person() {
if (name)
delete[] name;
}
void Person::changeName(const char* name) {
if (strlen(name) > strlen(this->name))
return; // 현재 할당된 메모리보다 긴 문자열은 다룰 수 없음
strcpy(this->name, name);
}
int main() {
Person father(1, "KimHOUUUU");
Person daughter(father); // 디폴트 복사 생성자 삽입 및 실행
cout << "daughter 객체 실행 직후 ---" << endl;
father.show();
daughter.show();
daughter.changeName("Grace");
cout << "daughter 이름을 Grace로 변경한 후- --- " << endl;
father.show();
daughter.show();
}
이번에는 단순히 객체의 사본을 만들어내는 얕은 복사가 아니라, 아예 별도의 메모리 공간을 할당하는 깊은 복사가 이루어졌다.
그렇기 때문에 이 프로그램의 실행 결과 잘못된 메모리 반환 등의 문제점이 발생하지 않고, 두 객체의 공간이 겹치지도 않아 문자열 변경시 원본과 사본 모두 변경되는 등의 문제점이 발생하지 않는다.
개발자가 명시하지 않아도, 묵시적으로 복사 생성자가 할당되는 경우들이 프로그램 개발 중에 종종 존재한다. 이때 깊은 복사 생성자가 존재하지 않는다면 프로그램이 비정상 종료할 가능성이 높다.
이러한 묵시적 복사가 발생하는 경우는 크게 3가지가 있다.
1. 객체를 초기화 하여 객체를 생성할 때
Person son = fater ;
이 경우 컴파일러는
Person son(father);
이 코드로 자동 변환하여 복사 생성자를 호출한다.
Person son;
son = father;
이 코드는 단순 치환문이기 때문에 복사 생성자가 호출되지 않는다. 오직 객체를 객체로 초기화 하여 객체가 생성될 때 발생하는 문제점이다.
2. "값에 의한 호출"로 객체가 전달될 때
함수의 매개변수 객체가 생성될 때 복사 생성자가 자동으로 호출된다.
void f(Person person){ // 매개변수 person 생성시 복사 생성자 호출
....
}
Person father(1, "Kitae");
f(father); // 값에 의한 호출
값에 의한 호출은 생성자가 실행되지 않는다고 앞에서 얘기하였다. 생성자 대신에 복사 생성자가 실행되기 때문에 그렇다.
3. 함수가 객체를 리턴할 때
함수가 객체를 리턴할 때 return 문은 return 객체의 복사본을 생성하여 호출한 곳으로 전달하게 된다. 이때 복사 생성자가 호출된다.
Person g(){
Person mother(2, "Jane");
return mother;
} // mother의 복사본을 생성하여 리턴, 이때 복사 생성자 사용
따라서 이런 경우를 대비해서라도, 깊은 복사가 가능한 복사 생성자를 만들어놓는 것이 낫다고 생각한다.