본문 바로가기
C++ STL, 알고리즘

참조형 &

by wanna_dev 2024. 10. 7.
  1. 포인터보다 안전하다. 왜냐하면 메모리 주소를 직접 다루지 않기 때문에 nullptr가 될 수 없다.
  2. 코드의 스타일이 더 좋아진다. 스택변수와 문법이 같아서 *이나 &같은 심볼을 사용하지 않아도 된다.
  3. 메모리의 오너십이 어디에 있는지 명확히 해준다.

c++에서 레퍼런스(참조) 란 일종의 변수에 대한 앨리아스(별칭)이다. 레퍼런스를 이용해서 수정한 내용은 그 레퍼런스가 가리키는 변수의 값에 그대로 반영된다.

레퍼런스는 변수의 주소를 가져오거나 변수에 대한 역참조 연산을 수행하는 작업을 자동으로 처리해주는 특수한 포인터라고 볼 수 있다.

레퍼런스 변수

레퍼런스 변수는 반드시 생성하자마자 초기화해주어야한다.

int x = 3;
int& xRef = x;

x변수에 대한 대입문 바로 뒤에 나온 xRef는 x에 대한 또 다른 이름이다. xRef를 사용하는 것은 x를 사용하는 것과 같다. xRef에 어떤 값을 대입하면 x의 값도 바뀐다.

xRef = 10;

레퍼런스 변수를 클래스 밖에서 선언만 하고 초기화하지 않으면 컴파일 에러가 발생한다.

정수 리터럴처럼 이름 없는 값에 대해서는 레퍼런스를 생성할 수 없다. 단, const 값에 대해서는 레퍼런스를 생성할 수 있다.

다음 코드에서는 unnamedRef1을 대입하는 문장에서 컴파일 에러가 발생한다. 상수가 non-const 레퍼런스이기 때문이다, 이는 5라는 상수를 수정하겠다는 뜻이기 때문에 말이 안되는 문장이다. 반면 unnamedRef2는 const 레퍼런스로 선언했기 때문에 문제없이 컴파일 된다.

const로 선언했기 때문에 애초에 unnamedRef2=7처럼 값을 변경할 일이 없기 때문이다.

int& unnamedRef1 = 5;//complie error
const int& unnamedRef2 = 5; // 정상작동

임시 객체도 마찬가지 이다. 임시 객체에 대해 non-const 레퍼런스는 만들 수 없지만 const 레퍼런스는 얼마든지 만들수 있다.

std::string getString(){return "Hello world!";}

std::string& string1 = getString(); // complie error
const std::string& string2 = getString(); //정상작동

getString()을 호출한 결과를 const레퍼런스에는 담을 수 있다. 그러면 이 레퍼런스가 스코프를 벗어나기 전까지 std::string 객체를 계속 가리킬수 있다.

레퍼런스 대상 변경

레퍼런스는 처음 초기화할 때 지정한 변수만 가리킨다. 레퍼런스는 한번 생성되고 나면 가리키는 대상을 바꿀 수 없다.

레퍼런스를 선언할 때 어떤 변수를 대입하면 레퍼런스는 그 변수를 가리킨다. 하지만 이렇게 한번 선언된 레퍼런스에 다른 변수를 대입하면 레퍼런스가 가리키는 대상이 바뀌는 것이 아니라 레퍼런스가 원래 가리키던 변수의 값이 새로 대입한 변수의 값으로 바뀌게 된다.

int x=3, y=4;
int& xRef = x;
xRef = y; // xRef가 y를 가리키는 것이 아니라 x의 값을 4로 변경한다.

여기서 y의 주소를 대입하면 가리키는 대상을 바꿀 수 있다고 생각할 수 있지만 컴파일 에러가 발생한다.

xRef = &y; // compile error!

이렇게 작성하면 컴파일 에러가 발생한다. y의 주소는 포인터지만 xRef는 포인터에 대한 레퍼런스가 아닌 int에 대한 레퍼런스이기 때문이다.

int x = 3, z=5;
int& xRef = x;
int& zRef = z;
zRef = xRef; // 레퍼런스가 아닌 값이 대입된다.

