chapter 5 함수와 참조, 복사 생성자

박준우·2024년 11월 17일

명품C++프로그래밍

목록 보기
4/10

1. 함수의 인자 전달 방식 리뷰

함수로 인자를 전달하는 방식에는 두 가지가 존재한다.

  • 값에 의한 호출(call by value)
  • 주소에 의한 호출(call by adress)

(1) 값에 의한 호출(call by value)

값에 의한 호출은 함수에 매개변수를 이용해 값을 넘겨줄 때, 값을 복사하는 방식으로 함수에 넘겨주는 것을 의미한다.

# 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;
    }
    

위 코드는 메인함수를 실행하며 스택에 m, n이 들어갈 int공간을 만든다. 그 후 swap을 메인에서호출하면, swap이라는 함수가 메인함수 스택 위에 쌓이게 되며, a와 b에 저장된다. 그 후 a값과 b값의 위치를 바꾼뒤 종료하면서 메인함수 스택에서 물러난다.

여기서 문제는 a와 b는 m과 n을 복사한 값이기에 a와 b값을 바꾼것은 결국 m과 n값에 아무런 영향을 미치지 못한 것이다.

원래의 의도대로 두 변수의 값을 바꾸고 싶다면 주소에 의한 호출이 필요하다.

(2) 주소에 의한 호출(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;
    }
  • 주소 전달:
    main 함수에서 swap(&m, &n) 호출 시, m과 n의 메모리 주소가 포인터 변수 a와 b에 복사되어 전달됩니다.

  • 역참조로 값 접근:
    *a와 *b는 각각 a와 b가 가리키는 메모리 위치(값이 저장된 곳)를 의미합니다. 즉, *a는 m의 값, *b는 n의 값입니다.

  • 값의 교환:
    swap 함수 내에서 *a와 *b의 값을 서로 교환(값을 서로의 값으로 새로 초기화하는 방식)하면, 실제 메모리상에서 m과 n에 저장된 값(*a, *b)이 변화 됩니다. 이 때 포인터가 가리키는 위치(a,b)는 전혀 변하지 않기에, m과 n을 출력했을 때 정상적인 교환이 된것으로 출력됩니다.

포인터의 정확한 의미

#include <iostream>
using namespace std;

int main() {
    int var = 42;       // 정수 변수 var에 42 저장
    int *ptr = &var;    // ptr은 var의 주소를 저장

    // 포인터 변수 자체(ptr)를 출력하면, var의 주소가 출력됩니다.
    cout << "ptr (주소): " << ptr << endl;

    // 포인터를 역참조(*ptr)하면, 그 주소에 저장된 값(var의 값)이 출력됩니다.
    cout << "*ptr (값): " << *ptr << endl;

    return 0;
}

위에서 포인터변수 ptr을 출력하면 메모리에 담긴 주소가 출력되지만, *ptr을 출력하면 포인터가 가리키는 값이 출력된다.

2. 함수 호출시 클래스 객체 전달

C++에서는 함수에 객체를 인자로 넣을 수 있다. 이 때 값에 의한 호출, 주소에 의한 호출을 이용한 객체전달이 가능하다.

(1) 값에 의한 객체 호출 과정

#include <iostream>
using namespace std;

class Circle {
private:
	int radius;
public:
	Circle();
    Circle(int r);
    ~Circle();
    double getArea(){return 3.14*radius*radius;}
    int getRadius(){return radius;}
    void setRadius(int radius){this->radius = radius;}
};

Circle::Circle(){
	radius = 1;
    cout << "생성자 실행 radius=" << radius << endl;
}
Circle::Circle(int radius){
	this -> radius = radius;
    cout << "생성자 실행 radius = " << radius << endl;
}
void increase(Circle c){
	int r = c.getRadius();
    c.setRadius(r+1);
}

int main(){
	Circle waffle (30);
    increase(waffle);
    cout << waffle.getRadius() << endl;
}

위 코드는 increase함수로 waffle(30)이라는 객체를 넣고있다. 따라서, increase함수의 매개변수 타입도 Circle이라는 클래스로 명명하였다.

