티스토리 뷰

C++

[개념] C++의 복사 생성자

다음김 2022. 6. 22. 10:59

 

해당 글은 '윤성우의 열혈 C++ 프로그래밍'을 참고하여 작성함. (이하 해당 도서라고 지칭)



C++에서의 대입 연산 시 호출되는 복사 생성자에 대해 알아보기에 앞서 자바에서의 대입 연산은 어떤 방식으로 진행되는지 먼저 살펴보고자 한다. (단순히 C++과 자바의 동작 방식의 차이를 보이고자 하는 것이니 생략하고 C++ 복사 생성자에 대한 설명부분으로 넘어가도 된다.)

 

자바에서 객체의 대입 연산은 참조값의 전달을 의미한다. 따라서 아래 코드의 실행결과는 다음과 같다.

class Main {  
  public static void main(String args[]) { 
    Simple sim1 = new Simple(10);
    Simple sim2 = sim1; //sim1의 참조값 전달

    System.out.println(sim1);
    System.out.println(sim2);

    System.out.println(sim1.getNum());
    sim2.setNum(20);
    System.out.println(sim1.getNum());
  } 
}

class Simple {
  int num;

  Simple(int num) {
    this.num = num; 
  }

  int getNum() {
    return num;
  }

  void setNum(int num) {
    this.num = num;
  }
}

실행 결과

Simple@2c7b84de
Simple@2c7b84de
10
20

 

즉 자바에서의 객체간 대입연산은 참조를 전달하는 것이므로 sim2 객체는 앞서 생성된 sim1 객체를 참조하게 된다. 따라서 sim2setNum 메소드를 호출하여 멤버변수를 변경한 후 sim1getNum 메소드로 멤버변수를 출력하면 값이 변경된 것을 확인할 수 있다.

 

 


반면 C++의 경우에는 대입 연산 시 복사 생성자가 호출되어 별도의 객체가 생성된다. 같은 코드를 C++로 작성한 예제이다.

#include <iostream>
using namespace std;

class Simple {
private:
    int num;

public:
    Simple(int num) :num(num) {}
    int getNum() { return num; }
    void setNum(int num) { this->num = num; }
};

int main() {
    Simple sim1(10);
    Simple sim2 = sim1;

    cout << &sim1 << endl;
    cout << &sim2 << endl;

    cout << sim1.getNum() << endl;
    sim2.setNum(20);
    cout << sim1.getNum() << endl;

    return 0;
}

실행결과

0x7ffddaffa178
0x7ffddaffa170
10
10

 

여기서 복사 생성자란 매개변수로 전달된 객체의 멤버변수 값을 그대로 복사하여 새로운 객체를 생성하는 생성자를 말한다. 하지만 위 코드에서 Simple 클래스의 생성자는 int형 변수를 인자로 받는 생성자 하나뿐인데 정의하지도 않은 복사 생성자가 어떻게 호출되는 것일까? 이는 별도의 생성자가 정의되어있지 않으면 자동으로 삽입되는 기본 생성자와 같이 별도의 복사 생성자를 정의하지 않으면 기본 복사 생성자가 자동으로 삽입되기 때문이다.

 

기본 복사 생성자는 인자로 전달된 객체의 멤버변수 값을 복사하여 새로운 객체를 생성할 뿐이다. 만약 복사 생성자에 추가적인 기능이 필요하다면 다음과 같이 복사 생성자를 직접 정의할 수 있다.

Simple(const Simple &copy) :num(copy.num) {}

//----------------------------------------------------

explicit Simple(const Simple &copy) :num(copy.num) {}

 

Simple sim2 = sim1; 이 문장에서 복사 생성자가 호출되는 것인데 그 이유는 sim2 = sim1; 부분이 묵시적으로 sim2(sim1);의 형태로 변환되기 때문이다. (만약 명시적으로 Simple sim2(sim1); 이렇게 생성자를 호출한 경우에만 복사 생성자를 호출하도록 하고 싶다면 정의한 복사 생성자 앞에 explicit 키워드를 붙이면 된다. 이 경우 Simple sim2 = sim1; 의 문장에 대해 'Simple 클래스에 적절한 복사생성자가 없습니다.'라는 컴파일 오류가 발생하게 된다.)



