C++은 할건데, C랑 다른 것만 합니다. 1편 참조자

0

C++

목록 보기
1/10

참조자

1. C언어에서의 swap

https://modoocode.com/141

본 글은 위 글을 정리일 뿐이다.

C언어에서는 함수에서 매개변수로 넘어온 변수의 값을 바꾸기 위해서는 pointer를 사용해야만 했다. 그러나, 이는 코드에 asterisk(*)가 너무 많아지고, 어디서 어떻게 변했는 지 알기 어렵게 했다는 단점이 있다.

이를 보완하여 c++에서는 참조자가 나왔는데, pointer 처럼 값을 바꿀 수는 있지만 asterisk는 사용하지 않는 특이한 문법이다.

#include <iostream>

using namespace std;

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

int main(){
    int first = 1;
    int second = 2;
    cout << "before changed : " << first << " " << second << endl;
    swap(&first, &second);
    cout << "after changed : " << first << " " << second << endl; 
}
before changed : 1 2
after changed : 2 1

다음의 코드는 cpp파일이지만 c언어의 포인터 문법을 이용한 swap이다.

c++에서는 c언어의 포인터를 사용하지 않고도 swap을 구현할 수 있다.

이는 참조를 사용하면 가능하다.

2. 참조자란(reference)

변수나 상수를 가리키는 또 다른 방법으로 변수의 타입에 &을 사용한다. 참조자는 변수의 원래 이름과는 다른 이름이지만 동일한 주소를 참조하고 있다고 생각하면 된다.

  • 사용방법
int &value2 = value1;

다음과 같이 &를 사용하여 참조자를 선언하고, 다른 변수를 넣어준다.

  • 예제
#include <iostream>

using namespace std;

int main(){
    int value1 = 10;
    int& value2 = value1;
    cout << value1 << " " << value2 << endl;
    value2 = 20;
    cout << value2 << " " << value1 << endl;
}
10 10
20 20

예제를 보면 value2는 value1의 참조자이다. 다만, 참조자인 value2가 int 형이기 때문에 들어가는 변수도 int형이어야 한다.

만약 double값을 받는 참조형이라면 double& 로 만들고, int*를 값으로 받는 참조형이라면, int*&로 만든다.

참조형은 포인터와는 다르게, 타입이 같다. 만약 포인터의 경우는 int 값을 받으려면 int의 주소 값을 받기위해 int*라는 타입을 가져야 한다.

그러나 참조형은 int 타입이지만 참조한다는 의미에 &만 붙여주면 된다.

이와 같은 특징으로 참조형은 어떤 변수의 별명이라고 불린다.

즉, 위 예제에서 value2는 value1의 별명을 일뿐이다. value2는 value1의 또 다른 이름이라고 컴파일러에게 알려주는 것일 뿐이다.

따라서, value1에서 값을 변경하는 것과 value2에서 값을 변경하는 것은 같은 결과이다.

3. 참조자의 특징

참조자의 특징은 다음과 같다.

  1. 참조자는 변수와 타입이 같아야 한다.
  2. 참조자 정의 시에는 반드시 누구의 별명인지 명시해야 한다.

이는 선언과 동시에 초기화까지 이루어져야 한다는 특징이 있다는 것이다.

int& value2; // error

다음의 코드는 에러가 발생하게 된다.

  1. 참조자가 한 번 다른 변수의 별명이 되면 절대로 다른 이의 별명이 될 수 없다.
int value1 = 10;
int &value2 = value1;
int value3 = 20;
value2 = value3; // value1 = value3

위 예제에서 value2는 value1의 별명이 된다. 이제 value2는 영원히 value1의 별명이다.

value2 = value3을 한다고, value3의 별명이되는 것이 아니다. 그냥 value1에 value3의 값이 들어가 20이 될 뿐이다.

이와 달리 포인터는 하나의 변수로서 그안에 들어가는 주소를 바꿀 수 있어 얼마든지 다른 값을 참조할 수 있다.

  1. 참조자는 메모리 상에 존재하지 않을 수도, 있을 수도 있다.

포인터는 메모리 주소를 참조하기 때문에 8byte size를 갖는다.

