[C++ 프로그래밍 / 이론] Chapter2. 값과 참조

YH·2023년 7월 24일
0

Chapter2. 값과 참조

변수가 대입되거나 다른 객체 생성을 위한 인자 등으로 사용되는 상황에서 데이터의 복사가 발생한다. 이때 실질적으로 복사되는 것이 메모리 주소인 경우 참조 전달이라고 하고, 그렇지 않은 경우 값 전달이라고 한다. 전통적으로 C언어에서는 포인터를 전달함으로써 참조 전달을 수행하였는데 C++에서는 참조를 받기위한 참조자를 따로 제공한다. 이하에서는 포인터를 메모리 주소를 담는 값 타입으로 취급하고 참조는 참조자만을 지칭한다.
참조자는 초기화가 필수이며 이때 참조 정보(메모리 주소)가 전달된다. 이후 참조자에 대한 접근은 원본에 대한 접근과 동등하게 취급된다. 따라서 원본에 새로운 별칭을 붙여준 것처럼 생각할 수 있다.

int x;
int& rx = x; //rx는 int형 참조자, &x와 &rx는 동일함
rx = 10; //x = 10과 동일한 효과
int& rrx = rx; //참조자는 포인터와 달리 참조레벨이 없음

rx의 타입을 보면 int&로 되어있는데 이렇게 타입 명 뒤에 &가 붙으면 해당 타입에 대한 참조 타입을 의미한다. 이는 어떤 타입명 뒤에 &가 붙으면 해당 타입에 대한 참조 타입을 의미한다. 이는 변수명 앞에 &가 붙어 변수의 주소를 반환하는 참조 연산자와 다른 구문이다.
rx는 x를 참조하는 참조자이므로 x의 주소인 &x와 rx의 주소인 &rx는 같다. rx에 대한 참조자를 다시 만들더라도 참조 레벨을 고려할 필요가 없다. 이미 x와 동등한 지위를 갖기 때문에 rx를 참조하는 참조자 또한 int& 타입이면 된다.
참조자를 초기화할 때 주는 인자는 반드시 왼 값이어야 한다. 왼 값이란 &(참조연산)을 통해 메모리주소를 얻어올 수 있는 값을 의미한다. 이름을 가진 모든 변수는 왼 값이며, 모든 함수는 왼 값이다.
포인터 대신 참조자를 사용하는 것은 타이핑을 줄여주기도 하지만 댕글링 포인터 버그로부터 보다 안전한다는 장점도 있다. 댕글링 포인터란 유효하지 않은 대상을 가리키는 포인터 또는 참조자를 말한다. 참조자는 초기화하가 필수이며 적어도 초기화한 시점에서는 참조대상이 유효하지만, 포인터는 문법적으로 초기화를 강제하지 않기 때문에 댕글링 포인터로 시작할 가능성이 있다. 물론 참조자라도 참조자가 원본보다 오래 살아있게 되면 댕글링 포인터가 되므로 그런 상황이 발생하지 않도록 유의할 필요가 있다.
가장 대표적인 참조자의 오용은 지역변수를 참조자로 리턴하는 것이다.

int& func()
{
	int x = 1;
    return x;
}

위 코드에서 x는 func가 끝나는 순간 스택 리와인딩에 의해 소멸하며 해당 메모리는 언제든지 다른 것으로 덮어 씌워질 수 있다. 그런데 함수 외부에 int&로서 x에 대한 참조를 리턴하게 되면, 이 리턴 값을 통해 이미 소멸한 x에 접근하는 코드가 작성될 수 있다. 이런식으로 참조자도 잘못 사용하면 댕글링 포인터 버그를 만들어내기 쉽다.
그럼에도 문법적으로 참조자를 리턴하는 것을 허용하는 이유는 그것이 유용한 경우가 있기 때문이다. 가장 대표적인 예는 축약형 산술 연산이 있다.

struct Matrix
{
	//멤버변수 정의 생략...
    Matrix& operator += (const Matrix& rhs)
    {
    	//구현 생략...
        return *this;
    }
};

