본문 바로가기
C++

[C++] 포인터와 참조자, Call by Value와 Call by Reference

by 망고먹는 개발자 2021. 9. 8.

1. 포인터

메모리는 연속적인 주소를 가지며 모든 변수는 메모리 상에 존재한다.

C++를 사용해 변수를 선언하면 운영체제는 런타임에 적당한 위치의 메모리를 할당해서 내어주게 된다.

포인터란 이렇게 값이 아닌 주소를 저장하는 변수를 의미한다.

 

포인터를 사용하면 동적으로 결정된 메모리의 주소를 직접 다룰 수 있으며 때로는 임의의 메모리에 접근할 수도 있다.

 

프로그래머가 직접 주소를 다루게 되면 프로그램 작성의 복잡도가 증가하고 실수가 늘어나지만, 그만큼 프로그램을 더 효율적으로 작성하는데 도움을 주는 경우가 많다.

2. 주소 연산자(&)와 역참조 연산자(*)

int value = 3;


//*를 변수를 선언할 때 타입 뒤(혹은 변수명 앞)에 붙여서 주소 타입의 변수로 지정 
int *ptr = &value; //&는 변수 앞에 붙여서 해당 변수의 주소를 가져옴


//*를 주소값 변수 앞에 붙여서 해당 주소에 존재하는 변수에 접근
*ptr = 4;

*와 &는 각각 단항 연산자로서 다음과 같은 역할을 한다.

주소 연산자 (address-of operator)

&value

value 변수의 주소를 의미한다.

&연산자는 변수의 주소를 가져오는데 사용된다.

역참조 연산자 (dereference operator)

*ptr

주소값인 ptr 변수의 주소에 존재하는 value 값에 접근할 수 있다.

 

ptr는 &value와 같고 *ptr는 value와 같다.

3. 포인터의 선언

int *ptr;

*를 변수의 선언시 사용하면 일반적인 값이 아닌 주소를 가지는 포인터로 선언할 수 있다. 

 

포인터의 *연산자가 처음엔 헤깔릴 수 있는데 크게 변수의 선언시에 사용되었을 때와, 

이미 선언된 주소값 변수앞에 붙어서 단항 연산자로 사용되었을 때가 있다.

 

변수의 선언시에 사용되었을 때에는 해당 타입의 주소값을 선언하는 역할을 하고,

포인터 변수 앞에 붙었을 때에는 해당 주소값 변수에 기록된 주소에 존재하는 변수에 접근하는 역할을 한다.

 

두 경우를 잘 구별해서 생각하면 쉽게 이해할 수 있다.

4. const와 포인터

const 값에 대한 포인터

지금까지의 포인터는 non-const 값에 대한 포인터이다.

만약 const 값에 대한 포인터를 다음과 같이 선언하면 컴파일되지 않는다.

const int value = 3;
int *ptr = &value; // compile error: cannot convert const int* to int*

const 값에 대한 포인터를 선언하려면 다음과 같이 데이터 타입 앞에 const 키워드를 사용해야한다.

const int *ptr = &value;

const 변수에 대한 포인터는 non-const 변수를 가리킬 수는 있지만, const 변수에 대한 포인터는 자신이 가리키는 변수를 const로 여기기 때문에 그 포인터를 통해 해당 변수를 수정할 수 없다.

int value1 = 3;
const int *ptr = &value1;
value1 = 4;
*ptr = 5; // compile error: expression must be a modifiable lvalue


int value2 = 6;
ptr = &value2; // 다른 const 값을 가리키는 것은 가능

const 포인터

const 변수와 같이 초기화 후에 변경할 수 없는 const 포인터를 선언하는 것 또한 가능하다.

int value = 2;
int *const ptr = &value;

별표와 포인터 이름 사이에 const 키워드를 사용하면 된다.

 

이 const 포인터는 초기화 이후에 다른 변수의 포인터로 변경될 수는 없지만, 가리키고 있는 값은 non-const이므로 const 포인터를 이용해 가리키고 있는 값을 수정할 수 있다

int value1 = 3;
int *const ptr = &value1;
*ptr = 5; // 가능


int value2 = 6;
ptr = &value2; // compile error : const인 값에 할당하는 것은 불가능

const 값을 가리키는 const 포인터

int value = 3;
const int *const ptr = &value;

const 값을 가리키는 const 포인터는 초기화 이후에 다른 변수를 가리키도록 변경할 수 없고, 가리키는 값을 포인터를 통해 변경할 수도 없다.

5. 참조자

참조자는 이미 존재하는 변수에 할당된 메모리 공간을 접근할 수 있는 또 다른 이름(별칭)을 뜻한다. 

int value = 3;


// ref1은 value의 참조자이다
// non-const 참조자는 non-const l-value로만 초기화 가능하다
int &ref1 = value;
// 참조자는 개수 제한이 없다
int &ref2 = value;
// 참조자를 통해 다시 참조자를 선언할 수 있다
int &ref3 = ref1;
    