복사 생성자는 대입 연산 이외의 경우에도 호출된다. 해당 도서에서 정의한 복사 생성자의 호출 시점 3가지는 다음과 같다.

1. 기존에 생성된 객체를 이용해서 새로운 객체를 초기화하는 경우
2. Call-by-value 방식의 함수호출 과정에서 객체를 인자로 전달하는 경우
3. 객체를 반환하되, 참조형으로 반환하지 않는 경우




해당 도서에서도 각 경우에 대한 예제를 잘 설명해주고 있지만 비슷한 경우이지만 약간씩 다른 경우 어떠한 결과가 나타나는지 궁금증이 생겨 이에 대해 정리해보려 한다.

 

먼저 기존에 생성된 객체를 이용해서 객체를 초기화하는 경우(첫번째) 중 초기화되는 객체가 새로운 객체가 아닌 기존 객체라면 어떻게 될까? 복사 생성자가 호출될까?

#include <iostream>
using namespace std;

class Simple {
private:
    int num;

public:
    Simple() :num(0) {}
    Simple(int num) :num(num) {}
    Simple(const Simple &copy) :num(copy.num) {
      cout << "복사 생성자 호출" << endl;
    }

    int getNum() { return num; }
    void setNum(int num) { this->num = num; }
};

int main() {
    Simple sim1(10);
    Simple sim2; //기본 생성자 호출 
    cout << &sim1 << endl;
    cout << &sim2 << endl;
    cout << sim1.getNum() << endl;
    cout << sim2.getNum() << endl;

    sim2 = sim1;

    cout << endl;
    cout << &sim1 << endl;
    cout << &sim2 << endl;
    cout << sim1.getNum() << endl;
    cout << sim2.getNum() << endl;

    return 0;
}

실행 결과

0x7ffd0fe5dad8
0x7ffd0fe5dad0
10
0

0x7ffd0fe5dad8
0x7ffd0fe5dad0
10
10

 

이 경우 Simple sim2; 문장에서 기본 생성자가 호출되므로 sim2 = sim1;의 문장에서는 복사 생성자 호출이 아닌 단순히 멤버변수의 복사만이 발생하게 된다. 따라서 실행결과에서 기본 생성자에 의해 생성된 객체 sim2의 주소가 대입연산 후에도 유지되는 것을 볼 수 있다.




두번째 경우에 대해서는 먼저 call-by-value 방식으로 함수에 매개변수 전달 시 정말 복사 생성자가 호출되는지 확인하고자 한다.

#include <iostream>
using namespace std;

class Simple {
private:
    int num;

public:
    Simple() :num(0) {}
    Simple(int num) :num(num) {}
    Simple(const Simple &copy) :num(copy.num) {
      cout << "복사 생성자 호출" << endl; //2
      cout << this << endl; //3
    }
    ~Simple() {
      cout << "소멸자 호출" << endl; //6,8
    }

    int getNum() { return num; }
    void setNum(int num) { this->num = num; }
};

void showSimple(Simple copy) {
    cout << copy.getNum() << endl; //4
    cout << &copy << endl; //5
}

int main() {
    Simple sim1(10);
    cout << &sim1 << endl; //1 (출력순서)
    showSimple(sim1);
    cout << &sim1 << endl; //7

    return 0;
}

실행결과

0x7ffe9de69f18
복사 생성자 호출
0x7ffe9de69f08
10
0x7ffe9de69f08
소멸자 호출
0x7ffe9de69f18
소멸자 호출

 

Simple copy = sim1; 의 문장이 실행된 것과 동일하다. (정확히 말하면 Simple copy(sim1);) 또한 복사 생성자에 의해 생성된 지역변수 copyshowSimple 함수의 호출이 끝난 후 소멸되는 것을 확인할 수 있다.

 

 

 

위 코드에서는 showSimple 함수의 매개변수로 객체가 전달된 반면 만약 객체의 참조형이 전달되면 복사 생성자가 호출될까?

void showSimple(Simple &copy) {
    cout << copy.getNum() << endl;
    cout << &copy << endl;
}

실행결과

0x7ffe77169128
10
0x7ffe77169128
0x7ffe77169128
소멸자 호출

 

