{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[C++] The C++ Programming Language: 7. Pointers, Arrays, and References (1)

 7. Pointers, Arrays, and References


7.1 Introduction

  이 챕터에서는 메모리를 참조하는 기초 언어 메커니즘을 이야기할 것입니다. 우리는 이름으로 객체를 참조할 수 있죠. 하지만 C++ 객체들은 '아이덴티티'가 있습니다. 우리는 해당 객체의 주소와 타입을 알 수 있다면, 이를 직접 참조할 수 있습니다.


7.2 Pointers

  타입 T에 대해 T*는 "T에 대한 포인터"입니다. 곧 T* 타입의 변수들은 T 타입 객체의 주소를 갖고 있죠:

1
2
char c = 'a';
char∗ p = &c; // p holds the address of c; & is the address-of operator
cs

  포인터의 기본적 연산은 역참조(dereferencing)입니다. 이는 포인터가 가리키는 객체를 참조하는 것이죠. 이는 indirection(간접 참조)이라고도 말합니다. 역참조 연산자는 *을 앞에 붙이는 것입니다:

1
2
3
char c = 'a';
char∗ p = &c; // p holds the address of c; & is the address-of operator
char c2 = ∗p; // c2 == ’a’; * is the dereference operator
cs

  p에 의해 가리켜진 객체는 c이며, c에는 'a' 값이 저장되어 있습니다. c2에 *p를 할당하게 되면, 이는 'a'가 할당되는 것입니다. 배열을 가리키는 포인터에게는 산술적 연산을 수행하는 것 역시 가능합니다.

  포인터의 구현은 프로그램을 실행시키는 머신의 주소 메커니즘에 직접적으로 매핑하기 위함입니다. 대부분의 머신은 byte 단위로 주소를 관리할 수 있죠. 그러지 않더라도 명령 단위(words)에서 byte를 추출할 수 있는 메커니즘을 제공합니다. 반면 bit 단위로 주소를 접근하는 머신들은 없죠. 결론적으로, 가장 작은 단위의 포인터 타입은 char입니다. bool 역시 최소 char만큼의 크기를 가진다는 사실에 유의하세요. 조금 더 작은 단위로 값들을 저장하고 싶다면, bitwise 논리 연산, 비트 필드(bit-fields), bitset 등을 사용하는 것이 좋을 것입니다.

  이름 뒤에 오는 *의 의미는 "가리킨다"입니다. 안타깝게도 배열이나 함수의 포인터는 조금 더 복잡한 표기법을 지녔습니다:

1
2
3
4
5
int∗ pi; // pointer to int
char∗∗ ppc; // pointer to pointer to char
int∗ ap[15]; // array of 15 pointers to ints
int (∗fp)(char∗); // pointer to function taking a char* argument; returns an int
int∗ f(char∗); // function taking a char* argument; returns a pointer to int
cs


7.2.1 void*

  저수준 코드에서, 우리는 종종 특정 오브젝트에 무엇이 저장되어 있는지 모른 채 그것의 주소를 넘겨야 할 때가 있습니다. void*는 이를 위함이며, void*를 "알 수 없는 타입 객체의 포인터"로 생각하면 됩니다.

  void* 타입 변수에는 어떤 오브젝트의 주소이든 할당될 수 있으나, 함수 및 멤버는 할 수 없습니다. 추가적으로, void*는 다른 void*를 할당받을 수 있으며, 동등성을 비교할 수 있고, 명시적으로 다른 타입으로 변환될 수 있습니다. 다른 연산들은 안전하지 않습니다. 컴파일러가 해당 포인터가 어떤 객체를 가리키고 있는지 알 수 없기 때문이죠. 따라서, 다른 연산들은 컴파일 에러를 불러일으키게 됩니다. void*를 활용하기 위해, 여러분들은 반드시 명시적으로 특정 타입으로의 변환을 해주어야 합니다:

1
2
3
4
5
6
7
8
9
10
void f(int∗ pi)
{
    void∗ pv = pi; // ok: implicit conversion of int* to void*
    ∗pv; // error : can’t dereference void*
    ++pv; // error : can’t increment void* (the size of the object pointed to is unknown)
    int∗ pi2 = static_cast<int∗>(pv); // explicit conversion back to int*
    double∗ pd1 = pv; // error
    double∗ pd2 = pi; // error
    double∗ pd3 = static_cast<double∗>(pv); // unsafe (§11.5.2)
}
cs


  일반적으로, 가리켜진 객체의 타입으로부터 다른 타입으로 변환된 포인터를 사용하는 것은 안전하지 않습니다. 예를 들어, 머신은 모든 double을 8-byte 경계로 할당할 것입니다. 이 때, pi가 그렇게 할당되지 않은 int를 가리키게 되면(pd3에 pv를 할당함으로써, 8-byte 경계를 갖지 않는 int가 할당되면-옮긴 이), 이상한 결과가 나타날 수 있습니다. 이런 형태의 명시적 타입 변환은 위험합니다. 결론적으로, static_cast는 보기 흉하고 코드에서 찾기 쉽도록 설계되었습니다.

  void* 사용의 제일은 객체의 타입 추정이 허용되지 않는 함수 포인터를 전달하는 것입니다. 이러한 객체를 사용하려면, 명시적 타입 변환이 필요하겠죠.

  void* 포인터를 활용하는 함수는 보통 시스템의 아주 낮은 레벨에 존재하고 있습니다. 실제 하드웨어 자원이 생성되는 곳이죠:

1
void∗ my_alloc(siz e_t n); // allocate n bytes from my special heap
cs

  시스템의 높은 레벨에서 void*를 볼 수 있다는 것은, 설계적 오류가 있을 가능성을 강력하게 시사합니다. 최적화를 위해, void*는 type-safe 인터페이스에 숨을 수 있습니다.


7.2.2 nullptr

  리터럴 nullptr는 null 포인터를 표현합니다. 포인터는 아무 객체도 가리키고 있지 않다는 것이죠. 어떤 포인터 타입이든 해당 값을 할당받을 수 있습니다. 물론 포인터가 아닌 타입의 경우에는 그렇지 않겠죠.

1
2
3
int∗ pi = nullptr;
double∗ pd = nullptr;
int i = nullptr; // error : i is not a pointer
cs

  단 하나의 nullptr 표현만이 존재하며, 어떤 포인터 타입에든 사용할 수 있습니다.

  nullptr를 도입하기 전에, 0은 null 포인터의 표기였습니다. 가령:

1
int∗ x = 0;
cs

  어떤 객체도 0번 주소에 할당되지는 않습니다. 또한 0(모든 bit가 0인 패턴)은 nullptr의 흔한 표현 중 하나입니다. 0은 int입니다. 하지만, 표준 변환은 0을 포인터의 상수 혹은 멤버의 포인터 타입으로 사용하는 것을 허용합니다.

  매크로 NULL을 이용해서 null 포인터를 표현하는 것은 인기 있는 방식입니다:

1
int∗ p = NULL;
cs

  하지만, NULL의 값은 서로 다른 구현부에서 서로 다른 값으로 정의될 수 있습니다; 예를 들어, NULL은 0이거나 0L일 수 있죠. C의 경우 NULL은 전형적으로 (void*)0입니다. 이는 C++에서 허용되지 않죠.

1
int∗ p = NULL // error : can't assign a void* to an int*
cs

  nullptr를 활용하는 것은 코드를 더 읽기 쉽게 만들고 여러 문제들을 방지할 수 있습니다. 함수가 포인터 혹은 정수로 오버로딩 되었는지에 대한 혼란 등을 피할 수도 있죠.


7.3 Arrays

7.4 Pointers into Arrays

(생략)


7.5 Pointers and const

  C++은 constant(상수)의 의미와 연관된 두 키워드를 제공합니다:

  • constexpr: 컴파일 시간에 평가됩니다.
  • const: 해당 스코프에서 변경되지 않습니다.

  기본적으로, constexpr의 역할은 컴파일 시간의 평가를 할 수 있게 만드는 것입니다. const의 역할은 불변성을 명시하는 것이죠. 이번 절에서는 두 번째 역할에 대해서 이야기해 볼 것입니다: 인터페이스 명세

  초기화 이후 값이 변경되지 않는 객체들을 많이 볼 수 있습니다:

  • 코드에서 리터럴 값으로 직접 사용하는 것 대신 상징적인 상수를 만들어 유지보수성을 확보하는 경우
  • 자주 읽기만 하고 변경되지는 않는 값을 가리키는 포인터
  • 읽기만 하고 변경되지는 않는 인자를 갖는 함수

  초기화 이후 불변한다는 표기를 위해, const 정의를 덧붙일 수 있습니다:

1
2
3
const int model = 90; // model is a const
const int v[] = { 1, 2, 3, 4 }; // v[i] is a const
const int x; // error : no initializer
cs


  무언가를 const로 선언하는 것은 해당 값이 스코프에서 결코 변경되지 않는다는 것을 의미합니다:

1
2
3
4
5
void f()
{
    model = 200; // error
    v[2] = 3; // error
}
cs

  const는 타입을 변경한다는 사실에 주의하세요. 이는 객체가 어떻게 사용될 수 있는지를 제한할 뿐이지, 해당 객체의 값이 상수로서 할당되었는지를 명시하지는 않습니다.

1
2
3
4
5
6
7
8
9
10
11
void g(const X∗ p)
{
    // can’t modify *p here
}
void h()
{
    X val; // val can be modified here
    g(&val);
    // ...
}
cs

  포인터를 이용할 때, 두 객체를 고려하게 됩니다: 포인터 자신과 해당 포인터가 가리키고 있는 객체입니다. 포인터에 const를 "접두사"로서 선언하는 것은 포인터가 아닌 객체를 상수로 만듭니다. 만약 포인터 자신을 상수로 만들고 싶다면, * 대신 *const를 이용하면 됩니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void f1(char∗ p)
{
    char s[] = "Gorm";

    const char∗ pc = s; // pointer to constant
    pc[3] = 'g'; // error : pc points to constant
    pc = p; // OK

    char ∗const cp = s; // constant pointer
    cp[3] = 'a'; // OK
    cp = p; // error : cp is constant

    const char ∗const cpc = s; // const pointer to const
    cpc[3] = 'a'; // error : cpc points to constant
    cpc = p; // error : cpc is constant
}
cs

  const*로 표기하는 것은 의미가 없음에 주의하세요. * 앞에 const가 있다면, 포인터가 가리키는 객체를 상수로 만듭니다.

  몇몇 사람들은 오른쪽에서 왼쪽으로 읽어나가는 것이 도움이 된다고 합니다. "cp is a const pointer to a char", "pc2 is a pointer to a char const" (우리는 왼쪽에서 오른쪽으로 읽어야 겠군요-옮긴 이)

  포인터를 통해 접근되는 상수인 객체는 다른 방법으로 접근했을 때 변경이 가능할 수도 있습니다. 이는 특히 함수 인자에서 유용합니다. 포인터 인자를 const로 선언하는 것은, 함수가 해당 객체들을 변경하지 못하도록 금지할 수 있습니다:

1
2
const char∗ strchr(const char∗ p, char c); // find first occurrence of c in p
char∗ strchr(char∗ p, char c); // find first occurrence of c in p
cs

  첫 번째 줄의 경우, 인자로 들어온 문자열이나 바깥으로 반환된 문자열을 변경할 수 없도록 만듭니다. 두 번째 줄의 경우 변경 가능합니다.


  여러분은 const가 아닌 변수의 주소를 포인터에게 const로 할당할 수 있습니다. 이는 아무런 문제가 없기 때문이죠. 하지만, 상수인 변수의 주소를 const가 아닌 포인터에게 할당할 수는 없습니다. 이는 해당 값의 변경을 할 수 없게 만들어야 되기 때문이죠:

1
2
3
4
5
6
7
8
9
void f4()
{
    int a = 1;
    const int c = 2;
    const int∗ p1 = &c; // OK
    const int∗ p2 = &a; // OK
    int∗ p3 = &c; // error : initialization of int* with const int*
    ∗p3 = 7; // try to change the value of c
}
cs


7.6 Pointers and Ownership

  자원은 한 번 할당되었다면 반드시 소멸시켜 주어야 합니다. 메모리는 new 키워드로 할당할 수 있고, delete 키워드로 소멸시킬 수 있습니다. 그리고 파일은 fopen()과 fclose()로 열고 닫을 수 있죠. 이는 자원을 포인터로서 직접 다루는 한 예제이기도 합니다. 이는 우리들에게 많은 혼란을 주죠. 포인터는 프로그램 전체에서 손쉽게 전달될 수 있기 때문입니다. 그리고 자원을 소유한 포인터인지, 아닌지를 구분하는 타입 시스템은 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void confused(int∗ p)
{
    // delete p?
}

int global {7};

void f()
{
    X∗ pn = new int{7};
    int i {7};
    int q = &i;
    confused(pn);
    confused(q);
    confused(&global);
}
cs

  만약 confused()가 p를 소멸시킨다면, 프로그램은 심각하게 오작동을 일으킬 것입니다. new로 할당되지 않은 자원들을 delete 시켰기 때문이죠. 만약 confused()가 p를 소멸시키지 않는다면, 이는 누수를 일으킬 것입니다. 이 경우, f()는 반드시 객체들의 생명주기를 관리해야 합니다. 전체의 큰 프로그램에서 어떤 것을 소멸시키고 소멸시키지 않을 건지를 결정하는 것은 일관되고 간단한 전략이 필요합니다.

  리소스 핸들 클래스에 오너쉽을 표현하는 포인터를 배치하는 것은 좋은 방법입니다. vector, string, unique_ptr와 같은 것들이죠. 이러한 방법으로, 리소스 핸들 클래스에 있지 않은 포인터들은 전부 소멸시킬 필요가 없음을 확신할 수 있죠. 





댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링

[코딩의탑] 5층: 포탈(Portal), 더 나아가기