C++에는 메모리(데이터를 저장하는 공간)를 빌리는 세 가지 기본적인 방법이 있습니다. 여러분은 이미 그중 두 가지를 경험해 보셨을 거예요.
정적 할당과 자동 할당에는 두 가지 큰 공통점(이자 단점)이 있습니다.
보통은 이 두 가지 특징이 전혀 문제 되지 않습니다. 하지만 외부 요인
(사용자의 입력이나 파일 읽기 등)을 다룰 때는, 이 제약 때문에 곤란한 상황이 발생하곤 해요.
예를 들어볼까요? 누군가의 이름을 저장하고 싶은데, 사용자가 이름을 입력하기 전까지는 그 이름이 몇 글자인지 알 수가 없죠. 또는 컴퓨터 디스크에서 기록을 불러오고 싶은데, 기록이 몇 개나 있는지 미리 알 수 없는 경우도 있습니다. 게임을 만들 때도 몬스터가 죽고 새로 태어나면서 몬스터의 수가 계속 변하는데, 몇 마리인지 딱 정해놓을 수가 없잖아요?
만약 코드를 짤 때 모든 크기를 미리 정해둬야만 한다면, 우리가 할 수 있는 최선은
그저 "이 정도면 충분하겠지?" 하고 최댓값을 어림짐작해서 찍는 것뿐입니다.
char name[25]; // 이름이 25자 미만이길 바래야죠!
Record record[500]; // 기록이 500개 미만이길!
Monster monster[40]; // 몬스터는 최대 40마리
Polygon rendering[30000]; // 이 3D 렌더링 폴리곤이 3만 개를 넘지 않기를!
하지만 이런 식의 '찍기'는 적어도 4가지 이유에서 아주 안 좋은 해결책입니다.
\0이 있어서 구분하기 쉽지만, monster[24] 같은 경우는 어떨까요? 이 몬스터가 살아있는지, 죽었는지, 아예 처음부터 존재하긴 했는지 알려면 상태를 체크하는 복잡한 코드가 추가로 필요해집니다.Visual Studio에서 아래 코드를 실행해 보면 바로 프로그램이 뻗는 걸 볼 수 있습니다.
int main() {
int array[1000000]; // 정수 100만 개 할당 (약 4MB 메모리 차지)
}
다행히도, 이 모든 골칫거리는 동적 메모리 할당을 통해 아주 쉽게 해결할 수 있습니다!
동적 메모리 할당이란 프로그램이 실행되는 도중에 "운영 체제야, 나 지금 메모리가 좀 필요한데 빌려줄래?" 하고 요청하는 방법입니다. 이 메모리는 비좁은 스택(Stack)이 아니라, 운영 체제가 넉넉하게 관리하는 힙(Heap)이라는 아주 거대한 메모리 창고에서 가져오게 됩니다. 요즘 컴퓨터에서 힙의 크기는 무려 기가바이트(GB) 단위랍니다!
변수 한 개를 동적으로 할당하려면(빌리려면), 배열이 아닌 일반 형태의 new 연산자를 사용하면 됩니다.
new int; // 정수용 메모리를 동적으로 할당 (그리고 결과는 버림)
위 코드는 운영 체제에게 정수(int) 하나를 담을 만큼의 메모리를 달라고 요청하는 것입니다. new 연산자는 그 메모리를 사용해서 공간을 만든 다음, 그 공간이 어디에 있는지 알려주는 '주소(포인터)'를 반환합니다.
보통은 이 반환된 주소를 잃어버리지 않게 포인터 변수에 잘 저장해 둡니다. 그래야 나중에 그 공간을 찾아갈 수 있거든요.
int* ptr{ new int }; // 정수 메모리를 동적으로 할당하고, 나중에 접근할 수 있게 그 주소를 ptr에 저장
이제 포인터가 가리키는 곳으로 찾아가서(역참조) 메모리를 사용할 수 있습니다.
*ptr = 7; // 할당받은 메모리에 7이라는 값을 저장
만약 지금까지 포인터를 왜 쓰는지 헷갈리셨다면, 이제 확실히 아시겠죠? 방금 빌린 넉넉한 메모리의 '주소'를 기억해둘 포인터가 없다면, 우리는 그 메모리를 두 번 다시 찾아가서 사용할 방법이 없기 때문입니다!
다만 주의할 점이 있어요. 힙(Heap)에 있는 데이터는 스택(Stack)에 있는 데이터보다 접근하는 속도가 살짝 느립니다. 스택에 있는 데이터는 컴파일러가 위치를 정확히 알고 있어서 바로 찾아가지만, 힙에 있는 데이터는 포인터라는 지도(주소)를 한 번 읽고, 그 주소로 다시 찾아가야 하는 두 번의 단계를 거쳐야 하기 때문이죠.
여러분의 컴퓨터에는 프로그램들이 쓸 수 있는 넉넉한 메모리가 있습니다. 프로그램을 실행하면 운영 체제가 이 메모리의 일부에 프로그램을 올려놓죠. 프로그램이 쓰는 메모리는 구역별로 역할이 나뉘어 있습니다. 어떤 곳은 코드가 들어가고, 어떤 곳은 함수나 지역 변수(스택)를 다루는 데 쓰입니다.
하지만 남아도는 엄청나게 많은 메모리는 누군가 "나 좀 쓸게!"라고 요청할 때까지 가만히 대기하고 있습니다.
여러분이 동적으로 메모리를 할당(new)한다는 것은, 운영 체제에게 "이 남는 메모리 중 일부를 내 프로그램 전용으로 예약해 줘"라고 부탁하는 것과 같습니다. 운영 체제가 수락하면, 그 메모리의 주소를 넘겨주죠. 이때부터 그 공간은 여러분 마음대로 쓸 수 있습니다.
그리고 다 쓰고 나면, 다른 프로그램이 쓸 수 있게 다시 운영 체제에 꼭 돌려줘야 합니다.
자동으로 정리되는 일반 메모리(스택)와 달리, 동적으로 빌린 메모리(힙)는 프로그램이 스스로 직접 요청하고 직접 반납해야 할 책임이 있습니다.
핵심 통찰
스택(Stack) 객체를 만들고 없애는 건 전부 자동으로 처리됩니다. 우리가 메모리 주소 같은 걸 신경 쓸 필요가 전혀 없죠.
하지만 힙(Heap) 객체는 자동으로 관리되지 않습니다. 우리가 직접 개입해야 해요! 다 썼을 때 정확히 어떤 객체를 부숴야 하는지 알기 위해, 우리는 그 객체를 가리키는 고유한 '메모리 주소'가 필요합니다.
new연산자를 사용하면 새로 만든 객체의 주소가 담긴 포인터가 튀어나옵니다. 우리는 이 주소를 잘 저장해 뒀다가, 나중에 데이터를 읽거나 다 쓰고 버려달라고(파괴해 달라고) 요청할 때 써먹어야 합니다.
동적으로 변수를 만들 때, 값을 텅 비워두지 않고 곧바로 원하는 값을 넣어서(초기화해서) 만들 수도 있습니다.
int* ptr1{ new int (5) }; // 직접 초기화(direct initialization) 사용
int* ptr2{ new int { 6 } }; // 유니폼 초기화(uniform initialization) 사용
동적으로 빌린 메모리를 다 썼다면, C++에게 "이 메모리 이제 다시 가져가도 돼!"라고 명확하게 말해줘야 합니다. 변수 하나를 반납할 때는 delete 연산자를 사용합니다.
// ptr이 이전에 new 연산자로 할당되었다고 가정합니다
delete ptr; // ptr이 가리키는 메모리를 운영 체제에 반환합니다
ptr = nullptr; // ptr을 빈 포인터(null pointer)로 설정합니다
delete라는 단어 때문에 무언가를 완전히 '삭제'해버린다고 오해하기 쉽지만, 사실 이 연산자는 아무것도 지우지 않습니다! 그저 우리가 빌려 썼던 방을 운영 체제에게 다시 "반납"할 뿐입니다. 그러면 운영 체제는 빈 방이 된 그 공간을 다른 프로그램이나 나중에 다시 쓸 수 있게 준비해 둡니다.
문법만 보면 마치 ptr이라는 변수 자체를 지우는 것처럼 보이지만, 절대 그렇지 않아요! 포인터 변수 ptr 자체는 여전히 살아서 존재하며, 다른 변수들처럼 새로운 값(예: nullptr)을 다시 넣어줄 수도 있습니다.
동적으로 할당받지 않은 엉뚱한 포인터에 대고 delete를 하면 프로그램에 아주 심각한 문제가 생길 수 있으니 꼭 주의하세요.
메모리를 반납(delete)하고 나면, C++는 반납된 메모리 안에 있던 데이터나 포인터의 값이 어떻게 될지 아무것도 보장해주지 않습니다. 대부분의 경우 운영 체제에 반납된 공간에는 원래 있던 숫자들이 그대로 남아있게 되고, 우리의 포인터 변수 역시 방금 반납해서 더 이상 우리 것이 아닌 그 메모리 주소를 여전히 가리킨 채로 남아있게 됩니다.
이렇게 더 이상 내 것이 아닌, 이미 반납된 메모리를 가리키고 있는 포인터를 댕글링 포인터(Dangling pointer)라고 부릅니다. (마치 끊어진 밧줄에 대롱대롱 매달려 있다는 뜻이죠!) 이 댕글링 포인터의 값을 읽으려 하거나 또 delete 하려고 하면, 프로그램이 어떻게 미쳐 날뛸지 모르는 '알 수 없는 행동(undefined behavior)'이 발생합니다.
#include <iostream>
int main() {
int* ptr{ new int }; // 정수를 동적으로 할당
*ptr = 7; // 해당 메모리 위치에 값을 넣음
delete ptr; // 메모리를 운영 체제에 반환. 이제 ptr은 허공을 가리키는 '댕글링 포인터'가 됨.
std::cout << *ptr; // 댕글링 포인터에 접근하면 알 수 없는 행동(undefined behavior) 발생
delete ptr; // 이미 반환한 메모리를 또 반환하려고 해도 알 수 없는 행동 발생.
return 0;
}
위 프로그램에서, 우리가 아까 넣었던 7이라는 숫자는 운 좋게 그대로 남아있을 수도 있지만, 다른 숫자로 바뀌어 버렸을 수도 있습니다. 더 무서운 점은, 그 메모리 공간이 이미 다른 프로그램의 차지가 되어버렸을 수도 있다는 거예요. 그런 남의 땅을 함부로 건드리려고 하면 운영 체제가 화를 내며 우리 프로그램을 강제로 종료시켜 버릴 겁니다.
메모리를 반납할 때 댕글링 포인터가 여러 개 생겨버릴 수도 있습니다. 다음 코드를 볼까요?
#include <iostream>
int main() {
int* ptr{ new int{} }; // 정수를 동적으로 할당
int* otherPtr{ ptr }; // otherPtr도 이제 같은 메모리 위치를 가리킴
delete ptr; // 메모리를 운영 체제에 반환. ptr과 otherPtr 둘 다 댕글링 포인터가 됨.
ptr = nullptr; // ptr은 이제 안전한 빈 포인터(nullptr)가 됨
// 하지만, otherPtr은 여전히 위험한 댕글링 포인터로 남아있음!
return 0;
}
이런 골치 아픈 일을 막기 위한 꿀팁들이 있습니다.
첫째, 여러 개의 포인터가 똑같은 동적 메모리 하나를 가리키게 만드는 일은 웬만하면 피하세요. 어쩔 수 없다면, 정확히 어떤 포인터가 이 메모리의 '진짜 주인(삭제할 책임이 있는 녀석)'인지, 그리고 어떤 포인터가 그냥 놀러 온 '손님(접근만 하는 녀석)'인지 명확히 구분해야 합니다.
둘째, delete로 포인터를 지웠다면, 그 포인터가 곧바로 사라질 상황이 아니라면 무조건 nullptr로 설정해서 비워두세요.
권장 사항
포인터를delete한 직후에 포인터 자체가 수명을 다해 사라지는 게 아니라면, 무조건nullptr(빈 포인터)로 설정하세요.
드문 일이긴 하지만, 운영 체제한테 메모리를 달라고 부탁했을 때 운영 체제가 "미안, 남은 공간이 없어!" 하고 거절할 수도 있습니다.
기본적으로 new가 실패하면 bad_alloc이라는 '예외(Exception)'가 터집니다. 아직 우리는 예외 처리 방법을 배우지 않았기 때문에, 이런 일이 생기면 프로그램은 그냥 픽 쓰러져서(크래시) 꺼져버릴 겁니다.
프로그램이 이렇게 픽픽 꺼지는 건 보통 원하지 않으실 테니, new가 실패했을 때 프로그램을 끄지 말고 조용히 '빈 포인터(null pointer)'를 반환하게 만드는 방법이 있습니다. new와 타입 사이에 (std::nothrow)라는 마법의 주문을 넣으면 됩니다.
int* value { new (std::nothrow) int }; // 정수 할당에 실패하면 value는 빈 포인터(null pointer)가 됨
만약 공간을 빌리는 데 실패하면, 주소 대신 null(아무것도 없음)이 들어옵니다.
주의할 점은, 실패해서 텅 비어버린 포인터를 억지로 읽으려고 하면 프로그램이 뻗어버린다는 것입니다. 그래서 가장 좋은 습관은 메모리를 달라고 요청한 뒤에 "진짜 제대로 빌렸나?" 하고 먼저 확인해 보는 것입니다.
int* value { new (std::nothrow) int{} }; // 정수 크기의 메모리를 요청
if (!value) // new가 null을 반환한 경우 (실패한 경우) 처리
{
// 여기서 에러 처리를 합니다
std::cerr << "메모리를 할당할 수 없습니다\n";
}
물론 최신 컴퓨터에선 메모리가 꽉 차서 실패하는 일이 거의 없기 때문에 (특히 연습할 때는 더더욱요), 이 확인 과정을 깜빡 잊기 아주 쉽답니다!
빈 포인터(아무것도 가리키지 않는 nullptr)는 동적 메모리를 다룰 때 엄청나게 유용합니다. 동적 할당의 세계에서 빈 포인터는 사실상 "나 아직 메모리 공간 못 받았어"라는 뜻과 같습니다. 덕분에 이런 식의 스마트한 코드를 짤 수 있어요.
// ptr이 아직 할당되지 않았다면, 새로 할당합니다
if (!ptr)
ptr = new int;
참고로 빈 포인터에 대고 delete를 하면 그냥 아무 일도 일어나지 않고 무사히 넘어갑니다. 따라서 굳이 아래처럼 복잡하게 쓸 필요가 없습니다.
if (ptr) // ptr이 빈 포인터가 아니라면
delete ptr; // 삭제해라
// 그렇지 않으면 아무것도 하지 마라
그냥 쿨하게 한 줄만 쓰면 됩니다.
delete ptr;
ptr에 진짜 메모리가 들어있다면 깔끔하게 반납될 것이고, 비어있는(null) 상태라면 아무 일도 일어나지 않을 테니까요.
권장 사항
빈 포인터(null pointer)를delete하는 건 완벽하게 안전하며 아무 일도 일으키지 않습니다. 굳이if문을 써서 검사할 필요가 없습니다.
동적으로 빌린 메모리는 우리가 명시적으로 "반납할게!"(delete)라고 하거나, 프로그램이 아예 끝날 때까지 끈질기게 자기 자리를 지킵니다. (물론 똑똑한 운영 체제가 끝나면 알아서 치워주긴 하지만요.) 하지만 이 메모리의 주소를 들고 있는 '포인터 변수' 자체는 일반 변수라서 함수가 끝나면 수명이 다해 사라져 버립니다. 여기서 아주 치명적인 엇박자가 발생합니다.
다음 함수를 한 번 보세요.
void doSomething() {
int* ptr{ new int{} };
}
이 함수는 안에서 정수 하나를 동적으로 빌리지만, delete를 통해 반납하지는 않고 있습니다. 포인터 변수 ptr은 그저 평범한 지역 변수일 뿐이라서, 함수가 끝나는 순간 뿅 하고 사라집니다. 문제는 ptr만이 그 빌린 메모리의 주소를 알고 있는 유일한 변수였다는 점이죠. ptr이 사라지면서, 프로그램은 동적으로 빌린 메모리가 어디 있는지 알 수 있는 지도를 영영 잃어버리게 됩니다. 지도가 없으니 영원히 찾아가서 반납할(delete) 수가 없는 상태가 돼버리는 거죠.
이런 끔찍한 상황을 메모리 누수(Memory leak)라고 부릅니다. 운영 체제에 돌려주지도 않은 채로 메모리 주소를 까먹어 버린 상황이죠. 우리 프로그램은 지도를 잃어버려서 지울 수 없고, 운영 체제 입장에서는 "아직 쟤네 프로그램이 저 공간 쓰고 있네"라고 생각해서 다른 곳에 빌려주지도 못합니다. 그야말로 우주 미아가 되어버린 공간이죠.
이런 메모리 누수들이 자꾸 쌓이면 프로그램이 빈 메모리를 야금야금 다 갉아먹게 됩니다. 우리 프로그램뿐만 아니라 컴퓨터에 켜져 있는 다른 프로그램들까지 쓸 메모리가 부족해지죠. 누수가 심한 프로그램은 메모리를 싹 다 먹어 치워서 컴퓨터 전체를 렉 걸리게 만들거나 심지어 블루스크린을 띄울 수도 있습니다. 프로그램이 완전히 종료되고 나서야 운영 체제가 빗자루를 들고 와서 잃어버렸던 메모리들을 싹 청소하고 되찾아갑니다.
포인터 변수가 사라질 때만 메모리 누수가 생기는 건 아닙니다. 메모리 주소를 들고 있던 포인터에 덮어쓰기로 다른 값을 넣어버려도 똑같이 지도를 잃어버리게 됩니다.
int value = 5;
int* ptr{ new int{} }; // 메모리 할당
ptr = &value; // 기존에 할당받은 주소를 잃어버림. 메모리 누수 발생!
이럴 때는 새 주소를 넣기 전에 원래 갖고 있던 메모리를 확실하게 먼저 반납해주면 됩니다.
int value{ 5 };
int* ptr{ new int{} }; // 메모리 할당
delete ptr; // 메모리를 운영 체제에 다시 돌려줌
ptr = &value; // 포인터에 value의 주소를 새로 지정
비슷한 이유로, 같은 포인터에 메모리 할당을 두 번 연속으로 해도 메모리 누수가 발생합니다.
int* ptr{ new int{} };
ptr = new int{}; // 예전 주소를 잃어버림. 메모리 누수 발생!
두 번째로 빌려온 메모리의 주소가 첫 번째 주소를 덮어써 버리기 때문에, 처음 빌렸던 메모리의 주소는 영영 잃어버리게 됩니다! 이 역시 덮어쓰기 전에 꼭 먼저 delete를 해줘서 예방해야 합니다.
new와 delete 연산자를 사용하면 프로그램에 필요한 단일 변수를 동적으로 빌리고 반납할 수 있습니다.다음 강의에서는 new와 delete를 사용해서 배열을 동적으로 만들고 지우는 방법에 대해 알아보겠습니다!
변수 하나를 필요할 때 그때그때(동적으로) 만들 수 있는 것처럼, 여러 변수가 이어져 있는 '배열'도 필요할 때마다 동적으로 만들 수 있습니다.
일반적인 '고정 배열'은 프로그램을 만들기 전(컴파일 타임)에 미리 그 크기를 딱 정해두어야 하죠. 하지만 배열을 동적으로 할당하면, 프로그램이 실행 중인 상태(런타임)에서 우리가 원하는 만큼 배열의 길이를 마음대로 정할 수 있답니다! (즉, 배열의 길이가 항상 고정된 상수, 즉 constexpr일 필요가 없다는 뜻이에요.)
일반적인 '고정 배열'은 프로그램을 만들기 전(컴파일 타임)에 미리 그 크기를 딱 정해두어야 하죠. 하지만 배열을 동적으로 할당하면, 프로그램이 실행 중인 상태(런타임)에서 우리가 원하는 만큼 배열의 길이를 마음대로 정할 수 있답니다! (즉, 배열의 길이가 항상 고정된 상수, 즉 constexpr일 필요가 없다는 뜻이에요.)
저자의 노트
이번 강의에서는 가장 흔하게 쓰이는 방식인 'C언어 스타일의 배열'을 동적으로 할당해 볼 거예요.
참고로 최신 C++ 기능인std::array를 동적으로 할당할 수도 있지만, 보통 이런 상황에서는 굳이 동적 할당을 직접 하기보다는 알아서 크기가 조절되는std::vector를 사용하는 것이 훨씬 편하고 좋습니다.
배열을 동적으로 만들려면, 일반적인 방법 대신 new와 delete의 배열 전용 버전
(보통 new[] 와 delete[]라고 부름)을 사용해야 합니다.
#include <cstddef>
#include <iostream>
int main()
{
std::cout << "Enter a positive integer: ";
std::size_t length{};
std::cin >> length;
int* array{ new int[length]{} }; // 배열 전용 new를 사용합니다. 길이가 상수일 필요가 없다는 점에 주목하세요!
std::cout << "I just allocated an array of integers of length " << length << '\n';
array[0] = 5; // 0번째 요소를 값 5로 설정합니다.
delete[] array; // 배열 전용 delete를 사용하여 배열 메모리를 해제합니다.
// 어차피 이 코드 직후에 범위를 벗어나기 때문에(스코프 종료), 여기서 array를 nullptr이나 0으로 설정할 필요는 없습니다.
return 0;
}
우리가 "배열을 만들어줘!"라고 대괄호([])를 사용해 명령했기 때문에, C++은 똑똑하게 일반 new 대신 배열용 new를 써야 한다는 걸 알아챕니다. 코드에서 new 글자 바로 옆에 []가 붙어있지는 않지만, 내부적으로는 new[] 기능이 작동하는 것이죠.
동적으로 만든 배열의 길이는 std::size_t라는 타입을 가집니다. 만약 일반 int 타입(상수가 아닌 것)을 길이로 사용하려고 하면, 컴퓨터가 "데이터가 손실될 수도 있어!"라며 경고를 보낼 수 있습니다. 이럴 때는 안전하게 static_cast를 써서 std::size_t로 타입을 바꿔주는 것이 좋습니다.
여기서 아주 중요한 사실이 있어요! 동적 배열은 일반 고정 배열과는 완전히 다른 넉넉한 공간에서 메모리를 빌려옵니다. 그래서 크기를 엄청나게 크게 만들 수 있죠. 위 프로그램을 직접 실행해서 배열 길이를 1,000,000(백만)이나 심지어 100,000,000(일억)으로 입력해도 문제없이 돌아갑니다. 한번 직접 해보세요!
이런 엄청난 장점 때문에, C++에서 메모리를 아주 많이 써야 하는 프로그램들은 대부분 이렇게 '동적 할당' 방식을 사용한답니다.
동적으로 빌려온 배열을 다 쓰고 나서 돌려줄(삭제할) 때는, 반드시 배열 전용 삭제 기호인 delete[]를 사용해야 합니다.
이것은 컴퓨터(CPU)에게 "변수 하나만 덜렁 지우는 게 아니라, 여러 개가 묶인 배열 전체를 싹 다 청소해야 해!"라고 알려주는 역할을 합니다. 초보 프로그래머들이 정말 자주 하는 치명적인 실수 중 하나가, 배열을 지울 때 delete[] 대신 그냥 delete를 써버리는 것입니다. 배열에 일반 delete를 쓰면 데이터가 꼬이거나, 메모리가 줄줄 새거나, 프로그램이 갑자기 튕기는 등 아주 끔찍한 일(정의되지 않은 동작)이 발생할 수 있습니다.
여기서 많은 분들이 궁금해하는 점이 있습니다. "컴퓨터는 배열을 지울 때 도대체 얼마나 큰 메모리를 지워야 하는지 어떻게 아는 걸까요?" 정답은, 처음에 new[]로 배열을 만들 때 컴퓨터가 몰래 '이 변수에 얼마나 큰 메모리를 빌려줬는지' 기록해두기 때문입니다. 그래서 나중에 delete[]를 부르면 알아서 딱 그만큼만 정확히 지워줍니다. 하지만 아쉽게도 프로그래머가 이 '숨겨진 길이 정보'를 코드에서 직접 꺼내 볼 수는 없습니다.
예전 '17.8 강의'에서 고정 배열은 '첫 번째 데이터가 있는 메모리 주소'를 들고 있다는 걸 배웠습니다. 또한, 배열이 첫 번째 데이터를 가리키는 '포인터'로 자연스럽게 변신(Decay)할 수 있다는 것도 배웠죠. 이렇게 포인터로 변해버리면 배열의 전체 길이나 크기(sizeof())를 알 수 없게 되지만, 그 외에는 작동 방식에 큰 차이가 없습니다.
동적 배열도 처음 태어날 때부터 아예 '첫 번째 데이터를 가리키는 포인터'로 시작합니다. 그래서 고정 배열이 포인터로 변했을 때처럼, 스스로 자기 길이가 얼마인지 모른다는 똑같은 단점을 가지고 있어요.
한마디로, 동적 배열은 포인터로 변신한 고정 배열과 기능적으로 완전히 똑같습니다. 단 하나, 프로그래머가 다 쓴 뒤에 직접 delete[]를 써서 청소해 줘야 한다는 책임감만 추가된 셈이죠!
동적으로 만든 배열의 모든 칸을 깔끔하게 0으로 채우고(초기화하고) 싶다면, 작성법은 아주 간단합니다. 끝에 빈 중괄호 {}만 붙여주면 돼요!
int* array{ new int[length]{} };
옛날 버전인 C++11 이전에는, 동적 배열에 0이 아닌 다른 숫자들을 한 번에 넣기가 무척 어려웠습니다. (여러 숫자를 한 번에 넣는 초기화 리스트는 고정 배열에서만 쓸 수 있었거든요.) 그래서 아래처럼 일일이 배열 칸을 하나씩 돌면서 값을 지정해 줘야만 했습니다.
int* array = new int[5];
array[0] = 9;
array[1] = 7;
array[2] = 5;
array[3] = 3;
array[4] = 1;
정말 귀찮고 번거로운 일이었죠!
하지만 C++11 버전부터는 드디어 동적 배열에도 중괄호 {}(초기화 리스트)를 써서 원하는 값들을 한방에 쏙쏙 넣을 수 있게 되었습니다!
int fixedArray[5] = { 9, 7, 5, 3, 1 }; // C++11 이전: 고정 배열 초기화 방식
int* array{ new int[5]{ 9, 7, 5, 3, 1 } }; // C++11 이후: 동적 배열 초기화 방식
// 똑같은 타입 이름(int)을 두 번씩 쓰기 귀찮다면 auto를 쓸 수도 있습니다. 이름이 긴 타입일 때 아주 유용해요.
auto* array{ new int[5]{ 9, 7, 5, 3, 1 } };
여기서 주의할 점은, 배열 길이([5])와 중괄호({...}) 사이에는 등호(=)가 들어가지 않는다는 것입니다.
코드를 헷갈리지 않고 일관성 있게 쓰기 위해, 일반 고정 배열을 만들 때도 위와 비슷한 중괄호 방식(균일 초기화)을 쓸 수 있습니다.
int fixedArray[]{ 9, 7, 5, 3, 1 }; // C++11에서 고정 배열 초기화하기
char fixedArray[]{ "Hello, world!" }; // C++11에서 고정 배열(문자열) 초기화하기
이때 대괄호 [] 안에 배열의 크기를 굳이 적어주지 않아도 컴퓨터가 알아서 세어주니 생략해도 괜찮습니다.
동적 할당을 사용하면 처음에 배열을 만들 때 우리가 원하는 만큼 길이를 정할 수 있죠. 하지만 아쉽게도 C++에는 '이미 만들어진 배열의 크기'를 나중에 고무줄처럼 쭈욱 늘리거나 줄이는 기능이 기본적으로 없습니다. 만약 크기를 꼭 바꿔야 한다면 어떻게 해야 할까요?
이런 복잡한 꼼수를 써야 합니다. 하지만 이 과정은 실수하기 딱 좋고, 데이터가 복잡한 객체(클래스)일 경우에는 만들어지는 규칙이 까다로워서 예상치 못한 오류가 뻥뻥 터지기 쉽습니다.
결론적으로, 이렇게 복잡하게 배열 크기를 직접 조절하려고 애쓰지 마세요! 크기가 유연하게 변하는 배열이 필요하다면 C++에서 제공하는 아주 똑똑한 도구인 std::vector를 대신 사용하는 것을 강력히 추천합니다. 복잡한 과정은 얘가 알아서 다 해주거든요!
소멸자(destructor)는 클래스로 만든 객체가 파괴될(사라질) 때 실행되는 또 다른 특별한 멤버 함수입니다. 쉽게 비유하자면, 생성자가 '방에 들어올 때 불을 켜는(초기화) 역할'이라면, 소멸자는 '방에서 나갈 때 불을 끄고 청소하는(정리) 역할'이라고 생각하시면 됩니다!
객체가 정해진 수명을 다해서 자연스럽게 사라지거나, 우리가 delete 키워드를 써서 직접 지웠을 때, 메모리에서 완전히 지워지기 직전에 필요한 뒷정리를 하도록 소멸자가 자동으로 불립니다. 단순한 값을 저장하는 기본 클래스들은 C++이 알아서 깔끔하게 치워주기 때문에 굳이 소멸자를 만들 필요가 없습니다.
하지만 만약 여러분의 클래스 객체가 특별한 자원(예: 동적 메모리 공간, 파일, 데이터베이스 등)을 쥐고 있거나 객체가 사라지기 전에 꼭 마무리해야 할 작업이 있다면 소멸자가 아주 유용하게 쓰입니다. 소멸자는 객체가 세상에서 사라지기 직전에 마지막으로 실행되는 곳이니까요!
생성자처럼 소멸자도 이름을 짓는 특별한 규칙이 있습니다:
~)를 붙여야 합니다.일반적으로는 코드에서 소멸자를 직접 부를(호출할) 필요가 없습니다. 객체가 죽을 때 알아서 불리기 때문이죠. 굳이 한 번 청소한 곳을 두 번 청소할 이유는 없으니까요! 하지만 소멸자가 실행되는 동안 객체가 아직 완전히 파괴된 것은 아니기 때문에, 소멸자 안에서 다른 멤버 함수를 부르는 것은 안전합니다.
소멸자를 사용하는 간단한 클래스를 코드로 살펴볼까요?
#include <iostream>
#include <cassert>
#include <cstddef>
class IntArray
{
private:
int* m_array{};
int m_length{};
public:
IntArray(int length) // 생성자: 객체가 태어날 때 실행됨
{
assert(length > 0);
m_array = new int[static_cast<std::size_t>(length)]{};
m_length = length;
}
~IntArray() // 소멸자: 객체가 사라질 때 실행됨
{
// 앞에서 동적으로 빌려왔던 배열 메모리를 깨끗하게 삭제(반납)합니다.
delete[] m_array;
}
void setValue(int index, int value) { m_array[index] = value; }
int getValue(int index) { return m_array[index]; }
int getLength() { return m_length; }
};
int main()
{
IntArray ar ( 10 ); // 10개의 정수 공간을 가진 배열을 할당합니다.
for (int count{ 0 }; count < ar.getLength(); ++count)
ar.setValue(count, count+1);
std::cout << "5번째 요소의 값은: " << ar.getValue(5) << '\n';
return 0;
} // 여기서 ar 객체의 수명이 끝나고 파괴되므로, ~IntArray() 소멸자 함수가 자동으로 불립니다!
팁
위 예제를 실행(컴파일)할 때 만약 다음과 같은 에러가 뜬다면:
error: 'class IntArray' has pointer data members [-Werror=effc++]|
error: but does not override 'IntArray(const IntArray&)' [-Werror=effc++]|
error: or 'operator=(const IntArray&)' [-Werror=effc++]|
이 예제를 실행할 때는 컴파일 설정에서-Weffc++플래그를 빼주시거나, 클래스 안에 다음 두 줄의 코드를 추가해 주시면 됩니다.
IntArray(const IntArray&) = delete;
IntArray& operator=(const IntArray&) = delete;
멤버에=delete를 사용하는 방법은 나중에 '14.14 - 복사 생성자 소개' 강의에서 자세히 다룰 예정이니 지금은 걱정하지 마세요!
이 프로그램을 실행하면 다음과 같은 결과가 나옵니다:
5번째 요소의 값은: 6
천천히 살펴볼게요. main() 함수의 첫 번째 줄에서 우리는 ar이라는 이름의 IntArray 객체를 만들고, 크기로 10을 넘겨주었습니다. 이때 생성자가 불리면서 배열을 위한 메모리 공간을 컴퓨터로부터 빌려옵니다(이것을 동적 할당이라고 합니다). 배열의 크기를 코드를 짤 때 미리 알 수 없고 사용하는 사람이 정하기 때문에, 이렇게 직접 메모리를 빌려와야만 합니다.
그리고 main() 함수가 끝날 때, ar 객체의 수명이 다해서 죽게 됩니다. 그러면 똑똑한 C++은 자동으로 ~IntArray() 소멸자를 부르고, 생성자에서 빌려왔던 메모리를 잊지 않고 깨끗하게 반납(delete)해 줍니다!
알림
지난 '16.2 - std::vector와 list 생성자 소개'에서, 배열이나 리스트 같은 컨테이너 클래스의 '길이'를 정해서 초기화할 때는 괄호()를 사용하는 것이 좋다고 배웠습니다. (요소들의 목록을 직접 넣을 때와 구분하기 위해서요!) 그래서 이 예제에서도IntArray ar ( 10 );처럼 괄호를 사용해 초기화했습니다.
앞에서 말했듯이, 생성자는 객체가 태어날 때 불리고 소멸자는 객체가 사라질 때 불립니다. 진짜로 그런지 cout (출력) 기능을 사용해서 언제 불리는지 직접 눈으로 확인해 보겠습니다.
#include <iostream>
class Simple
{
private:
int m_nID{};
public:
Simple(int nID)
: m_nID{ nID }
{
std::cout << nID << "번 Simple 생성 중 (Constructing)\n";
}
~Simple()
{
std::cout << m_nID << "번 Simple 소멸 중 (Destructing)\n";
}
int getID() { return m_nID; }
};
int main()
{
// 1번: 일반적인 방식(스택 메모리)으로 Simple 객체를 만듭니다.
Simple simple{ 1 };
std::cout << simple.getID() << '\n';
// 2번: 동적 할당(new 키워드)으로 Simple 객체를 만듭니다.
Simple* pSimple{ new Simple{ 2 } };
std::cout << pSimple->getID() << '\n';
// pSimple은 동적으로 빌려왔기 때문에, 우리가 꼭 delete로 직접 지워줘야 합니다.
delete pSimple;
return 0;
} // 1번 객체(simple)는 함수가 끝나는 여기서 수명이 다해 파괴됩니다.
이 프로그램을 실행하면 다음과 같은 결과가 나옵니다:
1번 Simple 생성 중 (Constructing)
1
2번 Simple 생성 중 (Constructing)
2
2번 Simple 소멸 중 (Destructing)
1번 Simple 소멸 중 (Destructing)
결과를 잘 보세요! '1번'이 먼저 만들어졌지만, 소멸(파괴)될 때는 '2번'이 먼저 죽고 '1번'이 나중에 죽었습니다. 그 이유는 우리가 delete pSimple; 코드를 통해 함수가 끝나기도 전에 '2번'을 직접 지워버렸기 때문입니다. 반면에 '1번(simple)'은 main() 함수가 완전히 끝날 때까지 살아있다가 마지막에 알아서 사라진 것이죠.
참고로, 전체 프로그램 어디서든 쓸 수 있는 전역 변수(Global variables)는 main() 함수가 시작되기도 전에 먼저 만들어지고, main() 함수가 끝난 후 제일 마지막에 파괴됩니다.
이름이 조금 어렵고 길죠? RAII(자원 획득은 초기화다)는 C++에서 아주 중요하게 쓰이는 프로그래밍 규칙입니다. 가장 핵심적인 아이디어는 "자원(메모리, 파일 등)을 사용하는 기간을 객체가 살아있는 기간과 똑같이 맞추자!"라는 것입니다.
C++에서는 이 훌륭한 아이디어를 생성자와 소멸자로 실천합니다. 객체가 태어날 때(생성자) 메모리나 파일 같은 자원을 빌려옵니다. 그리고 살아있는 동안 그 자원을 유용하게 씁니다. 마지막으로 객체가 죽을 때(소멸자) 쥐고 있던 자원을 자동으로 반납합니다.
이 RAII 방식의 가장 큰 장점은 컴퓨터의 자원이 새어나가는 것(메모리 누수)을 완벽하게 막아준다는 것입니다. 우리가 깜빡하고 자원 반납을 잊어버려도 객체가 죽을 때 알아서 청소해주기 때문이죠!
이 수업 맨 처음 살펴본 IntArray 클래스가 바로 이 RAII를 보여주는 완벽한 예시입니다. (생성자에서 메모리를 할당받고, 소멸자에서 반환했죠). 여러분이 즐겨 쓰시는 std::string이나 std::vector 같은 표준 기능들도 모두 이 똑똑한 RAII 방식을 따르고 있어서 알아서 메모리를 관리해 준답니다.
만약 코드 중간에 프로그램 작동을 멈추기 위해 std::exit() 함수를 사용하면, 프로그램이 바로 강제 종료되면서 어떤 소멸자도 불리지 않습니다. 소멸자가 로그를 남기거나 중요한 데이터를 저장하는 등 꼭 필요한 마무리 작업을 하도록 만들어 두었다면, 이 함수를 쓸 때 매우 주의해야 합니다! 작업이 마무리되지 않은 채로 프로그램이 꺼져버리니까요.
정리하자면, 생성자와 소멸자를 짝꿍처럼 잘 활용하면 클래스가 스스로 초기화도 하고 마무리 청소까지 알아서 척척 해냅니다. 개발자가 메모리를 지우는 걸 깜빡하는 등의 실수를 할 확률을 확 줄여주고, 클래스를 사용하는 사람 입장에서도 훨씬 편안하게 코드를 짤 수 있게 해주는 아주 고마운 기능입니다!
고급 학습자를 위한 내용
이 레슨은 선택 사항이며, C++에 대해 더 깊이 알고 싶은 분들을 위한 내용입니다. 앞으로의 레슨에서 이 내용을 몰라도 문제없으니 편하게 읽어보세요!
포인터의 포인터(이중 포인터)는 이름 그대로예요.
바로 '다른 포인터의 주소를 저장하는 포인터'랍니다.
일반적으로 정수(int)를 가리키는 포인터는 별표(*) 하나를 써서 만듭니다.
int* ptr; // int를 가리키는 포인터, 별표 한 개
int를 가리키는 포인터의 주소를 담는 '포인터의 포인터'는 별표를 두 개 씁니다.
int** ptrptr; // int 포인터를 가리키는 포인터, 별표 두 개
포인터의 포인터도 일반 포인터와 똑같이 작동해요. '역참조'(dereference, * 기호를 써서 가리키는 곳의 값을 가져오는 것)를 하면 원래 포인터가 가지고 있던 값을 꺼내올 수 있죠. 그리고 그 꺼내온 값 역시 포인터이기 때문에, 한 번 더 역참조를 하면 마침내 맨 처음 저장했던 진짜 숫자 값을 얻을 수 있습니다. 이렇게 역참조를 연달아 할 수 있어요.
int value { 5 };
int* ptr { &value };
std::cout << *ptr << '\n'; // int 포인터를 역참조해서 int 값을 가져옴
int** ptrptr { &ptr };
std::cout << **ptrptr << '\n'; // 한 번 역참조해서 int 포인터를 가져오고, 한 번 더 역참조해서 최종 int 값을 가져옴
위 프로그램을 실행하면 다음과 같이 나옵니다.
5
5
주의할 점은, 포인터의 포인터에 일반 변수의 주소를 다이렉트로 바로 넣을 수는 없다는 거예요.
int value { 5 };
int** ptrptr { &&value }; // 이렇게 하면 안 됩니다! (오류 발생)
왜냐하면 주소를 가져오는 & 연산자는 메모리에 자리가 확실히 있는 온전한 변수(lvalue)에만 쓸 수 있는데, &value 자체는 잠깐 생겼다 사라지는 임시 값(rvalue)이기 때문이에요.
하지만, 포인터의 포인터를 텅 비워둘 수는 있습니다 (null로 설정).
int** ptrptr { nullptr };
포인터의 포인터는 몇 가지 쓰임새가 있는데요. 가장 흔하게 쓰이는 곳은 바로 '포인터들을 담아두는 배열'을 동적으로 만들 때입니다.
int** array { new int*[10] }; // 10개의 int 포인터를 담을 수 있는 배열 할당
이건 우리가 아는 동적 배열과 똑같이 작동해요. 다만 배열 안에 들어가는 내용물이 단순한 숫자가 아니라, "정수를 가리키는 포인터"라는 점만 다릅니다.
포인터의 포인터를 쓰는 또 다른 흔한 이유는 다차원 배열을 동적으로 만들기 위해서예요 (다차원 배열이 기억나지 않는다면 이전 레슨인 '17.12 -- C스타일 다차원 배열'을 훑어보세요!).
크기가 정해져 있는 2차원 배열은 평소처럼 이렇게 쉽게 만들 수 있죠.
int array[10][5];
하지만 2차원 배열을 '동적'으로 만드는 건 조금 더 까다롭습니다. 아마 이렇게 직관적으로 해보고 싶으실 거예요.
int** array { new int[10][5] }; // 작동하지 않아요!
안타깝게도 이 코드는 에러가 납니다.
여기엔 두 가지 해결책이 있어요. 만약 배열의 맨 오른쪽 크기(여기선 5)가 변하지 않는 고정된 값(constexpr)이라면 이렇게 할 수 있습니다.
int x { 7 }; // 상수가 아님 (변할 수 있는 값)
int (*array)[5] { new int[x][5] }; // 맨 오른쪽 크기(5)는 반드시 고정된 상수여야 함
여기서 괄호 ()는 꼭 필요해요! 그래야 컴퓨터가 "아, 이 array는 5개의 int가 들어있는 배열을 가리키는 포인터구나"라고 알아듣거든요. 괄호가 없으면 컴퓨터는 int* array[5]로 읽어서, 'int 포인터 5개를 담은 배열'로 잘못 해석해 버립니다.
이럴 때는 컴퓨터가 알아서 타입을 맞춰주는 auto를 쓰면 정말 편합니다.
int x { 7 }; // 상수가 아님
auto array { new int[x][5] }; // 훨씬 간단하죠!
하지만 안타깝게도, 배열의 맨 오른쪽 크기가 컴파일할 때 정해진 상수가 아니라면 이 간단한 방법은 쓸 수 없어요. 그럴 때는 조금 복잡해집니다.
먼저 앞서 배운 대로 '포인터들을 담는 배열'을 만듭니다. 그런 다음, 반복문을 돌면서 각 포인터마다 또 다른 동적 배열을 달아주는 거예요. 즉, 우리의 동적 2차원 배열은 '동적 1차원 배열들을 엮어 놓은 동적 1차원 배열'이 되는 셈이죠!
int** array { new int*[10] }; // 10개의 int 포인터 배열을 할당합니다 — 이것들이 '행(가로줄)'이 됩니다.
for (int count { 0 }; count < 10; ++count)
array[count] = new int[5]; // 각각의 행마다 5개짜리 배열을 달아줍니다 — 이것들이 '열(세로칸)'이 됩니다.
이렇게 만들어 두면 평소처럼 배열을 쓸 수 있어요.
array[9][4] = 3; // 이건 (array[9])[4] = 3; 과 똑같은 뜻이에요.
이 방법을 쓰면 각각의 가로줄(행)을 따로따로 만들기 때문에, 굳이 네모 반듯한 직사각형 배열이 아니어도 괜찮아요. 예를 들어, 계단처럼 생긴 삼각형 배열도 만들 수 있습니다.
int** array { new int*[10] }; // 10개의 int 포인터 배열 할당 — 이것들이 '행'
for (int count { 0 }; count < 10; ++count)
array[count] = new int[count+1]; // 행 번호가 커질수록 배열 길이도 길어집니다 — 이것들이 '열'
위 코드에서 array[0]은 길이가 1인 배열, array[1]은 길이가 2인 배열... 이런 식으로 점점 길어지는 걸 볼 수 있죠.
다 쓰고 나서 이 방법으로 만든 2차원 배열을 지울(메모리 해제) 때도 똑같이 반복문이 필요해요.
for (int count { 0 }; count < 10; ++count)
delete[] array[count];
delete[] array; // 이 부분은 반드시 맨 마지막에 해야 합니다!
주의하세요! 배열을 지울 때는 우리가 만든 순서의 반대로 지워야 합니다 (안에 있는 알맹이 배열부터 지우고, 마지막에 껍데기 배열을 지워요). 껍데기(array)를 먼저 지워버리면, 그 안에 있던 알맹이 배열들의 위치를 찾을 길이 없어져서 컴퓨터가 엉뚱한 행동(정의되지 않은 동작)을 하게 됩니다.
이렇게 2차원 배열을 동적으로 만들고 지우는 건 복잡하고 실수하기 딱 좋아요. 그래서 가로 길이가 x, 세로 길이가 y인 2차원 배열이 필요하다면, 그냥 x * y 크기의 길쭉한 1차원 배열 하나로 쫙 펴서(flatten) 쓰는 게 훨씬 편할 때가 많습니다.
// 이렇게 복잡하게 하는 대신:
int** array { new int*[10] }; // 10개의 int 포인터 배열 할당 — 행
for (int count { 0 }; count < 10; ++count)
array[count] = new int[5]; // 열
// 이렇게 해보세요!
int *array { new int[50] }; // 10x5 2차원 배열을 50개짜리 1차원 배열 하나로 쫙 폅니다.
이렇게 1차원 배열로 펴놓고, 아주 간단한 수학 계산만 쓰면 2차원 느낌으로 몇 번째 줄, 몇 번째 칸인지 쉽게 찾아낼 수 있답니다.
int getSingleIndex(int row, int col, int numberOfColumnsInArray)
{
return (row * numberOfColumnsInArray) + col;
}
// 쫙 펴놓은 1차원 배열에서 [9][4] 위치에 3을 넣기
array[getSingleIndex(9, 4, 5)] = 3;
우리가 함수에 변수의 주소를 넘겨줘서 변수 값을 바꿀 수 있었던 것처럼, 포인터의 포인터를 함수에 넘겨주면 그 포인터가 가리키는 '포인터 자체'의 값을 바꿀 수도 있습니다 (벌써 머리가 아파오나요?).
하지만, 함수에서 포인터가 가리키는 대상을 바꾸게 하고 싶다면, 이중 포인터를 쓰는 것보다 포인터에 대한 참조(reference to a pointer)를 쓰는 게 훨씬 깔끔하고 좋습니다. 이 내용은 '12.11 -- 주소로 전달하기 (2부)' 레슨에서 다루고 있어요.
별표를 세 개 써서 포인터의 포인터의 포인터를 만드는 것도 가능하긴 해요.
int*** ptrx3;
이걸 쓰면 동적인 3차원 배열을 만들 수 있겠죠. 하지만 그러려면 반복문 안에 반복문을 또 넣어야 하고, 에러 없이 제대로 돌아가게 만들기가 엄청나게 복잡해집니다.
심지어 별표 4개짜리도 만들 수 있어요.
int**** ptrx4;
원한다면 그 이상도 얼마든지 덧붙일 수 있죠.
하지만 현실에서는 이렇게까지 겹겹이 포인터를 타고 들어갈 일이 거의 없기 때문에 잘 보지 못하실 겁니다.
어쩔 수 없는 상황이 아니라면, 포인터의 포인터는 되도록 쓰지 않는 것을 강력히 추천합니다. 쓰기 너무 복잡하고 자칫하면 위험해질 수 있거든요. 일반 포인터만 써도 텅 비거나 엉뚱한 곳을 가리키는 오류(null or dangling pointer)를 내기 십상인데, 이중 포인터는 값을 가져오려면 두 번이나 역참조를 해야 하니 그 위험성이 두 배로 커집니다!
void 포인터(제네릭 포인터, 또는 '만능 포인터'라고도 불러요)는 어떤 종류의 데이터 타입이든 가리지 않고 모두 가리킬 수 있는 아주 특별한 포인터입니다! 일반 포인터를 만들 때와 똑같이 선언하지만, 타입 자리에 void라는 키워드를 사용합니다.
void* ptr {}; // ptr은 void 포인터입니다
이 만능 포인터는 숫자(int), 소수(float), 심지어 직접 만든 복잡한 데이터(struct)까지 어떤 것이든 가리킬 수 있어요.
int nValue {};
float fValue {};
struct Something{
int n;
float f;
};
Something sValue {};
void* ptr {};
ptr = &nValue; // 가능 (valid)
ptr = &fValue; // 가능 (valid)
ptr = &sValue; // 가능 (valid)
하지만 치명적인 단점이 하나 있습니다! void 포인터는 자신이 '정확히 어떤 종류의 데이터'를 가리키고 있는지 스스로 알지 못해요. 그래서 별표(*)를 붙여서 그 안의 실제 값을 꺼내오는 행동(이것을 역참조라고 합니다)을 하면 에러가 납니다. 값을 꼭 보고 싶다면, 값을 꺼내기 전에 반드시 다른 포인터 타입으로 '변신(캐스팅, cast)'시켜주어야 합니다.
int value{ 5 };
void* voidPtr{ &value };
// std::cout << *voidPtr << '\n'; // 에러: void 포인터는 역참조(값을 꺼내오는 것)를 할 수 없어요!
int* intPtr{ static_cast<int*>(voidPtr) }; // 하지만, void 포인터를 int 포인터로 변환(캐스팅)해준다면...
std::cout << *intPtr << '\n'; // 변환된 결과값을 통해 정상적으로 실제 값을 꺼내올 수 있습니다
이 코드는 다음과 같이 출력됩니다:
5
그럼 당연히 이런 궁금증이 생기겠죠? "void 포인터가 자기가 뭘 가리키는지 모른다면, 도대체 무슨 타입으로 변환해 줘야 할지 우리가 어떻게 아나요?"
정답은... 프로그래머인 여러분이 직접 잘 기억하고 추적해야 한다는 것입니다!
실제로 void 포인터가 어떻게 쓰이는지 예시를 통해 볼까요?
#include <cassert>
#include <iostream>
enum class Type{
tInt, // 참고: "int"는 이미 예약된 키워드라 사용할 수 없어요. 대신 "tInt"를 사용합니다
tFloat,
tCString
};
void printValue(void* ptr, Type type){
switch (type)
{
case Type::tInt:
std::cout << *static_cast<int*>(ptr) << '\n'; // int 포인터로 변환(캐스팅)한 후, 값을 꺼내옵니다(역참조)
break;
case Type::tFloat:
std::cout << *static_cast<float*>(ptr) << '\n'; // float 포인터로 변환한 후, 값을 꺼내옵니다
break;
case Type::tCString:
std::cout << static_cast<char*>(ptr) << '\n'; // char 포인터로 변환합니다 (값을 따로 꺼내올 필요 없음)
// std::cout은 char*를 C스타일 문자열로 취급해서 전체를 출력해 줍니다
// 만약 여기서 별표(*)를 붙여 값을 꺼내왔다면, 문자열 전체가 아니라 맨 앞의 글자 하나만 출력되었을 거예요
break;
default:
std::cerr << "printValue(): invalid type provided\n";
assert(false && "type not found");
break;
}
}
int main(){
int nValue{ 5 };
float fValue{ 7.5f };
char szValue[]{ "Mollie" };
printValue(&nValue, Type::tInt);
printValue(&fValue, Type::tFloat);
printValue(szValue, Type::tCString);
return 0;
}
이 프로그램의 출력 결과입니다:
5
7.5
Mollie
void 포인터도 아무것도 가리키지 않는 텅 빈 상태(null)로 만들 수 있습니다.
void* ptr{ nullptr }; // ptr은 현재 null 포인터인 void 포인터입니다
앞서 void 포인터는 자신이 가리키는 데이터의 정체(크기)를 모른다고 했죠? 그래서 void 포인터에 대고 바로 delete를 사용해 메모리를 지워버리면, 컴퓨터가 얼만큼을 지워야 할지 몰라 예측할 수 없는 심각한 오류(미정의 동작)가 발생합니다. 만약 메모리를 해제해야 한다면, 반드시 먼저 알맞은 원래의 데이터 타입으로 static_cast를 통해 변신시켜준 다음에 지워야 해요.
마찬가지로 void 포인터로는 포인터 연산(포인터에 +1, -1을 해서 다음 칸으로 이동하는 것)도 할 수 없어요. 다음 칸으로 넘어가려면 지금 데이터가 메모리에서 얼만큼의 크기를 차지하는지 알아야 하는데, void 포인터는 그 크기를 모르기 때문입니다.
참고로...
C++에 'void 레퍼런스(참조)'라는 것은 존재하지 않아요. 만약 존재한다면void&같은 형태일 텐데, 자신이 참조하는 값이 어떤 타입인지 알 수 없는 빈 껍데기가 되어버리기 때문입니다.
결론적으로 말씀드리면, 정말 꼭 필요한 상황이 아니라면 void 포인터는 최대한 피하는 것이 좋습니다.
왜냐하면 컴파일러가 '이 데이터 타입이 맞는지' 검사해 주는 안전장치를 아예 꺼버리는 것과 같거든요. 실수로 전혀 말이 안 되는 코드를 짜더라도 컴파일러가 경고조차 해주지 않습니다. 예를 들어 아래 코드는 에러 없이 무사히 실행됩니다.
int nValue{ 5 };
printValue(&nValue, Type::tCString);
하지만 저 코드가 실제로 어떤 엉뚱한 결과를 뱉어낼지는 아무도 모르죠!
앞서 본 예시 함수(printValue)가 여러 데이터 타입을 한 번에 처리하는 멋지고 깔끔한 방법처럼 보일 수 있어요. 하지만 C++에는 타입 검사도 안전하게 다 해주면서 똑같은 기능을 구현할 수 있는 훨씬 훌륭한 방법인 함수 오버로딩(function overloading)이 있습니다. 오용을 막아주는 든든한 기능이죠.
또한, 예전에는 여러 타입을 다루기 위해 void 포인터를 썼던 많은 곳들이, 요즘은 강력한 타입 검사 기능을 제공하는 템플릿(templates)으로 훨씬 안전하게 대체되었습니다.
아주 가끔 프로그래밍을 하다 보면 void 포인터가 꼭 필요한 합리적인 상황을 만날 수도 있습니다. 하지만 그럴 때는 항상 "C++의 다른 안전한 언어 기능(오버로딩, 템플릿 등)으로 똑같이 해결할 방법은 없을까?"를 먼저 꼼꼼히 고민해 보시길 바랍니다!