실행결과를 통해 복사 생성자는 호출되지 않고 함수의 매개변수 copy는 함수 호출 전 생성한 객체(sim1)를 참조한다는 것을 알 수 있다. 즉 showSimple 함수 호출에 따른 생성자 호출(객체 생성)이 발생하지 않는다.




 

마지막 세번째의 경우 가장 이해하기 어려웠고 많은 예들이 떠올랐다. 먼저 객체를 반환하는 경우를 총 4가지로 분류해보았다.

 

1. 참조형 반환 + 참조자에 저장

2. 참조형 반환 + 객체에 저장

3. 객체 반환 + 참조자에 저장

4. 객체 반환 + 객체에 저장

 

참조형 반환 + 참조자에 저장

#include <iostream>
using namespace std;

class Simple {
private:
    int num;

public:
    Simple() :num(0) {}
    Simple(int num) :num(num) {}
    Simple(const Simple &copy) :num(copy.num) {
      cout << "복사 생성자 호출" << endl;
      cout << this << endl;
    }

    int getNum() { return num; }
    void setNum(int num) { this->num = num; }
    Simple& getThis() {
      cout << this << endl; //2
      return *this;
    }
};

int main() {
    Simple sim1(10);
    cout << &sim1 << endl; //1 (출력 순서)
    Simple &sim2 = sim1.getThis();
    cout << &sim2 << endl; //3

    return 0;
}

실행결과

0x7ffe2dc4c7f8
0x7ffe2dc4c7f8
0x7ffe2dc4c7f8

 

getThis 함수는 자기자신의 참조를 반환하므로 sim2 변수는 sim1 객체를 참조하게 된다. 따라서 sim2setNum 함수로 멤버변수를 변경함으로써 sim1의 멤버변수가 변경될 수 있다.



참조형 반환 + 객체에 저장

#include <iostream>
using namespace std;

class Simple {
private:
    int num;

public:
    Simple() :num(0) {}
    Simple(int num) :num(num) {}
    Simple(const Simple &copy) :num(copy.num) {
      cout << "복사 생성자 호출" << endl; //3
      cout << this << endl; //4
    }

    int getNum() { return num; }
    void setNum(int num) { this->num = num; }
    Simple& getThis() {
      cout << this << endl; //2
      return *this; //여기서 복사 생성자 호출X
    }
};

int main() {
    Simple sim1(10);
    cout << &sim1 << endl; //1 (출력 순서)
    Simple sim2 = sim1.getThis(); //여기서 복사 생성자 호출O
    cout << &sim2 << endl; //5

    return 0;
}

실행결과

0x7ffe0a64af98
0x7ffe0a64af98
복사 생성자 호출
0x7ffe0a64af90
0x7ffe0a64af90

 

이 경우 함수의 반환형은 참조형이기 때문에 return문에서 복사 생성자가 호출되는 것이 아닌, 대입 연산이 발생하는 곳에서 복사 생성자가 호출된다. 즉 참조형 반환값을 참조자로 받지 않고 객체로 받기 때문에, 참조자를 인자로 전달하여 복사 생성자를 호출함으로써 새로운 객체가 생성되는 것이다. (Simple sim2(sim1.getThis());)



객체 반환 + 참조자에 저장

#include <iostream>
using namespace std;

class Simple {
private:
    int num;

public:
    Simple() :num(0) {}
    Simple(int num) :num(num) {}
    Simple(const Simple &copy) :num(copy.num) {
      cout << "복사 생성자 호출" << endl; //3
      cout << this << endl; //4
    }

    int getNum() { return num; }
    void setNum(int num) { this->num = num; }
    Simple getThis() {
      cout << this << endl; //2
      return *this; //여기서 복사 생성자 호출!
    }
};

int main() {
    Simple sim1(10);
    cout << &sim1 << endl; //1
    const Simple &sim2 = sim1.getThis();
    cout << &sim2 << endl; //5

    return 0;
}

실행결과

0x7ffc51127078
0x7ffc51127078
복사 생성자 호출
0x7ffc51127068
0x7ffc51127068

 