그러나, 참조자는 메모리 공간을 갖지 않을 수 있는데, 가장 큰 이유는 '별명'이기 떄문이다.

int value1 = 1;
int value2 = value1;

위 코드에서 컴파일러는 value2를 보면, value2의 별명이겠거니 생각하면서, 런타임 시작 전에 value2의 자리를 value1으로 바꾸어 주면 된다. 이와 같은 경우에는 참조자인 value2가 메모리 공간을 갖지 않아도 된다.

#include <iostream>

using namespace std;

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

int main(){
    int value1 = 10;
    int value2 = 20;
    cout << value1 << " " << value2 << endl;
    swap(value1, value2);
    cout << value1 << " " << value2 << endl;
}
10 20
20 10

다음과 같이 first, second 참조자에 값이 value1과 value2로 들어가는 것을 볼 수 있다.

  1. 참조자의 참조자는 만들 수 없다.

참조자는 별명인데, 별명의 별명은 없다. 따라서, 참조자의 참조자는 없다.

int value1 = 1;
int& value2 = value1;
int&& value3 = value2; // 불가
  1. 참조자는 상수 리터럴을 받지 못한다.

다음의 예제를 보자

int &value1 = 10;

다음과 같은 예제는 error가 발생한다. 상수값 자체는 리터럴(리터럴은 한번 값이 설정되면 바뀌지 않는다.) 이기 때문에 c++문법 상 상수 리터럴을 빌반적인 레퍼런스가 참조하는 것은 불가능하다.

대신, const를 사용하면 된다.

const int& value1 = 10;

상수 참조자로 선언하면 리터럴도 참조할 수 있다. 이는 리터럴이 상수의 측면을 갖고 있을 수 있다고 볼 수 있다.

  1. 참조자의 포인터, 참조자의 배열, 참조자의 참조자는 불가능하다.

참조자의 참조자가 안되는 이유는 이전에 이미 언급하였다.

왜 포인터의 참조자와 배열의 참조자는 안될까?? 이유는 참조자가 언제나 메모리 공간을 갖고 있는게 아니기 때문이다.

int& arr[2] = {a,b};

다음과 같이 참조자를 담는 배열이 불가능하다는 것이다.

즉, 포인터나 배열의 원소 같은 경우는 언제나 주소를 요구한다. 즉, 배열 arr의 1번째 원소는 *(arr+1)과 같다. arr주소에 type 한개의 크기만큼 주소를 갖는게 arr+1 이기 때문이다.

참조자는 메모리를 가질 수도 있고, 안가질수도 있다. 이는 참조자가 자신이 참조하고 있는 변수의 주소를 주거나, 또 다른 경우는 자신의 주소를 준다는 말이다.

즉, 이는 참조자가 주소를 갖지 않을 수 있기 때문에 주소를 가져야만 하는 포인터와 배열은 모순이 발생한다. 이에 따라 참조자의 배열, 포인터를 막아둔 것이다.

다만, 참조자의 주소에 접근하는 것은 가능하다. 이 주소가 참조자의 주소인지, 아니면 참조자가 참조하는 변수의 주소인지는 다를 수 있다.

  1. 단, 배열들의 참조자는 가능하다.

배열을 참조하는 것이 배열 참조자이다.

type (&ref)[size] = arr;

와 같이 쓴다.

#include <iostream>

using namespace std;

int main(){
    int arr[3] = {1,2,3};
    int(&ref)[3] = arr;
    ref[0] = 10;
    ref[1] = 20;
    ref[2] = 323;
    cout << ref[0] <<' '<< ref[1]<<' '<< ref[2] << endl;
}
10 20 323

조금 사용하기 어려울 뿐이다.

4. Dangling 참조자

만약, 함수에서 참조자를 반환하는데, 지역변수를 반환하면 어떻게 될까??

#include <iostream>

using namespace std;

int& returnRef(){
    int a = 10;
    return a;
}

int main(){
    int& ret = returnRef();
    cout << ret;
}

Segmentation fault가 나올 것이다.

