17. Vector and Free Store

17.1. Introduction

C++ 표준 라이브러리에서 가장 유용한 컨테이너는 std::vector로, 기반 타입의 원소의 타입 세이프한 연속된 배열이다. []로 원소를 참조할 수 있고, push_back()으로 확장할 수 있고, size()로 크기를 가져올 수 있다. 여기서는 저수준 메모리 참조 관련 여러 주제들을 다뤄보자.

17.2. std::vector basics

다음 코드는 4개의 double을 가진 std::vector를 생성하고 값을 대입한다.

std::vector<double> age(4);
age[0] = 0.33;
age[1] = 22.0;
age[2] = 27.2;
age[3] = 54.2;

인덱스는 0부터 age.size() – 1까지이다. 메모리를 동적으로 재할당/관리하기 위해서는 기반 타입의 포인터를 들고 있어야 한다.

17.3. Memory, addresses, and pointers

주소는 메모리의 위치를 나타내는 숫자이다. 주소를 보관하는 오브젝트를 포인터라고 한다. int의 주소를 보관하는 포인터는 int*로 표시한다.

int x = 17;
int* pi = &x;

double e = 2.71828;
double* pd = &e;

*pi = 27; // OK: you can assign 27 to the int pointed to by pi
*pd = 3.14159;  // OK: you can assign 3.14159 to the int pointed to by pd
*pd = *pi; // OK: you can assign int to double

포인터는 정수처럼 출력될 뿐 실제로는 정수가 아니다. 그래서 포인터와 정수는 섞일 수 없다. 또한, 서로 다른 포인터끼리도 대입될 수 없다.

int i = pi; // error: can't assign an int* to int
pi = 7; // error: can't assign an int to int*
char* pc = pi; // error: can't assign an int* to char*
pi = pc; // error: can't assign a char* to int*

위의 변환을 진짜 하고 싶다면 reinterpret_cast를 쓰라.

17.3.1. The sizeof operator

타입이나 오브젝트의 크기를 알려면 sizeof를 쓴다.

std::cout << "the size of char is " << sizeof(char) << ' ' << sizeof (ch) << '\n';
std::cout << "the size of int is " << sizeof(int) << ' ' << sizeof (i) << '\n';
std::cout << "the size of int* is " << sizeof(int*) << ' ' << sizeof (p) << '\n';

sizeof는 문자 자료형(char)는 정의에 의해 1이다. 이외에는 플랫폼마다 다르며, 항상 일정하다는 보장이 없다.

std::vector 등 컨테이너에 sizeof를 쓴다고 컨테이너 안의 항목 수를 세는 것은 아니다.

17.4. Free store and pointers

C++ 프로그램을 시작하면 컴파일러는 코드를 저장할 메모리(코드 저장소/텍스트 저장소)를 준비하고 전역 변수에 대한 메모리(정적 저장소)를 준비하고, 함수를 호출할 때 함수와 그 지역 변수에 대한 메모리(스택 저장소/자동 저장소)를 준비한다. 나머지는 자유 저장소()으로 불리며 new에 의해 할당될 수 있다.

double* p = new double[n]; // allocate n doubles on the free store
char* q = new double[4]; // error: double* assigned to char*

new 연산자는 할당된 메모리에 대한 포인터를 리턴한다. 포인터 값은 첫 번째 바이트의 주소값을 반환한다. 포인터는 기반 자료형의 오브젝트를 가리킨다. 포인터는 얼마나 많은 원소들을 가리키는지 모른다. new로 할당하는 원소의 크기는 변수일 수 있다. 서로 다른 타입의 포인터간에는 대입될 수 없다. 타입 오류를 일으킬 수 있기 때문이다.

17.4.2. Access through pointers

포인터에도 [] 연산자를 쓸 수 있다.

double* p = new double[4];    // allocate 4 doubles on the free store
double x = *p;                            // read the (first) object pointed to by p
double y = p[2];                         // read the 3rd object pointed to by p
*p = 7.7;                    // write to the (first) object pointed to by p
p[2] = 9.9;                  // write to the 3rd object pointed to by p

17.4.3. Ranges

포인터의 문제점은 그 포인터가 가리키는 원소의 개수를 모른다는 점이다.