두번째 경우와 비슷한 결과가 나왔지만 복사 생성자가 호출되는 시점은 다르다. 두번째 경우와는 달리 getThis 함수는 객체를 반환하기 때문에 반환 결과를 저장하기 위한 별도의 메모리 공간 할당 후 이곳에 복사 생성자를 통해 생성된 임시 객체를 저장하는 일련의 과정이 발생한다. 따라서 새로 생성된 임시 객체를 sim2 변수가 참조하게 된다.
다만 sim2 변수에서 const 키워드를 삭제하면 'C++ 비const 참조에 대한 초기 값은 lvalue여야 합니다.' 라는 컴파일 오류가 발생한다. 이는 상수 및 임시 객체의 참조와 관련된 내용인 것 같아 이 부분에 대해 공부 후 다시 글을 작성해보려고 한다. 일단 함수의 호출로 반환되는 임시객체를 참조하기 위해서는 const 참조자가 필요하다고 생각하면 될 것 같다.



객체 반환 + 객체에 저장

int main() {
    Simple sim1(10);
    cout << &sim1 << endl;
    Simple sim2 = sim1.getThis();
    cout << &sim2 << endl;

    return 0;
}

실행결과

0x7ffe6921e818
0x7ffe6921e818
복사 생성자 호출
0x7ffe6921e810
0x7ffe6921e810

 

이 경우 반환된 임시 객체를 가지고 다시 복사 생성자를 호출하여 sim2 객체가 생성될 것이라고 생각하였는데 sim2 변수가 전달된 임시 객체를 참조하는 일이 발생하였다. 원래 객체를 참조자가 아닌 객체로 받게 되면 새로운 객체가 생성되는데 이 경우에는 sim2 라는 새로운 객체를 생성하지 않고 getThis 함수가 반환한 임시 객체를 참조하는 일이 발생한 것이다.
해당 도서의 저자는 '추가로 객체를 생성하지 않고 반환되는 임시 객체에 sim2 라는 이름을 할당하고 있으며, 이는 객체의 생성 수를 하나 줄여서 효율성을 높이기 위해서이다.' 라고 설명하고 있다. 직관적으로도 이미 생성해놓은 임시 객체에 참조만 붙여주면 되지 굳이 임시 객체를 이용해 새로운 객체를 생성할 필요는 없다고 이해할 수 있다.




 

복사 생성자 주의사항!

앞서 복사 생성자를 직접 정의할 때 너무 당연하게 인자를 const 참조자로 선언하였는데 그 이유를 다시 한번 곰곰이 생각해보자. 일단 복사 생성자 목적은 인자로 전달된 객체의 멤버변수 값을 이용해 새로운 객체를 생성하는 것이다. 즉 전달된 객체를 변경하는 연산은 하지 않는 것이 좋다. 따라서 const 키워드를 붙여줌으로써 전달된 객체의 값을 실수로 변경하는 경우 컴파일 오류로 이를 방지할 수 있다.

 

반면 복사 생성자의 인자를 참조자가 아닌 객체로 선언하게 된다면 무한 루프가 발생할 수 있다. 즉 복사 생성자가 호출되는 2번째 경우인 'Call-by-value 방식의 함수호출 과정에서 객체를 인자로 전달하는 경우'에서 복사 생성자를 호출하였더니 복사 생성자의 인자로 해당 객체를 다시 Call-by-value 방식으로 전달하게 되는 것이다. 그럼 다시 복사 생성자가 호출되고 앞과 같은 상황이 계속해서 반복되면서 무한 루프에 빠지게 된다. 이 경우를 막고자 다행히 복사 생성자의 인자를 객체로 선언하면 컴파일 오류가 발생한다.



그럼 앞 글에서 작성한 예제에서의 생성자에 일반 자료형이 아닌 객체를 매개변수로 전달하는 경우 복사 생성자의 호출 시점에 대해 알아보자.

#include <iostream>
using namespace std;

class Simple {
private:
    int num;
public:
    Simple(int num) :num(num) {}
    Simple(const Simple &copy) :num(copy.num) {
        cout << "복사 생성자 호출" << endl;
    }
    ~Simple() {
        cout<< "소멸자 호출" << endl;
    }
    int getNum() {
        return num;
    }
};