operator +=는 Matrix의 메서드이므로 Matrix의 인스턴스를 통해서만 호출될 수 있다. 따라서 호출 시점에서 그 인스턴스는 유효한 상태이며 자기 자신을 참조로서 리턴하는 것은 문제가 없다. 또는 참조 파라미터로 받은 인자를 리턴하는 것도 있을 수 있는 시나리오이다.
복제 오버헤드를 피하기 위해 파라미터를 참조로 했지만 함수 내에서 인자로 전달된 객체를 변경시키지 않는다면 const 한정사를 붙여주는 것이 좋다. 여기에는 두 가지 이유가 있는데 첫 번째 이유는 함수를 호출하는 사용자 입장에서 해당 함수에 넘긴 인자가 그로 인해 변조되지 않을 것이라는 것을 보장 받을 수 있다는 점이다. 만약 변조를 시도하는 코드가 있다면 컴파일이 거부될 것이기 때문이다. 두 번째 이유는 const 참조는 오른 값도 받을 수 있다는 것이다. 오른 값은 왼 값과 달리 주소를 얻을 수 없는 값을 말하는데, 대표적으로 임시 객체들은 모두 오른 값이다. 만약 const가 아닌 참조 파라미터라면 왼 값만 인자로 올 수가 있어 활용성이 보다 떨어진다.


  • 오른값 참조와 이동시맨틱*

C++11 이상부터는 오른값을 받기 위한 참조자가 제공된다. 어떤 타입 명 뒤에 &&가 붙으면 오른 값 참조자를 의미한다.

struct widget(); //c++에서 struct는 default 접근제한자가 public인 class

void func1(widget&& rValueWidgetRef)
{
	//...
}

widget func2()
{
	widget ret;
    return ret;
}

int main()
{
	func1(func2()); //참조 타입이 아닌 리턴값은 오른 값
    func1(widget{}); //임시 객체는 오른 값
    int&& rValueRef = 123; //모든 정수(문자포함), 실수 리터럴은 오른 값
}

func2는 리턴 타입이 widget이므로 func2()는 widget타입의 오른 값으로 평가된다. func1의 파라미터는 widget&& 이므로 widget 타입 오른 값에 바인딩 할 수 있다.
widget()은 임시 객체이고 오른 값이므로 func1에 인자로 넘길 수 있다.
모든 리터럴은 오른 값이다. 하나의 예외가 있는데 C스타일의 문자열(따옴표로 감싼 문자열) 리터럴 이다.

const char(*p)[5] = &"abcd"; //char[5] 배열에 대한 포인터
const char(&ref)[5] = "abcd"; //char[5] 배열에 대한 참조자

"abcd"는 문자열 리터럴이지만 다른 리터럴과는 다르게 참조연산이 가능한 왼 값이다. 따라서 배열에 대한 포인터로 참조하는 것도 가능하고 배열에 대한 참조자로 참조하는 것도 가능하다.
오른 값 참조는 이동시맨틱의 지원에 핵심적인 역할을 한다. 이동 시맨틱이란 어차피 소멸할 객체를 복제(깊은 복사)해야하는 상황에서 복제하는 대신 이동(얕은 복사)하여 메모리에 대한 오너십만 이동한다는 개념이다. 이때 어차피 소멸할 객체인지를 판단하는 기준이 그것이 왼 값인지 오른 값인지 이다.
기술적으로 이동시맨틱은 클래스를 정의함에 있어 이동 생성자와 이동 대입 연산자를 구현함으로써 지원됩니다. 복제 생성자와 복제 대입 연산자는 파라머니터로 const 참조 타입을 갖지만 이동 생성자와 이동 대입 연산자는 우측값 참조 타입을 갖습니다. 때문에 인자가 우측값이라면 const 참조보다는 우측값 참조가 더 잘 부합하므로 이동 생성장 또는 이동 대입 연산자가 호출되며, 이들은 얕은 복사를 하여 원본 객체가 가지고 있는 메모리들에 대한 오너십을 가져오고 원본 객체는 해당 메모리를 참조하지 못하도록 nullptr로 변경한다.

class Sample
{
private:
	int memberA;
    Class0 memberB;
    Class1* memberC; //array in heap memory
    
public:
	Sample(Sample&&); //이동 생성자
    Sample& operator=(Sample&&); //이동 대입 연산자
};

Sample::Sample(Sample&& src) : //이니셜라이저
memberA(src.memberA),
memberB(std::move(src.memberB)), //오른 값으로 캐스팅
memberC(src.memberC),
{
	src.memberA = 0;
    src.memberC = nullptr;
}

Sample::operator=(Sample&& src)
{
	if (this == &src)
    	return *this;
    delete[] memberC;
    memberA = src.memberA;
    memberB = std::move(src.memberB);
    memberC = src.memberC;
    src.memberA = 0;
    src.memberC = nullptr;
}