실행 과정

  1. Circle waffle(30)이 메인함수 스택에 쌓는다.
  2. 이 객체를 값을 복사하는 방식으로 객체를 복사해 increase를 처음으로 하는 새로운 스택에 쌓는다. (이 스택은 메인스택과 별개 공간의 스택으로, 지금까지의 쌓아올리는 스택이 아니다.)
  3. increase 스택의 radius 값을 +1 시킨다.
  4. increase 함수가 종료되면서, 스택이 삭제된다.
  5. 가만히 있던 waffle 의 radius값인 30을 출력한다.
  6. 메인함수가 종료되며 스택이 삭제된다.

코드 결과

  1. 생성자실행 radius = 30;
  2. 소멸자 실행 radius = 31;
  3. 30;
  4. 소멸자 실행 radius = 30;

여기서 재미있는 점은 복사된 c객체의 생성자가 실행되지 않았다는 점인데, 이는 이미 생성자가 실행된 waffle의 객체를 복사하기 때문이다. (만약 생성자가 한번 더 실행되었다면, 값을 30으로 하는 매개변수가 없었기 때문에, 30이 아닌, 기본 생성자가 실행되며 1로 초기화 되었을 것이다.)

이는 나중에 나올 복사생성자를 통해 설명한다.

(2) 주소에 의한 객체 호출

이제 아래와 같이 객체의 주소를 입력해 호출해보자.

int main(){
	Circle waffle (30);
    increase(&waffle);
    cout << waffle.getRadius() << endl;
}

void increase(Circle *p){ // 주소를 입력한다. 
	int r = p->getRadius();
    p->setRadius(r+1);
}

<실행 결과>
생성자 실행 radius = 30
31
소멸자 실행 radius = 31

실행과정

  1. waffle객체를 가진 메인함수를 메인스택에 쌓는다.
  2. increase함수를 호출하며 새로운 increase 스택에 쌓는다. 이때 p는 waffle의 radius가 담긴 메모리 주소를 가리킨다.
  3. p->getradius를 통해 p의 값을 가져와 r에 저장한뒤, p의 값의 radius를 +1로 업데이트한다. 이 때 p가 radius가 담긴 메모리 주소를 가졌기에, waffle의 radius가 +1된다.
  4. increase가 소멸한다. 이 때 p에는 메모리주소를 가진 포인터만 가져왔기에, 생성자와 소멸자가 실행되지 않는다.

3. 객체 치환 및 객체 리턴

(1) 객체 치환

객체를 치환하면 객체의 모든 데이터가 비트 단위로 복사된다.

Circle c1(5);
Circle c2(30);
c1 = c2;

c1에는 30이 저장되며, c1, c2의 내용은 완전히 같더라도 저장된 공간은 다르다.

(2) 함수의 객체 리턴

함수에서 객체를 리턴하면 그 객체의 복사본이 생기고 복사본이 전달된 뒤 객체가 소멸한다.

#include <iostream>
using namespace std;

class Circle {
private:
	int radius;
public:
	Circle(){radius=1;}
    Circle(int radius) {this->radius = radius;}
    double getArea(){return 3.14*radius*radius;}
    void setRadius(int radius){this->radius = radius;}
};

Circle getCircle(){
	Circle tmp(30);
    return tmp;
}

int main (){
	Circle c;
    cout << c.getArea() << endl; 
    
    c = getCircle() // 30이 저장된 tmp를 리턴한다.
    cout << c.getArea() << endl;
}
<실행 결과>
3.14
2826

첫번 째는 매개 변수를 넣지 않아 기본 생성자가 실행되고 계산되므로 3.14를 출력한다.
두번 째는 tmp(30)을 생성해 c에 복사시킨뒤 소멸한다.(생성자, 소멸자 실행) 이때 복사될 c는 반드시 같은 Circle 클래스 타입이어야 한다.

4. 참조와 함수

참조는 이미 선언된 변수에 대한 별명으로 그 변수와 완전히 같은 역할을 하며, 객체 치환처럼 다른 공간에 저장되는 것이 아니라, 똑같은 공간을 사용(스택이 쌓이거나 추가X)하며 이름만 공유한다.

참조의 사용 방법은 3가지가 있다.

  • 참조변수(개발 편의성)
  • 참조에 의한 호출
  • 함수의 참조 리턴