class SoSimple {
private:
    int num1;
    const int num2;
    Simple simple;
public:
    SoSimple(int n1, int n2, Simple csim) :num1(n1), num2(n2), simple(csim) {}
};

int main() {
    Simple sim(30);
    SoSimple ssim(10, 20, sim);

    return 0;
}

실행결과

복사 생성자 호출
복사 생성자 호출
소멸자 호출
소멸자 호출
소멸자 호출

 

생성자에 객체를 전달하고 해당 객체를 이용해 멤버변수를 초기화하는 경우 쓸데없는 객체가 복사 생성자를 통해 2개나 생성된다. 즉 인자에 객체를 전달하면서 한 번(Simple csim=sim), 다시 매개변수를 복사 생성자의 인자로 전달하여 멤버변수를 초기화하면서 한 번(Simple simple=csim), 총 두 번의 복사 생성자 호출이 발생한다. 이는 성능상 효율이 떨어지며 저장공간이 낭비될 수 있으니 피해야 한다. 그렇다면 위 코드를 어떻게 개선할 수 있을까?

 

SoSimple(int n1, int n2, const Simple &csim) :num1(n1), num2(n2), simple(csim) {}

먼저 생성자의 인자를 참조자로 선언하면 인자 전달에 있어서의 복사 생성자 호출을 막을 수 있다. 또한 인자로 전달된 참조를 통해 해당 객체의 값 변경은 발생하지 않으니 const로 선언해주는 것이 좋다. 이 경우의 실행결과는 다음과 같다. 즉 멤버변수 초기화에서만 복사 생성자가 호출된다.

 

실행결과

복사 생성자 호출
소멸자 호출
소멸자 호출



또는 다음과 같이 멤버 이니셜라이저에서 복사 생성자가 아닌 해당 클래스의 멤버변수를 인자로 전달하는 생성자를 호출하게 할 수도 있다.

SoSimple(int n1, int n2, const Simple &csim) :num1(n1), num2(n2), simple(csim.getNum()) {}

실행결과는 다음과 같으며 SoSimple 생성자 인자로 전달하기 위한 객체와 SoSimple 멤버변수 객체까지 총 2개의 객체가 생성되었다.

 

실행결과

소멸자 호출
소멸자 호출



만약 객체 2개가 아닌 객체 1개만을 생성하여 멤버변수를 초기화하고 싶다면 SoSimple 클래스의 simple 멤버변수를 객체가 아닌 참조자로 선언하면 된다.

class SoSimple {
private:
    int num1;
    const int num2;
    Simple &simple;
public:
    SoSimple(int n1, int n2, const Simple &csim) :num1(n1), num2(n2), simple(csim) {}
};

이 경우 계속해서 참조만을 전달하므로 main 함수 내부에서 생성한 객체를 sim, csim, simple 변수가 모두 참조하게 되는 것이다. 가장 효율적인 코드가 아닐까 싶지만 참조자를 멤버변수로 선언하는 일은 흔하지 않다고 한다.

 

실행결과

소멸자 호출




사실 자바의 경우 기본적으로 바로 위의 경우처럼 참조가 전달되기 때문에 생성된 객체 하나를 여러 변수가 참조하는 형태를 띄는 것이 보통이다. 반면 C++은 복사 생성자의 개념이 추가되면서 자바에 비해 좀더 고려해야 하는 부분들이 생겼다. 좀 오바스럽게 케이스를 나누어, 글을 쓰면서도 이렇게까지 해야 하나 싶었으나 그래도 글로 하나씩 써두니 조금은 정리가 되는 것 같다. 아무튼 복사 생성자에 대한 내용은 이것으로 마치겠다. 앞으로 추가되는 내용이 있다면 해당 글이 너무 길기 때문에 별도의 글로 작성할 것이다.




끝.




'C++' 카테고리의 다른 글

[개념] C++의 형변환 연산자  (0) 2022.07.02
[개념] C++의 다형성 - virtual 함수  (0) 2022.06.27
[개념] C++의 상속 3계명?  (0) 2022.06.24
[개념] C++의 멤버 이니셜라이저  (0) 2022.06.20
[개념] C/C++의 헷갈리는 const  (0) 2022.06.18
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함