Programming Languange/C++

[CPP] 1. 객체와 메모리

타자치는 문돌이

객체에 어떻게 접근할까? - 주소와 포인터

#include <iostream>

int main()
{
    int x = 10;
    int* p = &x;
    int& r = x;

    std::cout << "x value: " << x << '\n'; 
    std::cout << "x address: " << &x << '\n';

    std::cout << "p value: " << p << '\n'; 
    std::cout << "p address: " << &p << '\n'; 
    std::cout << "*p value: " << *p << '\n';

    std::cout << "r: " << r << '\n'; 
    std::cout << "&r: " << &r << '\n';
}

x는 객체다.
메모리의 어딘가에 4 바이트의 메모리를 잡고 10을 저장한다.

&xx가 10을 저장한 메모리의 주소이다.

int* p는 주소를 저장하는 변수로, 여기에 x의 주소인 &x를 저장할 수 있다.
즉, px의 주소 &x이다.

*pp의 주소에 저장된 객체에 접근한다.
px의 주소가 있으므로 x의 주소에 저장된 10에 접근한다.

int& rxx라는 이름 대신 r이라는 이름으로 쓸 수 있게 해준다.
따라서 r의 주소도 x와 같고, 값도 x와 같으며, x를 수정하면 값이 같이 달라진다.

x value: 10
x address: 0x7ffe0d6f03a4
p value:   0x7ffe0d6f03a4
p address: 0x7ffe0d6f03a8
*p value: 10
r: 10
&r:        0x7ffe0d6f03a4

int* p의 *와 *p의 *는 역할이 다르다.
int*는 p가 포인터 변수라는 것을 알려주는 선언자(int, float 같은)의 한 부분이고,
*p는 p의 주소에 저장된 객체에 접근하는 연산자이다.
int&과 &도 마찬가지로 int&는 선언자, &는 주소를 반환하는 연산자이다.

 


객체의 수명 - Stack, Heap, Static의 생명주기

운영체제를 공부할 때, 프로세스의 지역 변수는 Stack, 전역 변수는 Data, 동적으로 할당된 데이터는 Heap에 저장된다고 배웠다.

이 데이터는 언제까지 유효할까?

#include <iostream>

struct Tracer
{
    const char* Name;

    Tracer(const char* InName) : Name(InName)
    {
        std::cout << "Construct: " << Name << '\n';
    }

    ~Tracer()
    {
        std::cout << "Destruct: " << Name << '\n';
    }
};

Tracer GlobalTracer("Global");

void Foo()
{
    std::cout << "Enter Foo\n";
    Tracer LocalTracer("Local in Foo");
    Tracer* DynamicTracer = new Tracer("Dynamic in Foo");
    std::cout << "DynamicTracer : "<< DynamicTracer << '\n';
    delete DynamicTracer;
    std::cout << "Leave Foo\n";
}

int main()
{
    std::cout << "Enter main\n";
    std::cout << "GlobalTracer : "<< &GlobalTracer << '\n';
    Tracer LocalTracer("Local in main");
    std::cout << "LocalTracer : "<< &LocalTracer << '\n';
    Foo();
    std::cout << "Leave main\n";
}

Tracer는 생성과 소멸에 Name을 출력한다.

Construct: Global
    Enter main
    GlobalTracer : 0x56343152d158
    Construct: Local in main
    LocalTracer : 0x7fffd3d7cd80
        Enter Foo
        Construct: Local in Foo
        Construct: Dynamic in Foo
        DynamicTracer : 0x56343c94c6c0
        Destruct: Dynamic in Foo
        Leave Foo
        Destruct: Local in Foo
    Leave main
    Destruct: Local in main
Destruct: Global

전역 객체는 main보다 먼저 생성되고, 프로그램 종료 시점에 사라진다.
지역 객체는 Scope에 들어가면 생성되고, Scope 종료 시점에 사라진다.
동적 객체는 new에 생성되고, delete에 소멸한다. delete가 없으면 사라지지 않는다.

 


객체는 어떻게 메모리에 배치되는가 - sizeof, alignment, padding

#include <iostream>

struct A
{
    char c1;
    int i;
    char c2;
};

struct B
{
    int i;
    char c1;
    char c2;
};

int main()
{
    std::cout << "sizeof(char): " << sizeof(char) << '\n';
    std::cout << "sizeof(int): " << sizeof(int) << '\n';
    std::cout << "sizeof(void*): " << sizeof(void*) << '\n';

    std::cout << "sizeof(A): " << sizeof(A) << '\n';
    std::cout << "sizeof(B): " << sizeof(B) << '\n';
}