(1) 참조 변수 선언

<변수에 대한 별명>
int n=2;
int &refn = n;
------------------------
<객체에 대한 별명>
Circle circle;
Circle &refc = circle; 
refc.setRadius(30); 

참조변수(&)의 주의할점

별명인 refc는 circle을 가리키는 포인터가 아니기에 refc->set.radius 처럼 포인터를 사용해 접근하려하면, 오류를 일으킨다. 사용하고 싶다면, 아래처럼 포인터를 따로 만들어야한다.

int *p = &refc;
*p = 20;

(2) 참조 변수 사용

int n = 2
int &refn = n;
refn = 5;
refn ++; / n= 6, refn = 6

참조 변수 선언시 주의사항

  • 참조변수 단독으로 선언불가. 즉, 반드시 별명으로 지정될 변수와 함께 써줘야한다.
ex) int &refn; X
int &refn = n; O
  • 참조변수는 배열을 만들 수 없다.
ex) int &n[10]; X
  • 참조변수는 여러개 만들 수 있다.
int &n= refn;
int &r= refn;
int &k= r;

모두 사용 가능.

참조에 의한 호출(call by reference)

참조 변수는 참조에 의한 호출을 사용할 때 가장 많이 사용한다.

참조에 의한 호출이란?
함수 구현시 개발 편의성을 위해 참조 변수를 매개변수로 사용해 "실인자와 공간을 공유"하도록 하는 것이다.

#include <iostream>
using namespace std;

void swap(int &a, int &b){
	int tmp = a;
    a = b;
    b = tmp;
    }
    
int main(){
	int m=2, n=9;
    swap (m, n);
    cout << m << ' ' << n;
    
<실행 결과>
9 2

위 같이 코딩하면 m과 n의 값을 참조변수로 매개변수로 전달한다. 메인함수 위에 swap()stack이 쌓이면서, 포인터와 마찬가지로 공간을 할당하지 않고, 이름만 전달한다. swap으로인해 m과 n에 새로운 바뀐값이 입력되면, swap stack이 소멸하고 성공적으로 바뀐 결과를 출력 후 메인스택도 종료된다.

참조 매개 변수가 필요한 사례

int average(int a[], int size){
	if(size<0){ 
    	return false;
    }
    int sum = 0;
    for(int i=0; i<size; i++){
    	sum += a[i];
    } 
    return sum/size;
}

위 코드는 별 문제없이 평균을 구해 출력하는 것같다.

int x[]= {1,2,3,4};
int avg= average(x, -1) 

하지만 위처럼 코드를 짜면 어떨까? avg에는 0이 저장되는데, 이 0은 실제 평균이 0이아니라 Fail의 의미를 담고 있어야 하지만 이를 구분할 방법이 없다.

해결:

#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
    //1번조건문
    if(average(x, 6, avg)) {
   		cout << "평균은" << avg << endl;
    }
    else{
    	cout << "매개 변수 오류" << endl;
    }
    //2번조건문
    if(average(x, -2, avg)) {
   		cout << "평균은" << avg << endl;
    }
    else{
    	cout << "매개 변수 오류" << endl;
    }	
}

위 코드는 참조 매개변수(&avg)를 이용해 avg에 값이 들어와서 정상적으로 1번을 출력할 수 있다.

참조에 의한 호출의 특징

값에 의한 호출은 원본 객체를 변화시키지 않고, 소멸자만 실행하지만, 주소에의한 호출처럼 참조에 의한 호출은 원본객체를 대상으로 연산되며, 생산자 소멸자는 아예 실행하지 않는다.

참조 리턴

참조 리턴이란? 함수에서 리턴값으로 "참조"를 사용하는 것이다. 따라서 실제 main스택에 저장된 데이터를 리턴한다.

사용예시1

#include <iostream>
using namespace std;

char &find(char s[], int index){ //함수명 앞에 참조 표기를 하였다. 
	return s[index]; // find로 호출된 실제 배열의 값을 수정한다.
    }
    