단순한 문제인데, 함수인 returnRef가 실행 스택에서 사라져 지역 변수인 a를 해제하면 return되는 값인 a가 사라지기 때문이다.

즉, ret 참조자는 아무것도 참조하는게 없는 것이다. 이를 댕글링 레퍼런스(Dangling Reference)라고 한다.

Dangling이란 달랑달랑 거리는 모습으로, 참조할 변수가 없어 달랑달랑거린다는 것을 의미한다.

따라서, 지역변수를 참조자 변수로 반환하는 일이 없도록 해야한다.

하지만 외부 변수(파라미터)로 받은 변수를 참조자로 반환하는 것은 가능하다.

#include <iostream>

using namespace std;

int& returnRef(int& a){
    a = 5;
    return a;
}

int main(){
    int a = 10;
    int& ret = returnRef(a);
    cout << ret;
}
5

위 예제가 아까와 다른 것은 실행 스택에서 returnRef가 사라져도 파라미터로 받은 변수 a는 전역에 있기 때문에 사라지지 않는다는 것이다. 따라서 dangling 참조자 이슈가 발생하지 않는다.

물론 이와같은 경우는 굳이 리턴하지 않아도 변수의 값이 달라진다.

4.1 예외

그런데 이러한 Dangling 참조자에도 예외가 하나 있다. 위에서 함수의 지역 변수를 참조할 경우에 문제가 생긴다고 했다.

#include <iostream>

using namespace std;

int returnRef(){
    int a = 5;
    return a;
}

int main(){
    int& ret = returnRef();
    cout << ret;
}

다음은 위의 예제와는 조금 다르게 참조자를 반환하는 것이 아니라, 값을 반환했다. 그러나 어찌됐든 간에 지역 변수를 반환하는 것은 같다.

그런데 이번에는 컴파일 에러가 발생하고 재밌게도 const를 붙여라 라고 나온다.

#include <iostream>

using namespace std;

int returnRef(){
    int a = 5;
    return a;
}

int main(){
    const int& ret = returnRef();
    cout << ret;
}
5

그리고 정말 신기하게도 5가 나온다.

어떻게 이런일이 가능할까?? 이는 예외적으로 상수 참조자로 리턴값을 받게 되면 해당 리턴값의 생명이 연장되기 때문이다. 그 리턴값의 생명은 참조자가 사라지기 전까지가 된다.

5. const reference( 상수 참조 )

함수의 선언부분을 볼 때 다음과 같이 사용하는 경우들을 보았을 것이다.

int calc(const int &a ){
    if( a < 10) return 1;
    return 0;
}

여기서 calc 함수의 매개변수를 int a로 하는 것과 무엇이 다를까??

https://stackoverflow.com/questions/2627166/difference-between-const-reference-and-normal-parameter

다음의 답변을 참조하였다.

차이는 새로운 객체(또는 값)을 생성할 필요가 없다는 것이다. 함수에 구조체나 객체를 넘기면 가장 큰 문제는, 내부의 값을 모두 복사하여 넘기기 때문에 비용이 만만치 않다는 것이다.

하지만 참조자를 사용하면 단순히 참조하고 있는 걸로 동치셔켜 넘기는 것과 같기 때문에 연산 비용이 매우 줄어들게 된다.

또한 const를 사용하면 참조자의 경우에는 참조하고 있는 값을 바꿀 수 없다.

가령 다음과 같다.

#include <iostream>

using namespace std;

typedef struct Node{
    int a;
    int b;
    Node(int a , int b) : a(a), b(b) {};
};

int calc(const Node &node ){
    node.a = 10; // 불가
    node.b = 20; // 불가
    return 0;
}

int main(){
    Node node = Node(1,2);
    calc(node)
}

calc의 매개변수로 들어간 node의 값을 변경할 수 없다. 이유는 const이기 때문이다.

즉, 포인터 상수마냥 내부의 값을 바꿀 수 없다. 또한, 참조자 특성상 처음 부여 받은 값을 변경할 수 없기 때문에 다른 참조값을 넣어도 에러가 발생한다.

이에 따라, 포인터 상수, 상수 포인터의 두 이점을 모두 가져간다고 볼 수 있다.

0개의 댓글