sizeof는 객체가 차지하는 메모리의 크기이다.

sizeof(char): 1
sizeof(int): 4
sizeof(void*): 8
sizeof(A): 12
sizeof(B): 8

void*는 포인터의 크기로, 64 bit 환경에서는 주로 8 바이트, 32 bit에서는 4 바이트이다.

AB는 같은 내용을 담지만, 12와 8로 크기가 다르다. 이는 alignment에 따른 변수의 위치 조정 때문이다. int의 alignment가 4라면, int 객체는 메모리의 4의 배수 자리에 놓인다.
A의 경우 char-int-char 순서로 놓는 동시에 int를 4의 배수 자리에 놓기 위해 char 뒤에 padding 3 바이트를 추가한다.
B는 같은 데이터지만, int-char-char로 배치해 alignement가 1인 char를 붙여 배치할 수 있으므로 8 바이트만 사용한다.

alignment에 따른 padding은 클래스에서도 마찬가지로 발생한다.

 


배열, 포인터, 연속 메모리

#include <iostream>

int main()
{
    int arr[3] = {10, 20, 30};
    int* p = arr;

    std::cout << "sizeof(int): " << sizeof(int) << '\n';
    std::cout << "sizeof(arr): " << sizeof(arr) << '\n';
    std::cout << '\n';

    std::cout << "&arr[0]: " << &arr[0] << '\n';
    std::cout << "&arr[1]: " << &arr[1] << '\n';
    std::cout << "&arr[2]: " << &arr[2] << '\n';
    std::cout << '\n';

    std::cout << "arr: " << arr << '\n';
    std::cout << "&arr[0]: " << &arr[0] << '\n';
    std::cout << '\n';

    std::cout << "sizeof(arr) : " << sizeof(arr) << '\n';
    std::cout << "sizeof(&arr[0]) : " << sizeof(&arr[0]) << '\n';
    std::cout << "sizeof(p) : " << sizeof(p)   << '\n';
    std::cout << '\n';

    std::cout << "p : " << p << '\n';
    std::cout << "p+1 : " << p + 1 << '\n';
    std::cout << "p+2 : " << p + 2 << '\n';
}
&arr[1]: 0x7ffc8015d550
&arr[2]: 0x7ffc8015d554

arr: 0x7ffc8015d54c
&arr[0]: 0x7ffc8015d54c

sizeof(arr) : 12
sizeof(&arr[0]) : 8
sizeof(p) : 8

p : 0x7ffc8015d54c
p+1 : 0x7ffc8015d550
p+2 : 0x7ffc8015d554

배열은 같은 타입의 객체가 연속된 메모리에 저장된다.
따라서 int 배열 원소의 주소는 4 차이이다.

배열 이름은 포인터가 아니지만, 많은 표현식에서 배열의 첫 원소를 가리키는 포인터로 변환된다.
따라서 arr&arr[0]으로 쓰기도 한다.
둘은 같은 메모리 주소를 반환한다.

그러나 sizeof(arr)arr의 크기 (4*3), sizeof(&arr[0])sizeof(p)arr[0]p의 포인터 객체 크기(8)이다.

포인터의 산술 연산은 타입 크기를 기준으로 한다.
int*의 연산은 4 바이트 기준이다.
따라서 p + 1p + int (4 바이트)이다.
내부적으로 arr[i] == *(arr + i)이다.

#include <iostream>

struct A
{
    char c1;
    int i;
    char c2;
};

int main()
{
    A arr[3];

    std::cout << "sizeof(A): " << sizeof(A) << '\n';

    std::cout << "&arr[0]: " << &arr[0] << '\n';
    std::cout << "&arr[1]: " << &arr[1] << '\n';
    std::cout << "&arr[2]: " << &arr[2] << '\n';
}
sizeof(A): 12
&arr[0]: 0x7fff34d9d070
&arr[1]: 0x7fff34d9d07c
&arr[2]: 0x7fff34d9d088

Achar+int+charchar 1 + [padding 3] + int 4 + char 1 + [padding 3]로 12 바이트를 차지한다. 이때 마지막 char도 tail padding을 가져 A의 배열에서도 그 크기만큼 간격이 생긴다. 따라서 A 배열의 각 원소는 12 바이트 간격으로 배치된다.

 