double* pd = new double[3];
pd[2] = 2.2;
pd[4] = 4.4;
pd[–3] = –3.3;

pd에 2번째 원소가 있나? 4번째 원소는? 우리는 그 답이 예/아니오라는 것을 알지만 컴파일러는 모른다. 심지어 [-3]으로 접근해서 pd에서 3개만큼 앞에 있는 지점을 읽을 수도 있다. 이런 범위 밖 접근은 가장 찾기 힘든 버그를 낳으므로 절대로 피해야 한다.

double* p = new double;                // allocate a double
double* q = new double[1000];     // allocate 1000 doubles

q[700] = 7.7;                                        // fine
q = p;                                                   // let q point to the same as p
double d = q[700];                              // out-of-range access!

17.4.4. Initialization

포인터 오브젝트는 반드시 초기화하라. new로 할당한 오브젝트는 기본 타입들에 대해서는 초기화되지 않는다. 초기화 리스트를 이용해 초기화할 수 있다.

double* p0;                                        // uninitialized: likely trouble
double* p1 = new double;              // get (allocate) an uninitialized double
double* p2 = new double{5.5};      // get a double initialized to 5.5
double* p3 = new double[5];          // get (allocate) 5 uninitialized doubles
double* p4 = new double[5] {0,1,2,3,4};
double* p5 = new double[] {0,1,2,3,4};

사용자 정의 타입에 대해서는 new로 할당하면 기본 생성자로 초기화된다.

X* px1 = new X;               // one default-initialized X
X* px2 = new X[17];        // 17 default-initialized Xs

사용자 정의 타입이 생성자는 있지만 기본 생성자는 없다면 명시적으로 초기화해줘야 한다.

Y* py1 = new Y;                  // error: no default constructor
Y* py2 = new Y{13};           // OK: initialized to Y{13}
Y* py3 = new Y[17];           // error: no default constructor
Y* py4 = new Y[17] {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16};

17.4.5. The null pointer

포인터를 초기화할 때 쓸 포인터가 없으면 nullptr로 초기화하라. 0은 포인터에 대입되었을 때 널 포인터가 되며, 포인터가 올바른 상태인지를 체크한다.

double* p0 = nullptr;        // the null pointer
if (p0 != nullptr)              // consider p0 valid
if (p0)                          // consider p0 valid; equivalent to p0!=nullptr

위의 포인터 테스트는 완벽하지 않다. 초기화되지 않은 포인터나 delete된 포인터에 대해서도 체크를 통과하기 때문이다. 그러나 대부분의 경우 딱히 더 나은 수단이 없다.

17.4.6. Free-store deallocation

컴퓨터의 메모리는 제한되어 있기 때문에 자유 저장소의 메모리에 할당한 뒤에 그 메모리가 쓸모 없어지면 이를 delete로 반환해야 한다.

double* calc(int res_size, int max) // the caller is responsible for the memory allocated for res
{
          double* p = new double[max];
          double* res = new double[res_size];
          // use p to calculate results to be put in res
          delete[] p;       // we don’t need that memory anymore: free it
          return res;
}

double* r = calc(100,1000);
// use r
delete[] r;                   // we don’t need that memory anymore: free it

delete에는 두 가지 형태가 있는데 delete는 new에 의해 할당된 오브젝트를 반환하고 delete[]는 new[]에 의해 할당된 오브젝트를 반환한다. 반드시 알맞게 써야 한다.

delete를 두 번 하는 것은 매우 나쁜 실수이다. 아래의 코드는 자유 저장소 관리자가 p가 가리키는 메모리를 반환한 뒤 그 주소의 메모리를 변형하거나 다른 오브젝트가 점유하게 했을 수 있기 때문이다. 절대로 이런 실수를 하면 안 된다.

int* p = new int{5};
delete p;              // fine: p points to an object created by new
// . . . no use of p here . . .
delete p;             // error: p points to memory owned by the free-store manager

널 포인터를 delete하는 것은 안전하다.

int* p = nullptr;
delete p;              // fine: no action needed
delete p;              // also fine (still no action needed)

메모리를 자동으로 처리해줄 수는 없나? C++에서도 가비지 콜렉터가 가능하지만 메모리 면에서 공짜가 아니고 항상 이상적인 것도 아니다. 메모리 누수를 일으키지 않는 습관을 먼저 들이라.

