본문 바로가기
개발

Effective C++ 항목 20: '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다!!

by 뿔난 도비 2025. 6. 17.
반응형

Effective C++ 책을 가지고 스터디를 진행하고 있는데, 제가 맡았던 부분에 대해서 기록을 남겨볼까 합니다.

 

Effective C++ 항목 20: '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다!!

 

 

개요

 

- 기본적으로 C++는 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달 (pass-by-value) 방식을 사용한다.

- 함수 매개변수에는 '사본'이 전달되며, 함수를 호출한 쪽은 '사본'을 받는다.

- 이때, 사용되는 것이 복사 생성자인데, 값에 의한 전달은 때로는 고비용의 연산이 되기도 한다.

왜 고비용의 연산이 될까?

 

- 아래와 같은 부모-자식 관계를 갖는 클래스가 있다고 가정해보자.

- 다이어그램에서는 기본 생성자, 복사 생성자, 그리고 소멸자를 생략했으며, 실제 코드는 아래와 같다.

/*
	부모 클래스
*/
class Person {
public:
	Person() : name("GilDong"), address("Seoul") {
		std::cout << "부모 기본 생성자" << std::endl;
	}
	Person(const Person& rhs) : name(rhs.name), address(rhs.address) {
		std::cout << "부모 복사 생성자 호출" << std::endl;
	}
	~Person() {
		std::cout << "부모 소멸자 호출" << std::endl;
	}
private:
	std::string name;
	std::string address;
};
/*
	자식 클래스
*/
class Student : public Person {
public:
	Student(): schoolName("IDIS"), schoolAddress("SUNGNAM") {
		std::cout << "자식 기본 생성자" << std::endl;
	}
	Student(const Student& rhs) : Person(rhs), schoolName(rhs.schoolName), schoolAddress(rhs.schoolAddress) {
		std::cout << "자식 복사 생성자 호출" << std::endl;
	}
	~Student() {
		std::cout << "자식 소멸자 호출" << std::endl;
	}

private:
	std::string schoolName;
	std::string schoolAddress;
};

 

- 이때, 자식 클래스인 Student를 생성하고 함수 매개변수로 값에 의한 전달을 하면 실제로는 다음의 것들이 호출된다.

void passByValue(Student s) {}

int main() {
	Student p1;

	passByValue(p1);
	
	return 0;
}

 

1. 부모 클래스의 복사 생성자

2. 부모 클래스의 멤버 데이터인 string 객체 복사 생성자 x2

3. 자식 클래스의 복사 생성자

4. 자식 클래스의 멤버 데이터인 string 객체 복사 생성자 x2

 

- 그러고 나서 함수가 종료되면, 다음의 소멸자들이 호출된다.

1. 자식 클래스의 멤버 데이터인 string 객체 소멸자 x2

2. 자식 클래스 소멸자

3. 부모 클래스의 멤버 데이터인 string 객체 소멸자 x2

4. 부모 클래스 소멸자

 

- 총 6번의 복사 생성자와 6번의 소멸자가 호출될 수 있다.

- 실제로 위의 코드를 실행해보면 결과는 다음과 같다.

- 이때, string 객체의 생성과 소멸은 재정의하지 않았기 때문에 볼 수 없다.

해결책

 

- 이를 해결하기 위해서는 상수객체에 의한 참조자 호출을 할 수 있다.

- 상수객체에 의한 참조자 호출은 여러 가지 이점이 있다.

 

1) 새로 만들어지는 객체가 없다.

2) 복사 손실 문제가 없다.

 

- 복사 손실 문제는 다음 챕터에서 짚어보기로 하고, 새로 만들어지는 객체가 없는지는 아래의 코드를 실행해보면 알 수 있다.

void passByReference(const Student& s) {}

int main() {
	Student p1;

	passByReference(p1);
	
	return 0;
}

- 실행하면 아래의 결과가 나온다.

- 최초에 main() 함수가 호출되었을 때 p1 객체가 생성되면서 호출되는 생성자와 main() 함수가 종료되며 p1 객체가 소멸되면서 호출되는 소멸자 외에 어떠한 복사 생성자도 호출되지 않음을 알 수 있다.

- 하지만, 참조에 의한 전달을 하게 되면 원본이 변경될 위험이 존재한다.

- 값에 의한 전달을 하면 사본이 전달되고, 사본을 대상으로 변경이 적용되기 때문에 원본이 바뀌지 않는다.

- 이러한 문제를 예방하기 위해 const 처리를 해 변경 가능성을 없앨 수 있다.

복사 손실 문제 (Slicing Problem) 이란?

 

- 복사되는 과정에서 정보가 손실되는 것을 의미한다.

- 다음과 같은 상황에서 복사 손실 문제가 발생할 수 있다.

/*
	자식 객체를 부모 클래스 타입으로 
	전달받는 경우
*/
void slicingProblem(Person s) {}

int main() {
	Student p1;

	slicingProblem(p1);
	
	return 0;
}