int main() {
	char name[] = "Mike";
    cout << name << endl;
    
    find(name, 0) = 'S'; // 0번 위치에 S를 저장
    cout << name << endl;
    
    char &ref = find(name, 2); // 2번위치를 리턴받아서 ref 참조변수에 저장
    ref = 't';                 // ref에 t를 입력한다. 
    cout << name << endl;      
}

<실행 결과>
Mike
Sike
Site

5. 복사 생성자

복사란?
원본과 동일한 별개의 사본을 만드는 것. 원본 객체를 복사할 때 사용하는 개념이며, 특이점으로 일반 생성자 대신 '복사생성자'가 실행된다.

복사생성자는 아래와 같이 const로 선언한다.

class CalssName{
	ClassName(const ClassName &c);
    ClassName()
    }
}
구현 부
className::ClassName(const className &c){
	cout<<"복사완료" <<endl; 
}

재미있는 점은 매개변수로 자기자신의 클래스타입을 지정해 복사한다.
복사시 얕은 복사, 깊은 복사가 일어나며, 단 1개만 복사생성자를 설정할 수 있고, 다른 매개변수를 받을 수 없다.

객체 복사 사용법

복사 생성자 선언

# include <iostream>
using namespace std;

class Circle {
private:
	int radius;
public:
	Circle(const Circle &c); // 복사 생성자 선언
    Circle() {radius = 1;}
    Circle(int radius) {this->radius = radius;}
    double getArea() { return 3.14 * radius * radius;}
};
// 구현부
Circle::Circle(const Circle &c){ // 복사 생성자 구현 
	this -> radius = c.radius;
    cout << "복사생성자 실행 radius = " << radius << endl;
}

int main() {
	Circle src(30);
    Circle dest(src); // dest객체를 생성하고, src 객체를 넣는다. 
    
    cout << "원본 면적 = " src.getArea() << endl;
    cout << "사본 면적 = " dest.getArea() << endl;
}

<실행 결과>
복사 생성자 실행 radius = 30
원본 면적 = 2826
사본 면적 = 2826

위 코드는 dest라는 복사생성자가 실행되면 dest객체를 생성하고, 자기자신의 타입을 가진 src객체를 입력받으면 복사생성자가 실행되며, dest의 radius를 update한다. 그리고 각각 원본과 사본을 출력한다. 코드상 특이점은 자신의 클래스명을 가진 이미존재하는 객체를 인자로 넣은 것이다.

이 때 src와 dest 는 같은 main영역안의 객체임으로 같은 스택 안에 쌓인다.

디폴트 복사 생성자

class Circle {
	int radius;
public:
	Circle(int r);
    double getArea();
};

int main(){
	Circle dest(src); // dest복사 생성자 사용
};

위 같이 선언부, 구현부에 복사 생성자를 생성하지 않으면 어떻게 될까?

컴파일러는 아래와 같은 디폴트 복사생성자를 자동 생성해 준다.

Circle::Circle(const Circle & c){ // 복사 생성자 구현 
	this -> radius = c.radius;
}

6. 얕은 복사 생성자의 문제점과 깊은 복사 사용법

얕은 복사와 깊은 복사란?

  • 얕은 복사 :복사된 사본이 원본과 같은 메모리를 공유하는 것.
  • 깊은 복사 :복사된 사본이 원본과 다른 메모리를 공유하는 것.

클래스를 만들다 보면 맴버변수에 포인터를 사용하는 경우가 있다. 이 때 원본 객체의 포인터 맴버변수가 사본객체의 포인터 맴버 변수로 복사되면, 이 둘은 같은 메모리를 가리키게 되어 문제가 발생한다. == 포인터만 사용하지 않는다면 얕은 복사는 아무런 문제가 없다.

얕은 복사 생성자로 인해 비정상 종료되는 경우

#include <iostream>
#include <cstring>
using namespace std;

class Person{
	char *name;
    int id;
public:
	Person(int id, const char *name); //기본생성자
    Person(const Person &p); //기본복사생성자
    ~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);
}

Person::~Person(){ // 소멸자
	if(name) //만약 name[]동적 배열이 존재한다면, 
    	delete [] name; // 동적 배열을 삭제한다.
        }
        