17.5. Destructors

자원을 생성자에서 얻었다면 반드시 소멸자에서 이를 해제해 주자. 이는 C++의 기본 철학이다.

17.5.1. Generated destructors

소멸자는 생성자와 마찬가지로 따로 만들지 않을 경우 컴파일러가 기본으로 생성해 준다. 소멸자는 동적 할당되지 않았을 경우에는 스코프를 벗어날 경우 자동으로 호출되며 멤버 변수의 소멸자를 재귀적으로 호출한다.

17.5.2. Destructors and free store

클래스 계층과 소멸자를 결합할 때에는 중요한 조건이 있다. 기반 클래스의 소멸자의 경우 반드시 virtual로 설정해줘야 한다. Shape의 소멸자가 virtual이면 delete q에서 ~Shape()가 호출될 때 Shape()의 파생 클래스를 찾아가서 ~Text()를 호출하지만 virtual이 아니면 ~Shape()만 불리고 ~Text()는 불리지 않게 된다.

Shape* fct()
{
          Text tt {Point{200,200},"Annemarie"};
          // . . .
          Shape* p = new Text{Point{100,100},"Nicholas"};
          return p;
}
void f()
{
          Shape* q = fct();
          // . . .
          delete q;
}

virtual 멤버가 있는 함수라면 virtual로 소멸자를 설정해야 한다. 기반 클래스로 사용되어 그 파생 클래스가 new로 할당되고 기반 클래스 기반 포인터로 접근되어 delete로 해제될 가능성이 있기 때문이다.

17.6. Access to elements

get()과 set()으로 원소를 읽고 쓸 수 있으며, 이 과정에서 잘못된 읽기/쓰기에 대한 체크를 할 수 있다.

17.7. Pointers to class objects

클래스 오브젝트에 대해서는 new는 메모리 할당과 생성자 호출을, delete는 소멸자 호출과 메모리 해제를 한다. 생성자 밖에서 new를 쓰지 말라. 소멸자 밖에서 delete를 쓰지 말라. 그것은 십중팔구 나쁜 C++ 코드이다.

포인터 클래스 오브젝트의 멤버는 ->로 접근한다.

17.8. Messing with types: void* and casts

타입 세이프하지 않은 코드를 짜야 할 때 (ex. C++의 타입을 모르는 언어와 통신하는 코드를 짤 때) 범용 타입 포인터와 타입 변환이 필요할 때가 있다.

void*는 컴파일러가 타입을 모르는 메모리에 대한 포인터이다. 모든 포인터는 void*로/부터 변환될 수 있다. void*는 가리키는 타입을 모르기 때문에 직접 역참조할 수 없다.

void v;         // error: there are no objects of type void
void f();       // f() returns nothing — f() does not return an object of type void
void* pv1 = new int;                    // OK: int* converts to void*
void* pv2 = new double[10];     // OK: double* converts to void*

void f(void* pv)
{
    void* pv2 = pv;    // copying is OK (copying is what void*s are for)
    double* pd = pv;    // error: cannot convert void* to double*
    *pv = 7;    // error: cannot dereference a void* (we don’t know what type of object it points to)
    pv[2] = 9;    // error: cannot subscript a void*
    int* pi = static_cast<int*>(pv);    // OK: explicit conversion
}

static_cast는 관계된 타입을 명시적으로 캐스팅한다. 필요할 때가 아니면 쓰지 마라.

Register* in = reinterpret_cast<Register*>(0xff);

void f(const Buffer* p)
{
          Buffer* b = const_cast<Buffer*>(p);
          // . . .
}

reinterpret_cast는 관계 없는 타입도 캐스팅한다. 하드웨어 레지스터를 직접 읽고 쓰는 극히 예외적인 경우 아니면 아예 쓰지 마라.

const_cast는 const 성을 붙이거나 날린다. 역시 가급적 안 쓰는 것이 좋다. 써야 한다면 꼭 써야 하는지 다시 한 번 고려해보라.

17.9. Pointers and references