zRef가 가리키는 대상이 바뀌지 않고, z값이 3으로 변경된다.

결론 : 레퍼런스를 초기화 하고 나면 레퍼런스가 가리키는 변수를 변경할 수 없고, 그 변수의 값만 바꿀 수 있다.

포인터에 대한 레퍼런스와 레퍼런스에 대한 포인터

레퍼런스는 모든 타입에 대해 만들 수 있다. 심지어 포인터 타입을 가리키는 레퍼런스도 만들 수 있다.

예를 들어 int 포인터를 가리키는 레퍼런스를 다음과 같이 만들 수 있다

int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;

레퍼런스가 가져온 주소는 그 레퍼런스가 가리키는 변수의 주소와 같다.

int x = 3;
int& xRef = x;
int* xPtr = &xRef;
*xPtr = 100;

cout << xPtr << endl;
cout << *xPtr << endl;
cout << xRef << endl;
cout << &xRef << endl;

//res : 
//00A7F92C
//100
//100
//00A7F92C

이 코드는 x에 대한 레퍼런스의 주소를 가져와서 xPtr가 x를 가리키도록 (xPtr를 x에 대한 포인터로) 설정한다. 그래서 *xPtr에 100을 대입하면 x의 값이 100으로 바뀐다. 그런데 xPtr == xRef 라는 비교 연산을 수행하면 서로 타입이 다르다는 컴파일 에러가 발생한다. xPtr는 int에 대한 포인터 타입이고, xRef는 int에 대한 레퍼런스 타입이기 때문이다. 따라서 xPtr == &xRef나 xPtr == &x와 같이 작성해야한다.

int&&나 int&*와 같이 선언할 수없다.

레퍼런스 데이터 멤버

클래스의 데이터 멤버를 레퍼런스 타입으로 정의할 수 있다. 레퍼런스는 어떤 변수를 가리키지 않고서는 존재할 수 없다. 따라서 레퍼런스 데이터 멤버는 반드시 생성자의 본문이 아닌 생서자 이니셜라이저에서 초기화해야한다.

class MyClass{
public:
	MyClass(int& ref): mRef(ref){}
private:
	int& mRef;
}

레퍼런스 매개변수

레퍼런스 변수나 레퍼런스 데이터 멤버를 별도로 선언해서 사용하는 일은 많지 않다. 레퍼런스는 주로 함수나 메서드의 매개변수로 많이 사용한다.

매개변수는 값 전달 방식을 따르기 때문에 함수는 인수의 복사본을 받는다. 따라서 전달받은 인수를 함수 안에서 수정하더라도 인수의 원본은 변하지 않는다. 하지만 매개변수를 레퍼런스 타입으로 선언하면 인수를 레퍼런스 전달 방식으로 처리한다. 다시말해 매개변수 타입이 레퍼런스로 선언된 함수에 인수를 지정하면 그 인수의 레퍼런스가 함수로 전달된다. 따라서 전달된 값을 수정하면 인수로 지정한 원본 변수의 값도 바뀐다.

void swap(int& first, int& second){
	int temp = first;
	first = second;
	second = temp;
}

int main(){
	int x = 5; int y = 6;
	swap(x,y);
}

포인터를 레퍼런스로 전달하기

매개변수가 레퍼런스 타입인 함수나 메서드에 포인터를 전달하려면 포인터를 역참조해서 전달하면 포인터를 레퍼런스로 변환할 수 있다.

포인터가 가리키는 값을 가져와서 레퍼런스 매개변수를 초기화하기 때문이다.

int x = 5, y = 6;
int* xp = &x; 
int* yp = &y;
swap(*xp, *yp);
cout << x << " " << y << endl;

레퍼런스 전달 방식과 값 전달 방식

