[C++] #2: namespace, reference(참조자, Dangling reference)

Programming/C++ · 2020. 8. 13. 09:20

 


1. namespace에 대한 이해


#include <iostream> 

int main() {   
   std::cout << "Hello, World!!" << std::endl;   
   return 0; 
}

일단 제일 첫줄에 iostream이라는 헤더파일을 include하고 있습니다.

 

이는 C에서 stdio.h와 비슷한 역할을 합니다.

 

C++의 표준 입출력에 관한 내용을 담고 있습니다. (std::cout, std::endl)

 

1) std::cout  // <<과 <<사이의 내용을 화면 출력 (C: printf와 비슷)

2) std::endl  // 한 줄 엔터를 하나 출력해 주는 함수

 

간략하게 설명하면 위와 같은 기능을 가지고 있습니다.

 

자세한 내용은 차후에 다루겠습니다.

 

이부분에서 C와 가장 큰 차이점은 'std' 입니다.

 

std는 C++ 표준 라이브러리의 모든 함수, 객체 등이 정의된 이름 공간 (namespace)입니다.

 

namespace는 각 함수와 객체에 주인을 정해주는 것과 같습니다.

 

예제로 확인해 보겠습니다.

 

#include <iostream> 

namespace header1 {
int foo();
void bar();
}

namespace header2 {
int foo();
void bar();
}

int main() {
   header1::foo();
   header2::foo();
   // namespace에 따라 각 namespace에 정의된 foo()가 실행됨
   
   // foo(); // namespace가 지정되지 않으면 오류
      
   return 0; 
}

 

위와같이 header1과 header2에서 동일한 함수명을 가진 foo()를 사용한다고 했을 때,

 

namespace가 지정됨에 따라 foo는 각 namespace에 해당하는 함수의 내용을 따릅니다.

 

매번 namespace를 작성하는 것이 번거롭기 때문에 다음과 같은 방법을 사용하기도 합니다.

 

#include <iostream> 

namespace header1 {
int foo();
void bar();
}

namespace header2 {
int foo();
void bar();
}

using namespace header1;

int main() {
   foo();
   bar();
   // 두 함수 모두 header1에 있는 foo와 bar 함수가 실행된다.
   
   header2::foo();
   // 지정되지 않은 namespace는 이와같이 사용
   
   return 0; 
}

 

'using namespace'를 사용하여 해당 코드에서 사용할 namespace를 지정할 수 있습니다.

 

다른 namespace에 있는 내용은 앞선 예제와 같이 'namespace명::'을 붙여주시면 됩니다.

 

namespace 내의 특정 함수나 객체를 지정하는 경우도 있습니다.

 

#include <iostream> 

namespace header1 {
int foo();
void bar();
}

namespace header2 {
int foo();
void bar();
}

using header1::foo;

int main() {
   foo();
   // bar(); // error
   header1::bar();
   // foo()만 namespace 생략 가능
   
   header2::foo();
   // 지정되지 않은 namespace는 이와같이 사용
   
   return 0; 
}

 

using 'namespace 명'::'사용할 함수 또는 객체 명'을 추가하면 됩니다.

 

어느 하나의 namespace 안에서 다른 namespace를 사용하고자 하는 경우에도 main()과 마찬가지로

 

'namespace 명'::'사용할 함수 또는 객체 명'을 써주시면 됩니다. (아래 예제)

 

#include <iostream> 

namespace header1 {
int foo();
void bar();

header2::foo();
// header2의 foo()가 실행
}

namespace header2 {
int foo();
void bar();
}

 

일반적으로 namespace를 지정하지 않는 경우 'using namespace std'를 써서 사용합니다.

 

#include <iostream> 

int main() {   
   std::cout << "Hello, World!!" << std::endl;   
   return 0; 
}

 

마지막으로 이름이 없는 namespace를 만들 수 있습니다.

 

이러한 경우는 마치 'static' 키워드를 사용한 것과 같은 효과를 보입니다.

 

해당 이름 공간에 정의된 것들은 해당 파일 안에서만 접근할 수 있습니다.

 

#include <iostream>

namespace {
// 이 함수는 이 파일 안에서만 사용할 수 있습니다.
// 이는 마치 static int OnlyInThisFile() 과 동일합니다.
int OnlyInThisFile() {}

// 이 변수 역시 static int x 와 동일합니다.
int only_in_this_file = 0;
}  // namespace

int main() {
  OnlyInThisFile();
  only_in_this_file = 3;
}

 

