설계 및 선언 🔐

안녕하세요! 두두코딩 입니다 ✋
오늘은 상수 객체 참조자에 의한 전달 개념에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

값에 의한 전달의 문제점

기본적으로 C++는 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때, “값에 의한 전달 (pass by value)” 방식을 사용한다. 우리가 특별히 지정하지 않는 한, 함수 매개 변수는 실제 인자의 ‘사본’ 을 통해 초기화 되며, ‘사본’ 을 반환받도록 구현된다.

이런 “값에 의한 전달” 이 고비용 연산이 되기도 하는데, 코드로 문제를 한번 알아보자.

class Person {
public:
	Person();
	virtual ~Person();
	...

private:
		std::string name;
		std::string address;
};

class Student : Person {
public:
	Student();
	~Student();
	...

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

위의 코드가 있다고 생각해보자. 위의 코드는 사람이라는 기반 클래스가 있고, 해당 클래스를 상속받아 “학생” 클래스를 생성한다. 아래의 코드가 동작됐을 때 어떤 일이 발생하겠는가?

// 학생 확인 함수
bool validateStudent(Student s) {
	...
}

int main () {
	...
	Student plato;

	bool platoIsOk = validateStudent(plato);
}

위의 validateStudent 함수는 Student 객체를 전달받아 학생이 맞는지 확인하는 함수이다.

해당 함수를 호출하게 되면, validateStudent 함수에 있는 매개변수 splato를 초기화 하기 위해 복사생성자를 호출할 것이다. 그리고, validateStudent 함수를 떠나게 될 때 소멸자 한번을 호출한다.

즉, 해당 함수만 호출했을 뿐인데, 아래의 동작이 수행된다.

이렇게 끝나면 그래도 사용할만 하다. Student 객체 같은 경우 string 객체 즉, schoolName, schoolAddress 를 포함하고 있다. 해당 객체들도 생성자가 호출되면 초기화 되어야한다. 또한, Person 객체에서 상속받은 string 객체 즉, name, address도 초기화를 위한 생성자를 호출해야한다. 즉, 4번의 생성자가 추가로 호출된다.

그리고, 복사생성자로 생성된 매개변수 s가 함수를 떠나게 되면 해제되게 되는데, 이때 우리가 호출했던 string들의 소멸자를 호출해야한다. 즉, 4번의 소멸자가 추가로 호출 된다.

자, 정리해보자. 우리는 Student 객체를 ValidateStudent함수에 인자로 전달했을 뿐인데, 아래와 같은 동작이 추가로 발생하게된다.

위와 같은 동작을 한다고 해서 프로그램이 멈추거나 안돌아가거나 하는 건 아니다. 다만, 시스템 성능이나 메모리적 관점에서는 좋지 못한 코딩방법이다. 그렇다면, 이렇게 부과적으로 불리는 생성자 혹은 소멸자를 호출 하지 않도록 할 수 있는 방법은 없을까?

❗ 바로 상수객체에 대한 참조자로 전달하게 되면 해당 문제를 해결할 수 있다!

상수객체에 대한 참조자로 전달

상수객체에 대한 참조자로 전달이 무슨말일까? 아래의 코드를 보자.

// 위의 코드
// bool validateStudent(Student s);

// 변화된 코드
bool validateStudent(const Student& s);

위와 같이 함수를 만들게 되면, 불필요하게 호출되는 생성자 및 소멸자를 제거할 수 있다. & 연산자를 사용하게 되면 기존 객체를 참조하는 것이기 때문에 새로운 객체가 만들어지지 않는다 여기서 중요한 점이 const 키워드이다.

원래는 Student라는 객체를 값으로 전달 받아 사용하기 때문에 인자로 전달하는 Student는 변화가 없다. 즉, Student를 전달받아 복사한 후 내부 함수에서 사용하기 때문에 전달하는 Student의 값은 변화가 없다.

따라서, 우리는 참조되는 Student 값을 변경하지 않겠다는 약속으로 const 키워드를 작성해줘야한다.

위와 같은 방식으로 작성할 경우 불필요한 생성자 호출을 막을 수 있으며, 추가로 복사 손실 문제도 방지할 수 있다.

복사 손실

복사 손실이란 파생 클래스 객체가 기반 클래스 객체로서 전달되는 경우는 드물지 않게 접할 수 있다. 이때 해당 객체가 값으로 전달되면 기반 클래스의 복사생성자를 호출하게 되고, 파생 클래스로 동작하는 개념들을 전부 잃어버리게(손실)된다.

크게 놀랄필요 없는게, 기반 클래스의 복사 생성자를 부르는 것이니 너무 당연하게 파생 클래스는 고려되지 않는다.

실제 예시를 갖고 알아보자.

#include <iostream>

using namespace std;

class Window {
public:
	string name() const {
		return "this is window";
	}