위 코드는 동적 할당된 메모리를 포함하는 어떤 클래스에 대해서 이동 생성자와 이동 대입 연산자의 구현 예를 보여준다. memberB를 생성할 때 src.memberB를 move 함수를 통해 오른 값으로 캐스팅해준다. 이렇게 했을 때, Class0이 만약 이동 대입 연산자를 지원한다면 이동이 될 것이고 지원하지 않는다면 복사가 될 것이다.
memberC는 포인터이므로 그대로 값 복사를 해주면 된다. 그러나 이동 대입 연산자에서는 좌항의 객체가 이미 존재하는 상태이기 때문에 먼저 자신의 memberC가 가리키는 메모리를 먼저 해제해주어야 한다.
이동 생성자와 이동 대입 연산자는 특수 멤버 함수로서 미리 정해진 규칙에 따라 컴파일러가 자동으로 생성해주기도 한다.

특수 멤버 함수자동 생성 규칙
디폴트 생성자-사용자 선언 생성자가 없는 경우
소멸자-사용자 선언 소멸자가 없는 경우
복사 생성자-사용자 선언 복사 생성자가 없는 경우
-사용자 선언 이동 연산이 하나라도 있으면 삭제됨
복사 대입 연산자-사용자 선언 복사 대인 연산자가 없는 경우
-사용자 선언 이동 연산이 하나라도 있으면 삭제됨
-멤버변수 중 const 또는 참조자가 있으면 삭제됨 (직접 선언하더라도 삭제됨)
이동 생성자-사용자 선언 복사 연산들과 이동 연산들, 소멸자가 모두 없는 경우
이동 대입 연산자-사용자 선언 복사 연산들과 이동 연산들, 소멸자가 모두 없는경우
-멤버변수 중 const 또는 참조자가 있으면 삭제됨 (직접 선언하더라도 삭제됨)

컴파일러가 자동 생성해주는 특수 멤버 함수는 각 함수가 해야할 기본적인 일만 해준다.

  • 디폴트 생성자는 모든 멤버 변수 또한 디폴트 생성자를 통해 생성해준다.
  • 소멸자는 모든 멤버 변수의 소멸자를 호출해준다. base 클래스의 소멸자가 virtual인 경우 자동 생성된 소멸자도 virtual이다.
  • 복사 생성자는 모든 멤버 변수를 복사 생성자를 통해 생성해준다.
  • 복사 대입 연산자는 모든 멤버 변수를 각각 대응하는 원본 멤버 변수로부터 복사 대입해준다.
  • 이동 생성자는 모든 멤버 변수를 이동 생성자를 통해 생성을 시도한다. 불가능한 경우 복사 생성된다.
  • 이동 대입 연산자는 모든 멤버 변수를 대응하는 원본 멤버 변수로부터 이동 대입해준다.

3의 법칙이라고 "소멸자와 복사 생성자 복사 대입 연산자는 셋 중 하나라도 직접 구현한다면 나머지 둘도 구현해야 한다"는 말이 있다. 이 셋 중 하나를 직접 구현해야 하는 상황은 동적 메모리에 대한 관릴르 직접하는 경우 말고는 생각하기 어렵고, 그렇다면 이 세 특수 멤버 함수는 모두 직접 구현해야할 필요가 있기 때문이다.
특수 멤버 함수의 자동 작성이 원치 않게 취소되어 직접 작성해야만 한느 경우가 있을 수 있는데, 이럴 때는 default 키워드를 활용하면 타자량을 줄일 수 있다.

class Base {
public:
	Base() = default; //디폴트 생성자
    Base(const Base&&) = default; //복사 생성자
    Base(Base&&) = default; //이동 생성자
    Base& operator = (const Base&) = default; //복사 배정 연산자
    Base& operator = (Base&&) = default; //이동 배정 연산자
    virtual ~Base() = default; //소멸자
}

final이 아닌 클래스라면 소멸자는 virtual로 선언해야한다. virtual 키워드는 해당 함수에 대해 가상함수 테이블을 만들어주는 선언이다. 즉, 소멸자는 다형성을 지원해야할 필요가 있다. 그렇지 않으면 다음과 같은 상황에서 메모리 누수가 발생할 수 있다.

struct A {
};

struct B : A {
	int* pArr;
    B()
    {
    	pArr = new int[8];
    }
    ~B()
    {
    	delete[] pArr;
    }
};

void func()
{
	const A& aRef = B();
    // ...
    // aRef가 소멸할 때 B의 소멸자가 호출되지 않고 A의 소멸자만 호출되어 동적 메모리 해제가 누락됨
}
profile
Keep Recycling Your Dreams

1개의 댓글

comment-user-thumbnail
2023년 7월 24일

정보 감사합니다.

답글 달기