헤더파일을 통해 위와 같은 파일을 받더라도 익명의 namespace 안에 정의된 모든 것들을 사용할 수 없습니다. (main 함수에선 사용할 수 있다. --> 같은 파일에 있으니까!)

 


2. 입출력


참고: https://m.blog.naver.com/PostView.nhn?blogId=lyw94k&logNo=220853743268&proxyReferer=https:%2F%2Fwww.google.com%2F

 

나중에 다시 하겠지만 그전까지 많이 사용하기 때문에 간단하게 짚고 넘어가겠습니다.

 

표준 입출력 함수 (iostream을 추가하면 사용할 수 있는)는 C++에서 입력, 출력을 할 수 있는 함수 중 표준으로 정해진 것입니다.

 

표준 입출력 함수인 cin과 cout 두 함수는 콘솔 입출력 명령이고, 위에서 말했던 것 처럼 iostream의 std 안에 존재합니다.

 

항상 std::를 붙여줘야하는 번거로움이 있기 때문에 'using namespace std' 혹은 'using std::cin', 'using std::cout'과 같이 선언하고 사용할 수 있습니다.

 

안정성 측면에서 후자를 추천한다고 합니다.

 

1) cin

 

cin은 console input의 약자입니다.

 

>>를 사용하여 다음에 오는 변수에 사용자가 콘솔 창에서 입력한 값을 대입해줍니다.

 

또한, 입력 버퍼를 사용하기 때문에 입력 후 엔터를 눌러야 입력을 종료할 수 있습니다.

 

2) cout

 

cout은 console output의 약자입니다.

 

<<를 사용해서 다음에 오는 데이터를 축력합니다.

 

다음에 올 수 있는 것은 데이터 자체거나 데이터를 가지고 있는 변수 또한 포함됩니다.

 

<< 다음의 데이터가 출력된 이후 줄바꿈을 하고 싶을 때 endl을 써주시면 됩니다.

 

cout << "~~~" << endl;

 

위와 같이 사용할 수 있습니다.

 

아래와 같은 간단한 예제를 실행 해보시길 바랍니다.

 

#include <iostream>


using std::cout;
using std::cin;
using std::endl;

int a;

int main() {
  cin >> a;
  cout << a << endl;
}

 


3. 참조자의 도입


 

참조자는 C++에서 새롭게 도입된 개념입니다.

 

쉽게 말하면 name tag 혹은 nickname을 달아주는 것이라고 보면 됩니다.

 

#include <iostream>

int change_val(int *p) {
  *p = 3;

  return 0;
}
int main() {
  int number = 5;

  std::cout << number << std::endl;
  change_val(&number);
  std::cout << number << std::endl;
}

C언어를 공부하신 분이라면 위와 같은 예제는 쉽게 이해가 될 것입니다.

 

number로 선언된 정수형 변수에 5라는 값을 대입하고, change_val 함수의 정수형 포인터 매개변수에 전달됩니다.

 

해당 포인터는 number의 주소로 직접 접근하여 3을 대입하고, 함수가 종료된 뒤에 number는 5로 초기화된 데이터 대신 3을 가지게 됩니다.

 

C에서 어떠한 변수를 가리킬 때 반드시 포인터를 사용했던 것과 달리 C++에서는 다른 변수나 상수를 가리키는 방법으로 참조자(reference)를 사용할 수 있습니다.

 

참조자는 다음과 같이 사용할 수 있습니다.

 

#include <iostream>

int main() {
  int a = 3;
  int& another_a = a;

  another_a = 5;
  std::cout << "a : " << a << std::endl;
  std::cout << "another_a : " << another_a << std::endl;

  return 0;
}

 

컴파일 결과는 [a : 5, another_a = 5]와 같이 나옵니다.

 

참조자를 정의하는 방법은 참조 하고자 하는 변수의 자료형 옆에 &를 붙여주면 됩니다.

 

ex) int 형 변수를 참조할 때는 int&, int* 형 포인터 변수를 참조할 때는 int*&

 

참조자는 참조하고자 하는 변수의 또다른 이름이라고 생각하시면 됩니다.

 

가장 많이 비교되는 것이 포인터인데, 포인터와 가장 큰 차이점은 다음과 같습니다.

 

@참조자 변수는 메모리를 차지하지 않는다. (64bit 기준 포인터 변수는 8byte를 할당 받습니다.)

@참조자 변수는 선언과 동시에 초기화(참조할 변수를 대입하는 과정)가 필요하다. (ex. int& another_a; 안됨)