포인터와 참조자는 다음 면들에서 다르다.

  • 포인터에 대한 대입은 포인터의 값을 바꾼다.
  • 참조자에 대한 대입은 참조된 값을 바꾼다.
  • 포인터를 얻기 위해서는 new나 &을 쓴다.
  • 포인터를 역참조하기 위해서는 *나 []을 쓴다.
  • 참조자는 초기화된 뒤 다른 오브젝트를 참조할 수 없다.
  • 참조자 대입은 깊은 복제를 수행한다. 포인터 대입은 그렇지 않다.
int x = 10;
int* p = &x;                       // you need & to get a pointer
*p = 7;                                // use * to assign to x through p
int x2 = *p;                        // read x through p
int* p2 = &x2;                   // get a pointer to another int
p2 = p;                               // p2 and p both point to x
p = &x2;                            // make p point to another object

int y = 10;
int& r = y;                       // the & is in the type, not in the initializer
r = 7;                                // assign to y through r (no * needed)
int y2 = r;                        // read y through r (no * needed)
int& r2 = y2;              // get a reference to another int
r2 = r;                         // the value of y is assigned to y2
r = &y2;                     // error: you can’t change the value of a reference
                                   // (no assignment of an int* to an int&)

17.9.1. Pointer and reference parameters

함수로부터 전달된 인자를 변경하려면 3가지 방법이 있다.

int incr_v(int x) { return x+1; }      // compute a new value and return it
void incr_p(int* p) { ++*p; }         // pass a pointer (dereference it and increment the result)
void incr_r(int& r) { ++r; }            // pass a reference

작은 오브젝트에 대해서는 incr_v를 쓰면 된다. 큰 오브젝트에 대해서 incr_v를 쓰려면 이동 생성자와 이동 대입 연산자를 만들어줘야 효율적이다.

incr_p와 incr_r 간에는 각각의 장단점이 있다. incr_p는 주소 참조 연산자를 써야 하므로 포인터가 전달된다는 것을 코드를 읽는 사람에게 전달할 수 있다. 단 널 포인터가 올 수 있음을 대비해야 한다. incr_r은 주의깊게 읽지 않으면 x가 변경된다는 것을 모를 수 있다. 단 널 참조자 같은 것은 없으므로 널 포인터 걱정을 안 해도 된다. 즉, 아무것도 전달되지 않음을 허용하려면 포인터로 전달하거나 std::optional을 쓴다.

int x = 7;
incr_p(&x)             // the & is needed
incr_r(x);
incr_p(nullptr);           // crash: incr_p() will try to dereference the null pointer
int* p = nullptr;
incr_p(p);                     // crash: incr_p() will try to dereference the null pointer

17.9.2. Pointers, references, and inheritance

파생 클래스의 포인터나 참조자는 기반 클래스의 포인터나 참조자로 묵시적 변환될 수 있다.

void rotate(Shape* s, int n);                 // rotate *s n degrees

Shape* p = new Circle{Point{100,100},40};
Circle c {Point{200,200},50};
rotate(p,35);
rotate(&c,45);

void rotate2(Shape& s, int n);           // rotate s n degrees

Shape& r = c;
rotate2(r,55);
rotate2(*p,65);
rotate2(c,75);

17.9.3. An example: lists

포인터 관련 코드를 구현할 때에는 널 포인터에 대해 주의 깊은 체크를 해야 한다.

17.9.4. List operations

C++ 표준 라이브러리에서는 std::list를 제공한다. 보통 이중 연결 리스트로 구현되며, 제공되는 연산들은 다음과 같다.

  • 생성자
  • insert : 원소 앞에 삽입
  • add : 원소 뒤에 삽입
  • erase : 원소 제거
  • find : 원소를 탐색
  • advance : n번 뒤에 있는 원소를 가져옴

17.9.5. List use

타입 세이프티에 관해 충분히 체크하라.

17.10. The this pointer

this 포인터를 클래스 멤버 함수에서 호출하면 그 클래스 객체에 대한 포인터를 리턴한다. 멤버 변수나 함수에 대한 호출에는 this를 붙일 필요가 없다. this 포인터가 가리키는 오브젝트는 변경될 수 없다.

17.10.1. More link use

this 포인터를 잘 활용해 코드의 가독성을 높이자.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Google photo

Google의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중