레퍼런스 전달 방식은 함수나 메서드 안에서 인수로 전달한 값을 수정하면 그 결과가 원본 변수에도 반영되게 만들고 싶을때 주로 사용한다. 하지만 이 경우 말고도 레퍼런스 전달 방식이 필요할 때가 있다. 레퍼런스로 전달하면 인수에 대한 복제본을 만들지 않기 때문에 두가지 장점이 있다,

  1. 효율성 : 크기가 큰 객체나 struct 는 복제 오버헤드가 크다. 레퍼런스 전달 방식을 사용하면 객체나 struct에 대한 레퍼런스만 함수에 전달한다.
  2. 정확성 : 값 전달 방식을 지원하지 않는 객체가 있다. 지원하더라도 깊은 복제가 적용되지 않을 수 있다. 동적 할당 메모리를 사용하는 객체에 대해서는 반드시 커스텀 복제 생성자와 복제 대입 연산자를 정의해서 깊은 복제를 제공해야한다.

이런 장점을 최대한 활용하고, 원본 객체는 수정할 수 없게 하려면 매개변수를 cosnt 레퍼런스로 선언하면 된다.

레퍼런스 리턴값

함수나 메서드의 리턴 값도 레퍼런스 타입으로 지정할 수 있다. 이렇게하는 주된 이유는 효율성 때문이다. 객체 전체를 리턴하지 않고 객체에 대한 레퍼런스만 리턴하면 복제 연산을 줄일 수 있다. 물론 함수 종료 후에도 계속 남아있는 겍체에 대해서만 이렇게 레퍼런스로 리턴할 수 있다.

함수나 메서드에서 스코프가 그 함수나 메서드로 제한되는 로컬변수는 레퍼런스로 리턴하면 안된다.

rvalue 레퍼런스

lvalue(좌측값, 좌항)는 변수처럼 이름과 주소를 가지면서 대입문의 왼쪽에 나온다. rvalue는 lvalue가 아닌 나머지를 말한다. rvalue의 대표적인 예로 상숫값과 임시 객체가 있다.

일반적으로 rvlaue는 대입 연산자의 오른쪽에 나온다.

void handleMessage(std::string& message){
	cout<<"handleMessage with lvalue reference : "<< message << endl;
}

handleMassgae()는 매개변수를 lvalue 레퍼런스로 정의했다. 그래서 다음과 같이 호출할 수 없다.

handleMessage("Hello World");

std::string a = "Hello";
std::string b = "World";
handleMessage(a+b); //임시값은 lvalue가 아니다.
//다음과 같이 호출하려면 
//이렇게 구현해야한다.
void handleMessage(std::string&& message){
	cout<<"handleMessage with rvalue reference : "<<message<<endl;
}

레퍼런스와 포인터의 선택기준

레퍼런스로 할수 있는 일을 모두 포인터로 처리할 수 있으니 c++에서 굳이 레퍼런스를 제공할 이유가 없다고 생각할 수도 있다. 예를 들어 앞에서 본 swap()을 다음과 같이 포인터로 구현해도된다.

void swap(int* first, int* second){
	int temp = *first;
	*first = *second;
	*second = temp;
}

하지만 이렇게 하면 코드가 복잡해진다.

레퍼런스를 사용하면 코드를 깔끔하고 읽기 쉽게 작성할 수 있다. 게다가 포인터보다 훨씬 안전하다. 레퍼런스의 값은 널이 될 수 없고, 레퍼런스를 명시적으로 역참조할 수 없다. 그래서 포인터처럼 역참조 과정에서 에러가 발생할 가능성도 없다. 단, 포인터가 하나도 없을 때만 레퍼런스가 더 안전하다고 말할 수 있다.

void refcall(int& t){++t;}

임의의 메모리를 가리키도록 초기화한 포인터를 하나만들자 그러고 나서 다음 코드처럼 이 포인터를 역참조해서 refcall()의 레퍼런스 타입 인수로 전달해보자 그러면 컴파일 에러는 발생하지 않지만 실행과정에서 무슨일이 벌어질지 예측할 수 없다.

int* ptr = (int*)8;
refcall(*ptr);

포인터를 사용한 코드는 거의 대부분 레퍼런스로 표현할 수 있다. 심지어 객체에 대한 레퍼런스는 객체에 대한 포인터처럼 다형성도 지원한다. 하지만 반드시 포인터를 사용해야하는 경우가 있다.