@참조자 변수는 한 번 특정 변수의 별명이 되면 다른 변수의 별명이 될 수 없다. (포인터는 주소값 대입을 통해 변경 가능) 

 

아래 예제에서 한번 더 확인하시길 바랍니다.

#include <iostream>

int main() {
  int a = 3;
  int& another_a = a; // 선언과 동시에 초기화

  another_a = 5; // 포인터와 같이 참조하는 변수의 데이터를 변경할 수 있다.
  std::cout << "a : " << a << std::endl;
  std::cout << "another_a : " << another_a << std::endl;

  int b = 7;
  
//  another_a = b; // 참조하는 대상을 바꾸려고 하는 경우는 안 된다.

  return 0;
}

 

- 함수 인자로 레퍼런스 받기

 

#include <iostream>

int change_val(int &p) {
  p = 3;

  return 0;
}
int main() {
  int number = 5;

  std::cout << number << std::endl;
  change_val(number);
  std::cout << number << std::endl;
}

 

실행 결과는 [5, 3] 입니다.

 

설명이 굳이 필요 없을 것 같습니다.

 

C에서 함수의 매개변수로 포인터를 설정하고 함수에 변수의 주소값을 넣어주는 경우와 같습니다.

 

하지만, 참조자로 받는 경우 위의 예제와 같이 change(&number)와 같은 부분이 필요없습니다.

 

또한, 함수의 매개변수로 참조자를 설정하는 경우 바로 초기화할 필요가 없습니다.

 

함수에 인자를 넘겨주는 순간 int& p = number와 같이 초기화가 실행되기 때문입니다.

 

- 다른 참조자 예시

 

#include <iostream>

int main() {
  int x;
  int& y = x;
  int& z = y;

  x = 1;
  std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

  y = 2;
  std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;

  z = 3;
  std::cout << "x : " << x << " y : " << y << " z : " << z << std::endl;
}

 

실행결과

 

x : 1 y : 1 z : 1

x : 2 y : 2 z : 2

x : 3 y : 3 z : 3

 

x의 참조자로 y를 정의하고, 다시 y의 참조자로 z를 정의했습니다.

 

여기서 '어떠한 타입의 참조자는 &를 붙인다고 했는데 왜 참조자의 참조자인 z를 선언할 때 타입은 int&&이 아닐까?'라는 의문이 들 수 있습니다.

 

실제로 C++에서 참조자의 참조자를 만드는 경우는 금지되어 있고, 별명의 별명을 또 짓는 것도 말이 안됩니다.

 

따라서 위의 예제에서 z는 결국 y가 참조하고 있는 변수 x를 참조합니다.

 

// C++
std::cin >> user_input;

// C 언어
scanf("%d", &user_input);

 

C에서 변수를 입력하기 위해선 포인터로 주소를 전달해야 했습니다. (scanf에서 & 부분)

 

하지만 C++에서는 그렇지 않습니다.

 

그 이유도 해당 과정이 참조자를 통해 전달하기 때문입니다. (cin에서 &는 필요하지 않다)

 

- 상수에 대한 참조자

 

#include <iostream>

int main() {
  int &ref = 4;

  std::cout << ref << std::endl;
}

 

상수값은 리터럴이기 때문에 참조를 받을 수 없습니다. (리터럴: 소스코드 상에서 고정된 값을 가지는 것을 말함)

 

예를들어 cin >> user_input에 4를 입력했다고 하면, user_input = 4라는 사실이 보장됩니다.

 

이와같이 해당 값이 고정일 경우를 리터럴이라고 합니다.

 

만약 리터럴한 값을 참조자로 받을 수 있다면, 참조자를 통해 리터럴한 값을 변경할수도 있는거겠죠?

 

이와같은 것이 앞뒤가 안 맞기 때문에 리터럴한 값은 참조할 수 없습니다.

 

하지만 상수 리터릴인 경우 참조를 할 수 있는 방법이 한 가지 있습니다.

 

#include <iostream>

int main() {
  const int &ref = 4;

  std::cout << ref << std::endl;
}

 

바로 const를 이용해 상수 참조자로 선언하면 리터럴도 참조할 수 있습니다.

 

#include <iostream>

int main() {
  const int &ref = 4;

  int a = ref; // int a = 4

}

 

여기서 ref는 항상 4를 의미하는 참조자입니다.

 

이러한 경우에 참조자를 통해 해당 값을 변경하려고 해도 const 성질을 갖기 때문에 변경할 수 없다는 error가 생성됩니다.

 