void Person::changeName(const char* name){ // 기본보다 긴 name으로 변경 불가.
	if(strlen(name) > strlen(this->name))
    	return;
    strcpy(this->name, name);
    }

Person::Person(const Person &p){ // 기본 복사생성자, 컴파일러가 생성하기에 적지 않아도 된다.
	this ->id = p.id;			
    this ->name = p.name;       
}
    
int main(){
	Person father(1, "Kitae");
    Person daughter(father); // 복사생성자 호출
    
    cout << "daughter 객체 생성 직후 ----" << endl;
    
    father.show();
    daughter.show();
    
    daughter.changeName("Grace");
    cout << "daughter 이름을 Grace로 변경한 후 ----" << endl;
    father.show();
    daughter.show();
    
    return 0;
}

위 코드를 해석하면 다음과 같다.

  1. father 객체를 생성해 id에 1, name에 kitae 를 입력한다.
  2. father를 복사한 daughter 객체를 생성한다.
  3. 이 때 생성자 대신 기본 복사 생성자를 호출한다.
  4. "daughter 객체 생성 직후 ---" 문장이 출력된다.
  5. father.show에 의해 (1, kitae) 가 출력된다.
  6. daughter.show에 의해 (1, kitae) 가 출력된다.
  7. daughter.changeName()에 의해 name이 grace로 바뀐다.
  8. father.show에 의해 1, grace 가 출력된다. //얕은 복사라서 father도 같이 바뀜
  9. daughter.show에 의해 1, grace 가 출력된다.
  10. main 함수가 종료되면서, father객체가 소멸하며, 동적 배열이 삭제된다.
  11. daughter 객체가 소멸하며, 동적 배열을 삭제시킬 수 없어 RUNTIME 에러를 출력한다.

깊은 복사 생성자를 이용해 정상 처리하는 경우

#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);
}

Person::~Person(){ // 소멸자
	if(name) //만약 name[]동적 배열이 존재한다면, 
    	delete [] name; // 동적 배열을 삭제한다.
        }
        
void Person::changeName(const char* name){ // 기본보다 긴 name으로 변경 불가.
	if(strlen(name) > strlen(this->name))
    	return;
    strcpy(this->name, name);
    }

Person::Person(const Person &person){ // 깊은 복사 생성자(명시)
	this ->id = person.id;
    int len = strlen(person.name);
    this ->name = new char [len + 1]; 
    strcpy(this->name, person.name);
    cout << "복사생성자 실행. 원본 객체의 이름" << this -> name << endl;
}
    
int main(){
	Person father(1, "Kitae");
    Person daughter(father);
    
    cout << "daughter 객체 생성 직후 ----" << endl;
    
    father.show();
    daughter.show();
    
    daughter.changeName("Grace");
    cout << "daughter 이름을 Grace로 변경한 후 ----" << endl;
    father.show();
    daughter.show();
    
    return 0;
}
  1. father 객체를 생성해 id에 1, name에 kitae 를 입력한다.
  2. father를 복사한 daughter 객체를 생성한다.
  3. 이 때 생성자 대신 명시한 복사 생성자를 호출한다.
  4. 복사생성자에서 name에 새로운 배열을 만들어서 공간을 할당한다.
  5. 새로운 공간에 strcpy를 이용해 father가 복사된 "person.name"을 새공간 "name"에 복사해 저장한다.
  6. father.show에 의해 1, kitae 가 출력된다.
  7. daughter.show에 의해 1, kitae 가 출력된다.
  8. daughter.changeName("Grace");에 의해 name이 greace로 변경된다.
  9. father.show에 의해 1, kitae 가 출력된다.
  10. daughter.show에 의해 1, grace 가 출력된다.
  11. main 함수가 종료되면서, father객체가 소멸하며, 동적 배열이 삭제된다.
  12. daughter 객체가 소멸하며, 동적 배열이 정상 삭제된다.

비교 해보기

1. 얕은 복사 생성자(기본) 
Person::Person(const Person &p){ 
	this ->id = p.id;			
    this ->name = p.name;       
}

2. 깊은 복사 생성자
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); 
    cout << "복사생성자 실행. 원본 객체의 이름" << this->name << endl;
}
profile
DB가 좋아요

0개의 댓글