대표적인 예로 가리키는 위치를 변경해야할 때가 있다. 앞에서 설명했듯이 레퍼런스 타입 변수는 한번 초기화되고 나면 그 변수가 가리키는 주솟값을 바꿀 수 없다.

예를들어 동적할당 메모리의 주소는 레퍼런스가 아닌 포인터에 저장해야한다. 또 다른 예로 주솟값이 nullptr이 될 수도 있는 optional타입은 반드시 포인터를 사용해야한다. 또한 컨테이너에 다형성 타입을 저장할 때도 포인터를 사용해야한다.

매개변수나 리턴값을 포인터와 레퍼런스 중 어느것으로 표현하는 것이 적합한지 판단하는 한가지 방법은 메모리의 소유권이 어디에 있는지 따져보는 것이다. 메모리의 소유권이 변수를 받는 코드에 있으면 객체에 대한 메모리를 해제하는 책임은 그 코드에있다. 따라서 객체를 스마트 포인터로표현한다. 소유권을 이전할 필요가 있다면 항상 스마트 포인터를 사용하는 것이 좋다. 반면 메모리 소유권이 변수를 받는 코드에 없어서 메모리를 해제할 일이 없다면 레퍼런스로 전달한다.

int 타입 배열을 두개(하나는 짝수, 하나는 홀수)로 나누는 함수가 있다고 가정하자. 이 함수는 원본 배열에 있는 원소의 개수가 짝수인지 홀수인지 모른다고 하자. 그러므로 원본 배열을 살펴보고 배열을 나누는 데 필요한 만큼의 메모리를 동적으로 할당해야한다. 이때 새로 만든 두 배열의 크기도 리턴하려 한다. 그러면 새로 만든 두 배열에 대한 포인터와 각각의 크기등 총 내가지 값을 리턴해야한다. 이럴때는 당연히 레퍼런스로 전달해야한다.

void seperateOddsAndEvens(const int arr[], size_t size, int** odds, size_t* numOdds, int** evems, size_t* numEvens){
	//짝수와 홀수의 개수를 센다.
	*numOdds = *numEvens = 0;
	for(size_t i = 0; i<size; ++i){
		if(arr[i]%2 == 1){
			++(*numOdds);
		}
		else{
			++(*numEvens);
		}
	}
	//새로 만들 두 배열의 크기에 맞게 공간 할당
	*odds = new int[*numOdds];
	*evens = new int[*numEvens];
	
	//원본 배열에 담긴 홀수와 짝수 원소를 새로 만들 배열에 복사
	size_t oddsPos = 0, evenPos = 0;
	for(size_t i = 0; i < size; ++i){
		if(arr[i]%2==1){
			(*odds)[oddsPos++] = arr[i];
		}
		else{
			(*evens)[evenPos++] = arr[i];
		}
	}
	
}

함수에 최종적으로 전달할 네 개의 매개변수는 모두 레퍼런스다. 그러므로 이 변수가 가리키는 값을 변경하려면 seperateOddsAndEvens() 본문 안에서 매개변수를 역참조해야하는데, 그러면 코드가 지저분해진다. 또한 sperateOddsAndEvens()를 호출할 ㄸ 포인터 두개와 두 int 값에 대한 주소 두개도 함께 전달해야한다. 그래야 함수 안에서 두 포인터와 두 int 값 주소가 가리키는 값을 변경할 수 있다. 또한 sperateOddsAndEvens()로 생성한 두 배열을 삭제하는 작업도 이 함수를 호출한 코드에서 처리해야한다.

int unSplit[] = {1,2,3,4,5,6,7,8,9,10};
int* oddNums = nullptr;
int* evenNums = nullptr;
size_t numOdds = 0, numEvens = 0;
sperateOddsAndEvens(unSplit, std::size(unSplit), &oddNums, &numOdds, &evenNums, &numEvens);

delete[] oddNums; oddNums = nullptr;
delete[] evenNums; evenNums = nullptr;

코드가 지저분해지는 것이 싫다면 다음과 같이 레퍼런스 전달 방식으로 구현한다.