- 레퍼런스의 배열과 배열의 레퍼런스

 

일반적으로 C++에서 레퍼런스의 레퍼런스, 레퍼런스의 배열, 레퍼런스의 포인터는 존재할 수 없다고 규정하고 있습니다.

 

C++ 상에서 배열이 어떤 식으로 처리되는지 생각해보겠습니다.

 

문법 상 배열의 이름은 첫 번째 원소의 주소값으로 변환되어야 합니다.

 

이를통해 arr[1]이 *(arr+1)로 바뀌어 처리될 수 있기 때문입니다.

 

주소값이 존재한다는 의미는 해당 원소가 메모리 상에서 존재한다는 의미와 같습니다.

 

앞서 설명한 것 처럼 참조자는 메모리를 할당 받지 않기 때문에 배열의 조건을 위배 합니다.

 

하지만, 반대로 배열들의 레퍼런스는 가능합니다.

 

#include <iostream>

int main() {
	int arr[3] = {1, 2, 3};
    int(&ref)[3] = arr;
    
    ref[0] = 2;
    ref[1] = 3;
    ref[2] = 1;
    
    std::cout << arr[0] << arr[1] << arr[2] << std::endl;
    
    return 0;
}

 

실행결과

231

 

위의 예제에서 가장 중요한 점은 int (&ref)[3] 이면 반드시 크기가 3인 int 배열의 별명이 되어야 합니다.

 

2차원 배열도 역시 동일합니다.

 

int arr[3][2] = {1, 2, 3, 4, 5, 6};
int (&ref)[3][2] = arr;

 

- 레퍼런스를 리턴하는 함수

 

#include <iostream>

int function(){
    int a = 4;
    
    return a;
}

int main(){
    int b = function();
    
    return 0;
}

 

위와 같은 예제에서 function 안에 정의된 변수 a의 값이 b에 복사되었습니다.

 

복사를 마친 a는 메모리에서 삭제됩니다. (할당받은 주소도 없어집니다.)

 

#include <iostream>

int& function(){
    int a = 4;
    
    return a;
}

int main(){
    int b = function();
    b = 3
    return 0;
}

 

위의 예제를 하나씩 풀어보도록 하겠습니다.

 

function에서 return되는 a를 봅시다.

 

int a = 4;

int& function = a;

 

int b = function;

 

이러한 구조와 같다고 생각하시면 됩니다.

 

하나 다른점은 a가 function이 종료됨과 함께 메모리 상에서 사라진다는 것이죠,,,

 

b는 뭘 받긴 받았는데 눈 깜짝하니까 사라져버린겁니다.

 

이러한 경우를 Dangling reference라고 합니다.

 

참조할 대상이 메모리에서 사라져버린 것을 말합니다.

 

에러는 생성되지만 실행이 안 되는 것은 아닙니다.

 

따라서 위의 예제 처럼 referece를 return하는 함수에서 지역 변수의 reference를 return하지 않도록 조심해야 합니다.

 

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

int main() {
  int b = 2;
  int c = function(b);
  return 0;
}

 

위의 예제는 살짝 다릅니다.

 

외부에서 인자로 들어온 값을 참조하고, 해당 참조자에 값을 대입하고, 대입한 값을 반환합니다.

 

즉, 이때는 사라지지 않는 변수 b를 참조하고 있는 a가 c에게 전달되었기 때문에 오류가 발생하지 않습니다.

 

이와같이 참조자를 return하는 경우의 장점은 다음과 같다고 합니다.

 

그렇다면 이렇게 참조자를 리턴하는 경우의 장점이 무엇일까요? C 언어에서 엄청나게 큰 구조체가 있을 때 해당 구조체 변수를 그냥 리턴하면 전체 복사가 발생해야 해서 시간이 오래걸리지만, 해당 구조체를 가리키는 포인터를 리턴한다면 그냥 포인터 주소 한 번 복사로 매우 빠르게 끝납니다.

마찬가지로 레퍼런스를 리턴하게 된다면 레퍼런스가 참조하는 타입의 크기와 상관 없이 딱 한 번의 주소값 복사로 전달이 끝나게 됩니다. 따라서 매우 효율적이죠!

 

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

int main() {
  int& b = function();
  
  return 0;
}

 

이러한 경우도 a가 사라지기 때문에 Dangling reference 상태가 되지만 int&를 const int&로 바꿔줌으로써 해결할 수 있습니다.

 

반응형