cout << value << endl; // 3
cout << ref1 << endl; // 3
cout << ref3 << endl; // 3


ref2 = 4;


cout << value << endl; // 4


ref3 = 1;


cout << value << endl; // 1


ref1++;


cout << value << endl; // 2

non-const 참조자는 non-const l-value로만 초기화 가능하다

int value = 3;
int &ref1 = value;


ref1 = another; // 참조자는 초기화 이후 다른 변수를 참조하도록 만들 수 없다.
// another의 값이 복사되어 들어감


int &ref2; // 빌드 실패 - 참조자는 선언과 동시에 초기화 되어야 함


int &ref3 = 3; // 빌드 실패 - literal 상수(r-value)로 초기화 불가능
int &ref4 = null; // 빌드 실패 - 참조자는 null로 초기화할 수 없음


const int num = 1;
int &ref5 = num; // 빌드 실패 - const 상수(const l-value)로 초기화 불가능

6. const 참조자

const 값에 대한 참조자

non-const 참조자는 non-const l-value로만 초기화 할 수 있다.

const l-value로 초기화하기 위해서는 const 키워드로 const 값에 대한 참조자라고 명시해주면 된다.

const int value = 3;
const int &ref = value;

const 값에 대한 포인터와 같이 이 const 값에 대한 참조자를 통해서는 값을 변경할 수 없다.

 

const 값에 대한 참조자는 literal 상수(r-value)로 초기화하는 것도 가능하다.

literal 상수는 임시로 존재하며 해당 코드 행이 넘어가면 소멸된다.

하지만 const 값에 대한 참조자가 literal 상수를 참조하면 C++에서는 literal 상수를 저장하는 임시 변수를 만들고 그 참조자가 이를 참조하도록 한다. 

const int &ref = 3;

덕분에 함수의 매개변수를 const 값에 대한 참조자로 지정했을때에도 literal 상수 혹은 표현식을 전달할 수 있다.

void foo(const int& value);


foo(3);

7. 매개변수의 전달

함수의 매개변수를 전달하는 데에는 여러가지 방법이 있다.

Call by Value와 Call by Reference라는 용어로 부르는데, 최근에는 인자를 전달한다는 관점에서 더 명확하게 Passing by Value, Passing by Reference, Passing by Address라고 부르는 듯 하다.

 

swap 함수를 통한 비교가 가장 대표적이다.

// Passing by Value
void swap(int a, int b)
{
    int tmp = a;
    a = b;
    b = tmp;
}


// Passing by Reference
void swap(int& a, int& b) 
{
    int tmp = a;
    a = b;
    b = tmp;
}


// Passing by Address
void swap(int* a, int* b) 
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

Passing by Value는 함수가 호출될 때 매개변수로 전달된 값을 복사 생성자를 이용해 통째로 복사해 사용한다. (또한 함수의 scope가 끝나면 복사 소멸자가 불린다) 그래서 함수를 호출한 함수 외부의 원본 값에 영향을 줄 수 없다. 

 

또한 새로운 변수가 통째로 복사되기 때문에 비용이 크다. Derived 타입의 객체가 Base 타입으로 전달될 때 부모 타입의 복사 생성자가 호출되면 Derived 객체의 정보들이 사라지는 Slicing Problem(복사 손실) 문제가 발생하기도 한다.

 

Passing by Reference는 함수를 호출할 때 인자를 참조자로 선언하므로 복사본이 생기지 않고(복사 생성자와 복사 소멸자가 호출되지 않음), 원본을 직접 참조하게 되어 함수 외부의 원본 값에 직접 영향을 준다.

하지만 이는 원본에 무분별하게 영향을 줄 수 있다는 점에서 우려가 생길 수 있는데, const 변수에 대한 참조자로 인자를 선언하면 함수 내에서 원본을 수정할 수 없도록 막을 수 있다.

void foo(const int& value)
{
    value = 3; // compile error : const인 변수에 할당할 수 없음
}

이는 이펙티브 C++에서도 확인할 수 있다 (20. 값에 의한 전달보다는 상수 객체 참조자에 의한 전달 방식을 택하는 편이 대게 낫다.)

 

Passing by Address는 주소값을 직접 전달함으로서 해당 포인터가 가리키는 변수의 복사를 막는다는 면에서 Call by Reference라고 설명하는 곳도 있고, 포인터 값을 복사해서 전달하고 그 주소값을 이용하지만 결국 주소'값'의 복사일 뿐이라는 점에서 Call by Value라고 설명하는 곳도 있는 듯 하다.

 

어쨌든 Passing by Value를 사용하면 복사 생성자와 복사 소멸자가 호출된다는 것과 복사 손실 문제가 일어날 수 있다는 점을 잘 알고 있어야 할 것이다. (custom 객체를 함수 인자로 사용할 때에는 언제나 Passing by Reference를 사용하자.)

반응형

댓글