- 위의 실행 결과를 살펴보면, 부모 생성자와 부모 소멸자만 호출되는 것을 알 수 있다.

- 이것은 전달받은 s에는 자식 객체의 일부 데이터를 부모 객체로 복사한 결과로 생성된 부모 객체가 담겨있기 때문이다.

- 개발자는 자식 객체의 데이터를 전달했다고 생각하지만, 실제로는 자식 객체의 데이터는 잘리게 되는 것이다.

- 이런 문제를 해결하기 위해서도 상수객체에 의한 참조자를 전달하는 것이 옳다.

- 상수객체에 의한 참조자를 전달하면, 원래 객체의 성질을 잃지 않기 때문이다.

 

- 객체의 성질을 잃지 않는다?? 이게 무슨 말일까?

- 아래의 코드를 살펴보자.

/* 부모 */
class Person {
public:
	...
	virtual void walk() const {}
	...
};
/* 자식 */
class Student : public Person {
public:
	...
	void walk() const {
		std::cout << "자식 걷는중" << std::endl;
	}
	...
};

void passByReference(const Person& s) {
	s.walk();
}

int main() {
	Student p1;

	passByReference(p1);

	return 0;
}

- s.walk()를 호출하게 되면, 우리가 생각하는 것처럼 "자식 걷는중"이 출력된다.

- 그 이유는 부모 타입의 참조자이지만 실제로 들어간 객체는 자식 타입이기 때문이다.

- 자바의 Late Binding과 같이 virtual로 선언된 가상 함수의 경우 실제로 호출될 함수는 Runtime 시점에 결정되는데 s에 담겨있는 객체가 실제로는 Student 객체이기 때문에 Student의 walk() 함수가 호출된다.

- 만약 값에  의한 전달을 했다면, Student 객체의 walk() 함수는 절대 호출될 수 없다.

- 따라서 다형성을 보존하고 싶다면, 상수객체에 의한 참조자를 전달하는 것이 옳다.

항상 상수객체 참조자에 의한 전달을 사용하나?

 

- 답은 당연하게도 '아니다' 이다.

- int, double과 같은 기본 타입은 사이즈가 작아 컴파일러의 판단에 따라 레지스터에 저장된 채로 연산될 수 있다.

- 하지만 참조자를 전달하는 경우 추가적인 주소 계산이 이루어져야 하기 때문에 효율성이 떨어질 수 있다.

- 따라서 기본 타입은 값에 의한 전달을 하는 것이 효과적이다.

 

- 또, 반복자와 함수 객체 역시 값에 의한 전달이 이루어지도록 설계되어 있다.

- 이들은 "복사 효율을 높일 것", "복사 손실 문제에 노출되지 않을 것"을 전제로 설계되어 있다.

- 따라서 반복자나 함수 객체를 직접 구현할 때에도 값에 의한 전달을 쓰는 것이 낫다.

 

.... 그렇다면, 사이즈가 작으면 다 값에 의한 전달을 하면 될까?

- 이것도 답은 '아니다' 이다.

- 만약, 어떤 객체가 포인터 변수를 하나 갖는다고 가정해보자.

- 이 객체를 그냥 복사하게 되면, 아래와 같이 기존 객체와 복사된 객체가 똑같은 곳을 가리키고 있다.

- 이런 경우, A에서 *p1의 메모리를 해제하면, A_copied는 쓰레기 값을 가리키게 된다.

- 그렇기 때문에 이런 객체는 아래 그림과 같이 포인터 변수가 가리키는 대상 역시 복제할 필요가 있다.

- 위와 같은 경우라면 복사하려는 객체의 크기가 작더라도 복사 비용이 비쌀 수 있다.

 

- 또, 컴파일러 중에는 기본 타입과 사용자 정의 타입을 아예 별개로 취급하는 경우가 있다.

- 이런 경우, double 타입의 데이터 멤버만 가지고 있는 객체이더라도 레지스터에 넣지 않는 경우가 있다.

- 이런 환경이라면, 포인터는 확실하게 레지스터에 저장되니 차라리 참조에 의한 전달을 쓰는 것이 좋다.

 

- 마지막으로 사용자 정의 타입의 크기는 변경의 여지가 존재한다.

- 초기에는 사이즈가 작아 값에 의한 전달이 효율적이었을 수도 있지만, 나중에는 아니게 될 수 있다.

결론

 

- 결국 기본 타입, 반복자, 그리고 함수 객체를 제외한 나머지는 상수객체 참조자에 의한 전달을 하는 것이 좋다.

 

C++을 다뤄보지 않아서 하나의 항목을 읽기 위해서 많은 백그라운드 지식이 필요했다. 그렇지만, 이렇게 힘들게 힘들게 공부하면서 뭔가 더 오랫동안 기억하게 될 것 같다. 소프트웨어 공학 관련 책과 이 책은 공통점이 있는데, 주장하고자 하는 것이 항상 정답이 아니라는 것이다.

반응형