	virtual void display() const {
		cout << "display window" << endl;
	}
};

class WindowWithScrollBars : public Window {
public:
	virtual void display() const {
		cout << "WindowWithScrollBars" << endl;
	}
};

위 코드는 Window 기반 클래스를 상속받아 WindowWithScrollBars라는 클래스를 생성한다.

void printNameAndDisplay(Window w)
{
	cout << w.name() << endl;
	w.display();
}

int main() {
	WindowWithScrollBars wwsb;
	printNameAndDisplay(wwsb);
}

printNameAndDisplay를 통해 값으로 window를 전달받아 현재 Window의 내용을 출력하도록 하는 코드이다. 값으로 전달받기 때문에 Window의 복사생성자가 불리게 되고, 파생 클래스의 내용은 사라지게 된다. 즉, cout << "display window" 해당 문장이 불리게 되고, 이를 복사 손실 낫다고 한다.

당연히 WindowWithScrollBarsdisplay가 호출 될 줄 알았는데, 복사손실로 불리지 않는 것을 코드로 확인할 수 있다. 이를 해결하기 위해서는 아래와 같이 구성해야한다.

void printNameAndDisplay(const Window& w)
{
	cout << w.name() << endl;
	w.display();
}

위와 같이 사용할 경우 window 참조자로 동작하며 window를 포함해 만들어진 WindowWithScrollBars 객체를 제어할 수 있게 된다. virtual 키워드 특성을 활용해 파생 클래스의 display를 즉, cout << "WindowWithScrollBars" 가 호출되는 것을 볼 수 있다.

언제 값으로 전달 해야하나요?

위의 내용만 들어보면, 값으로 전달하지 않고 무조건 참조자로 전달하는 것이 좋지 않을까? 그렇게 생각할 수 있다.

물론, 보통은 참조자 또는 포인터를 써서 사용하지만, 전달하는 타입이 기본 제공 타입일 경우에는 값으로 전달하는 것이 좋다.

값으로 전달하면 좋을 몇 가지 경우를 소개한다.

위의 3가지 경우에는 값으로 전달하는 것이 좋다. STL 관련된 부분들은 구현할 때, “복사 효율을 높일 것”, “복사손실 문제에 노출되지 않을 것” 이라는 두 가지 생각해야될 점이 존재한다.

그렇다면 왜 해당 경우들에서는 값으로 전달하는 것이 좋을까?

기본 타입기준으로 분석해보자.

  1. 기본타입은 크기가 작다.
    • 위의 설명으로 모든 것이 해결 되지 않는다. 예를들어, 사용자 타입이긴 한데, 포인터만 달랑하나 들고 있을 경우에는 어떻게 설명할 것인가? 포인터가 있을 경우 가리키는 객체도 모두 복사해야되는 부담이 있기 때문에 비용이 클 수 있다. 따라서, 가리키는 객체가 없는 기본타입은 값으로 전달해도 된다.
  2. 컴파일러 중 기본타입은 수행성능이 다를 수 있다.
    • 기본 타입은 레지스터에 넣어서 처리하지만 참조타입 즉, 포인터 타입은 레지스터에 넣어주지 않을 수 있다. 즉, 확실하게 레지스터에 들어가 처리되기 때문에 참조가 아닌 값으로 처리하는 것이 좋다.
  3. 사용자 정의 타입크기는 언제든 변할 수 있다.
    • 가장 중요한 이야기이다. 사용자 정의 타입은 말그대로 사용자가 정의한다. 따라서, 고정적이지 않다. 그래서 참조로 전달하는 것이 좋다.

To Sum Up

👉 ‘값에 의한 전달’ 보다는 ‘상수 객체 참조자에 의한 전달’을 선호하도록 하자. 효율과 복사손실을 둘 다 해결할 수 있는 만능이다.