void separateOddsAndEvens(const int arr[], size_t size, int*& odds, size_t& numOdds, int*& evens, size_t& numEvens){
	numOdds = numEvens = 0;
	for(size_t i=0; i<size; ++i){
		if(arr[i]%2 == 1){
			++numOdds;
		}
		else{
			++numEvens;
		}
	}
	
	odds = new int[numOdds];
	evens = new int[numEvens];
	
	size_t oddsPos =0, evenPos = 0;
	for(size_t i=0; i<size; ++i){
		if(arr[i]%2==1){
			odds[oddsPos++] = arr[i];
		}
		else{
			evens[evenPos++] = arr[i];
		}
	}
}

이렇게 하면 int 나 포인터의 주소를 넘기지 않아도 레퍼런스 매개변수에 의해 자동으로 전달된다.

separateOddsAndEvens(unSplit, std::size(unSplit), oddNums, numOdds, evenNums, numEvens);

표준라이브러리에서 제공하는 vector 컨테이너를 사용하면 앞서 구현한 separateOddsAndEvens()를 훨씬 안전하고, 이해하기 쉽게 구현할 수 있다.

메모리 할당 및 해제 작업을 컨테이너가 자동으로 처리해주기 때문이다.

void separateOddsAndEvens(const vector<int>& arr, vector<int>& odds, vector<int>& evens){
	for(int i:arr){
		if(i%2 ==1){
			odds.push_back(i);
		}
		else{
			evens.push_back(i);
		}
	}
}

이렇게 하면 다음과 같이 호출할 수 있다.

vector<int> vecUnSplit = {1,2,3,4,5,6,7,8,9,10};
vector<int> odds, evens;
separateOddsAndEvens(vecUnSplit, odds, evens);

이렇게 컨테이너로 구현하면 odds와 evens를 명시적으로 해제할 필요가 없다. vector 클래스가 알아서 처리해준다. 그래서 포인터나 레퍼런스로 구현한 버전보다 훨씬 사용하기 쉽다.

이렇게 vector를 사용하도록 수정한 버전은 분명히 포인터나 레퍼런스로 구현한 버전보다 훨씬 낫다. 하지만 여기서 구현한 것처럼 결과를 매개변수로 전달하는 방식은 가급적 사용하지 않는 것이 좋다. 함수가 어떤 값을 리턴해야한다면 출력 매개변수가 아닌 리턴문을 사용한다. c++11부터 추가된 이동 의미론을 적용하면 값으로 리턴해도 효율적으로 처리할 수 있다

그리고 c++17에 도입된 구조적 ㅂ바인딩을 사용하면 함수에서 여러 값을 리턴하는 과정을 정말 간단히 구현할 수 있다.

따라서 separateOddsAndEvens() 함수에 vector 타입의 두 개의 출력 매개변수를 정의하지 말고, 그냥 두 vector를 묶은 페어(pair) 하나만 리턴하게 만드는 것이 바람직하다. std::pair 유틸리티 클래스는 <utility>헤더에 나와있다.

pair는 std::make_pair()로 생성한다.

pair<vector<int>, vector<int>> separateOddsAndEvens(const vector<int>& arr){
	vector<int> odds, evens;
	for(int i : arr){
		if(i%2 == 1){
			odds.push_back(i);
		}
		else{
			evens.push_back(i);
		}
	}
	return make_pair(odds, evens);
}

int main(){
 vector<int> vecUnSplit = 1,2,3,4,5,6,7,8,9,10};
 auto [odds, evens] = separateOddsAndEvens(vecUnSplit);
 for (auto i : odds) {
		cout << i << endl;
 }
}

 

참조 : 전문가를 위한 c++

'C++ STL, 알고리즘' 카테고리의 다른 글

구조적 바인딩 c++  (0) 2024.10.14
파라미터 참조형 vs 포인터  (0) 2024.10.14
c++ 구조적 바인딩  (0) 2024.10.07
++i vs i++ 누가 일반적인가.  (0) 2024.10.07
Optional Type  (0) 2024.10.07