사라진 객체 - Dangling Pointer와 Undefined Behavior

Dangling Pointer

Dangling Pointer는 이미 수명이 끝난 객체를 가리키는 포인터이다.

#include <iostream>

int* MakePointer()
{
    int Local = 10;
    return &Local; // 위험
}

int main()
{
    int* p = MakePointer();

    std::cout << *p << '\n'; // Undefined Behavior
}

LocalMakePointer()가 종료되면 사라지지만, p는 사라진 Local의 주소를 들고 있다.
이때 사라진 Local의 주소를 가리키는 p를 dangling pointer라고 한다.

int* p = new int(10);

delete p;

std::cout << *p << '\n'; // Undefined Behavior

이 경우도 pdelete로 사라진 new int(10)의 주소를 들고 있다.
안전하게 처리하려면

delete p;
p = nullptr;

로 처리한다.

nullptr과 dangling pointer는 다르다. nullptr은 '아무 객체도 가리키지 않는다'는 명확한 값으로

if (p)
{
    std::cout << *p << '\n';
}

와 같이 사용할 수 있지만,
dangling pointer의 값은 nullptr이 아닐 수도, 유효하지 않은 예전 주소값을 그대로 가지고 있을 수도 있다.

dangling pointer와 마찬가지로 dangling reference도 존재한다.

int& MakeReference()
{
    int Local = 10;
    return Local; // 위험
}

int main()
{
    int& r = MakeReference();

    std::cout << r << '\n'; // Undefined Behavior
}

마찬가지로 이미 소멸한 객체를 참조하고 있다면 위험하다.

Undefined Behaviour

C++이 프로그램이 어떻게 동작할지 정의하지 않는다는 뜻으로 어떤 결과나 나올지 모른다.
정상처럼 보일 수도, 쓰레깃값이 나올 수도, 크래시가 날 수도 있다.

int* p = new int(10);

delete p;

std::cout << *p << '\n';

이 코드가 어떤 경우에는 10을 출력할 수 있지만, 그건 우연히 메모리에 아직 10이 남아 있던 것이고 항상 같은 결과를 보장하지도, 안전하지도 않다.

#include <iostream>

int main()
{
    int arr[3] = {10, 20, 30};

    std::cout << arr[3] << '\n'; // Undefined Behavior
}

arr[3]*(arr + 3)으로 arr[2] 다음 위치 주소를 역참조한다. 그러나 이 위치의 값은 무엇일지 알 수 없다. 따라서 역참조하면 안 된다.

이를 반복문에 활용할 수는 있다.

int arr[3] = {10, 20, 30};

int* begin = arr;
int* end = arr + 3;

for (int* p = begin; p != end; ++p)
{
    std::cout << *p << '\n';
}

반복문의 종료 지점으로 사용하지만, 절대 그 값을 역참조해서는 안 된다.

 


const와 메모리

const는 이 이름을 통해서는 객체를 수정하지 않겠다는 뜻이다.

const int x = 10;

x = 20; // 컴파일 에러

const int*int* const

int a = 10;
int b = 20;

const int* p = &a;

*p = 30; // 불가능
p = &b;  // 가능

이건 p가 가리키는 int를 수정할 수 없다는 뜻이다.
pb를 가리킬 수는 있다.
그러나 p가 가리키는 값을 수정할 수는 없다.
즉, 대상은 수정 가능하지만, 내용은 수정할 수 없다.

int* const p = &a;

*p = 30; // 가능
p = &b;  // 불가능

이건 반대로 p 자체가 const이므로
p가 가리키는 int를 바꿀 수는 없지만, p가 가리키는 int의 값을 수정할 수는 있다.
즉, 대상은 수정할 수 없지만, 내용은 수정 가능하다.

const int* const p = &a;

이러면 대상도 내용도 수정할 수 없다.

const &

int a = 10;

const int& r = a;

r = 20; // 불가능 
a = 20; // 가능

ra의 별칭으로, r의 값은 수정할 수 없다는 뜻이다.

함수 인자로의 const

C++의 const는 함수 인자를 받을 때의 약속이 된다.

void PrintValue(const int& value)
{
    std::cout << value << '\n';
}

이 경우 value를 복사하지 않고 참조로 받지만, 함수 안에서 value를 수정하지 않는다는 뜻이다. 즉, value의 복사 비용이 들지 않고, 읽기 전용으로 전달할 수 있다.

반응형