C++ 포인터 기초
목차
드디어 12회만에 C++의 핵심인 포인터에 대한 주제로 넘어왔습니다.
포인터가 무엇인지 설명하기 전에 데이터를 저장할 때 컴퓨터가 알아야 할 것에 대해서 생각해 보겠습니다. 컴퓨터의 원리란 엄청 어렵게 느껴지지만 사실 알고나면 단순한 부분이 있습니다.
컴퓨터는 인간의 논리와 비슷하게 작동합니다. 비슷하다는 것은 인간은 감정이 어떤 의사결정에 영향을 미치지만 컴퓨터는 순수하게 참과 거짓 0과 1로 로직을 결정합니다. 사실 대부분의 경우 인간 사회에서도 이분법적으로 따지기 때문에 그리 이해못할 일은 아닙니다.
컴퓨터가 어떤 데이터를 저장할 때 알아야 할 세가지를 알아보겠습니다.
우선 돈 100만원을 정수형으로 저장해보겠습니다. 이것은 정수형에서 100만을 표현할 수 있어야 합니다. char 형과 short 형에는 100만을 저장할 수 없고 -+ 21억까지 저장 가능한 4바이트 int형을 사용해야 합니다
정수형 -> 어떤 타입을 저장해야 하는가? (사이즈)
다음은 어디에 저장할지 결정해야 합니다. 메모리겠죠. 값을 저장하는 메모리는 크게 스택과 힙으로 나눌 수 있습니다.
메모리의 어디에 저장하는가? 스택? 힙?
그밖에 어떤 종류의 정보인지 메타 데이터가 필요합니다.
정수/문자열/배열/구조체/클래스 등 (특성 적인 부분)
C++의 포인터는 데이터를 조작하기 위해서 위 세가지 정보를 사용합니다.
그럼 포인터의 기초인 주소 연산자 부터 알아보겠습니다.
주소 연산자
변수에는 주소가 있습니다. 우리가 변수를 만들 때 식별자 (identifier)를 사용합니다. 예를 들어 int var1; 의 var1은 식별자입니다. 이 식별자는 컴파일러가 다른 변수들과 구분하는데 사용하는 것이지 실제의 주소와는 상관이 없습니다. 실제 주소를 가지고 변수 선언을 해야 한다면 0000 0000 과 같은 16진수 주소를 사용할 것입니다. 식별자 없이 주소로 변수를 사용한다면 이렇게 하겠죠. int 0000 0000 = 10; 0이라는 메모리 주소에 4바이트를 확보하고 10을 넣는다.
그러나 변수는 int var1 과 같이 사용합니다. 즉 var1 -> 0000 0000 으로 매칭되는 관계가 컴파일러 내부에서 맺어진다는 말입니다.
& 주소연산자는 이를 다시 복구합니다. &var1 은 0000 0000 을 불러옵니다.
이 관계를 이해하는 것이 메모리 관리의 시작입니다.
아래 예제는 주소연산자 &의 예제입니다.
#include <iostream> #include <string> using std::cout; using std::endl; using std::string; int main() { char myChar = 'A'; int myInt = 10; double myDouble = 10.5; string myString = "Hello CPP!"; cout << "- char type value : " << myChar << ", memory address : " << (void *) &myChar << endl; cout << "- int type value : " << myInt << ", memory address : " << &myInt << endl; cout << "- double type value : " << myDouble << ", memory address : " << &myDouble << endl; cout << "- string type value : " << myDouble << ", memory address : " << &myString << endl; return 0; }
- char type value : A, memory address : 0x61ff0e - int type value : 10, memory address : 0x61ff08 - double type value : 10.5, memory address : 0x61ff00 - string type value : 10.5, memory address : 0x61fee8
cout 객체는 char 일 경우 문자임을 인식해서 자동으로 아스키 코드 문자를 출력하고 나머지는 숫자형을 출력합니다.
메모리 주소는 4바이트 16진수로 표현됩니다. 64비트 애플리케이션은 8바이트 주소를 사용합니다. 4바이트는 메모리 주소를 4기가까지 표현할 수 있습니다.
여기서는 4바이트 16진수가 주소연산자로 찾아온 주소값입니다.
포인터는 이 주소연산자를 사용합니다.
포인터 선언
포인터를 선언하면서 식별자의 주소 연산자를 대입하면 4바이트의 주소값이 포인터에 전달됩니다.
포인터는 식별자의 주소를 알고 있으므로 메모리에 접근하여 그 안에 저장된 값도 가져올 수 있습니다. 포인터는 말 그대로 식별자의 주소를 가리키고 있는 것 입니다.
포인터에도 int 형이건 double 형이건 데이터타입은 지정해줘야하는데 실제 포인터에는 4바이트 주소가 들어간다고 했습니다. 때문에 포인터 타입을 알 수 없는 경우 void 로 지정해줘도 됩니다. 어쨋든 주소를 저장하는데는 4바이트가 들어갑니다.
#include <iostream> #include <iomanip> #define LOG(x) std::cout << " - " << std::setw(5) << std::left << #x << " : " << x << std::endl #define LINE(s) std::cout << "\n[ ------- " << #s << " ------- ]" << std::endl int main() { int var = 10; int* ptr = &var; LINE(value); LOG(var); LOG(*ptr); LINE(address); LOG(&var); LOG(ptr); return 0; }
[ ------- value ------- ] - var : 10 - *ptr : 10 [ ------- address ------- ] - &var : 0x61ff08 - ptr : 0x61ff08
포인터를 이해하려면 머리속에 메모리의 이미지 모델을 가지는게 좋습니다. 하나의 변수는 하나의 메모리 주소와 값에 연결되어 있습니다. 4바이트 포인터에서 메모리는 0000 0000 부터 시작해서 FFFF FFFF 까지 약 42억개의 바이트 주소를 가지고 있습니다.
이제 이 안의 내용물을 조작하는 것이 바로 C++의 메모리 관리와 포인터의 모든 것 입니다.
메모리안에는 현재 프로세스 뿐 아니라 운영체제의 프로세스나 다른 실행중 프로세스, 데몬 프로세스 등이 돌아가고 있기 때문에 잘못 조작했을시에는 책임이 큽니다. 그러므로 포인터에 대해서는 A부터 Z까지 철저하게 알아둬야 합니다.
포인터란?
다시 짧은 코드로 보면 포인터는 변수의 주소를 저장한 변수입니다. 값을 가리키기 위해선 역참조(dereference) 연산자인 * 를 사용합니다. 선언시에도 *를 사용하는데요. 그러다 보니 초기화 할당과 초기화 후의 할당은 다르게 보이는 부분에 주의합니다.
int var = 10; void* ptr = &var;
동적 메모리 할당
동적 메모리 할당과 대비되는 것은 정적 메모리 할당입니다. 둘의 차이는 정적 메모리 할당은 컴파일 시 메모리의 크기가 정해진 다는 것이고(static) 동적 메모리 할당은 런타임(실행시간)에 크기가 정해진 다는 것 입니다.
프로그램이 실행된 후 다양항 사용자의 요구사항을 수용하고 효율적인 메모리 사용을 위해서는 동적 메모리 할당이 필요합니다. 예를 들어 정적 메모리 할당으로 100 바이트를 할당했다고 합니다. 프로그램 실행 도중에 바뀔수가 없으므로 1바이트를 사용할 때도 100바이트 메모리를 차지하고 있을 것이며 200바이트가 필요하더라도 100바이트밖에 사용할 수 없습니다.
동적 메모리 할당(dynamic) 에서는 언제든지 메모리를 생성하고 해제할 수 있습니다. 동적으로 메모리를 할당하는 것은 어떤 변수에도 사용할 수 있는데요. 특히 포인터를 사용하여 메모리를 할당하면 거의 투명하게 메모리를 조작할 수 있게 됩니다. 그리고 포인터를 여러개 연결시켜서 복잡한 자료구조도 구현할 수 있습니다.
아래의 예제는 동적으로 메모리를 할당하는 코드입니다. 특히 배열과 비교를 해보면 포인터와 같다는 것을 알 수 있는데요. 이는 배열의 작동 방식이 포인터와 같기 때문입니다. 배열의 인덱스는 그 데이터형의 바이트와 곱하여 메모리 주소간 이동이 가능합니다.
#include <iostream> #include <iomanip> #include <cstring> #define LOG(x) std::cout << " - " << std::setw(10) << std::right << #x << " : " << x << std::endl #define LINE(s) std::cout << "\n[ ----- " << #s << " ----- ]" << std::endl #define JUST_PUT(a) std::cout << a << " " int main() { int* dArray = new int[5]; memset(dArray, 0, 5); // 배열의 사용방식과 같다. dArray[0] = 20; dArray[1] = 30; dArray[2] = 40; dArray[3] = 50; dArray[4] = 60; LINE(Pointer); for (size_t i = 0; i < 5; i++) { JUST_PUT(dArray[i]); /* code */ } LINE(Array); int myArray[5] = {0, }; // 포인터의 사용방식과 같다. *(myArray+0) = 15; *(myArray+1) = 30; *(myArray+2) = 45; *(myArray+3) = 60; *(myArray+4) = 75; // myArray[0] = 15; // myArray[1] = 30; // myArray[2] = 45; // myArray[3] = 60; // myArray[4] = 75; for (size_t i = 0; i < sizeof myArray/4; i++) { JUST_PUT(myArray[i]); /* code */ } LINE(address and values using pointer); LOG(dArray); LOG(*(dArray+0)); LOG(*(dArray+1)); LOG(*(dArray+2)); LOG(*(dArray+3)); LOG(*(dArray+4)); LINE(address and values using array); LOG(myArray); LOG(myArray[0]); LOG(myArray[1]); LOG(myArray[2]); LOG(myArray[3]); LOG(myArray[4]); delete[] dArray; return 0; }
[ ----- dynamic allocation ----- ] - myString : 0xfd1750 - *myString : 0
new 연산자로 동적 메모리를 할당하고 memset 으로 초기화를 시켜줍니다. new 로 메모리를 할당하면 항상 delete 로 메모리를 해제시켜주는 코드가 쌍으로 들어가야 합니다. 메모리를 반환하지 않거나 혹은 제때에 메모리를 해제하지 못할 경우 메모리 낭비가 발생합니다. 또는 메모리 누수(memory leak) 현상이 올 수 있습니다.
메모리 관리에 실패하면 개별 프로세스 뿐 아니라 주변 프로세스나 심하면 운영체제까지 영향을 미칠 수 있습니다. C++에서 delete 를 적절한 곳에 배치하는 것은 대단히 중요합니다. 지금은 하드웨어 성능이 좋아졌지만 그래도 메모리를 최대한 아껴써야 합니다. 특히 여러 스레드를 열어야 하는 백앤드에서 메모리 관리에 실패하면 손실이 큽니다.
메모리를 관리할 줄 모르면 차라리 C++을 사용하지 않는게 낳습니다. 다시말해 C++를 사용하려면 포인터와 메모리를 최대한 활용해야 합니다.
그리고 이 코드들에 #define 매크로를 사용했는데 이는 출력양식을 정해놓고 반복을 쉽게 하기 위해서입니다.(귀차니즘 때문에)
배열과 포인터
배열과 포인터를 좀 더 자세히 비교해봅니다.
#include <iostream> #include <iomanip> #include <cstring> #define LOG(x) std::cout << " - " << std::setw(15) << std::right << #x << " : " << x << std::endl #define LINE(s) std::cout << "\n[ ----- " << #s << " ----- ]" << std::endl #define JUST_PUT(a) std::cout << a << " " int main() { int* dArray = new int[5]; memset(dArray, 0, 5); // 배열의 사용방식과 같다. dArray[0] = 20; dArray[1] = 30; dArray[2] = 40; dArray[3] = 50; dArray[4] = 60; LINE(Pointer); for (size_t i = 0; i < 5; i++) { JUST_PUT(dArray[i]); /* code */ } LINE(Array); int myArray[5] = {0, }; // 포인터의 사용방식과 같다. *(myArray+0) = 15; *(myArray+1) = 30; *(myArray+2) = 45; *(myArray+3) = 60; *(myArray+4) = 75; // myArray[0] = 15; // myArray[1] = 30; // myArray[2] = 45; // myArray[3] = 60; // myArray[4] = 75; for (size_t i = 0; i < sizeof myArray/4; i++) { JUST_PUT(myArray[i]); /* code */ } LINE(address and values using pointer); LOG(dArray); LOG(*(dArray+0)); LOG(*(dArray+1)); LOG(*(dArray+2)); LOG(*(dArray+3)); LOG(*(dArray+4)); LINE(address and values using array); LOG(myArray); LOG(&myArray[0]); LOG(myArray[0]); LOG(myArray[1]); LOG(myArray[2]); LOG(myArray[3]); LOG(myArray[4]); delete[] dArray; return 0; }
[ ----- Pointer ----- ] 20 30 40 50 60 [ ----- Array ----- ] 15 30 45 60 75 [ ----- address and values using pointer ----- ] - dArray : 0xef1008 - *(dArray+0) : 20 - *(dArray+1) : 30 - *(dArray+2) : 40 - *(dArray+3) : 50 - *(dArray+4) : 60 [ ----- address and values using array ----- ] - myArray : 0x61fef0 - &myArray[0] : 0x61fef0 - myArray[0] : 15 - myArray[1] : 30 - myArray[2] : 45 - myArray[3] : 60 - myArray[4] : 75
결과창에서 확인해보면 포인터를 사용하여 주소와 값을 처리하는 방식과, 배열을 사용하여 주소와 값을 처리하는 방식을 알 수 있습니다. 특히 배열은 myArray 가 첫번째 주소입니다. 즉 &myArray[0]과 같습니다. 포인터로 하면 (myArray+0) 인 것입니다.
기호 사용이 익숙치 않으면 굉장히 까다롭게 보일텐데요. 포인터를 사용하는 순간부터 기호 하나 하나를 정확히 읽을 수 있어야 합니다.
C++ 을 배우면 나머지 대부분의 언어들은 언제든지 마음만 먹으면 쉽게 배울 수 있을 것입니다. 특히 포인터의 문법이 가장 어려운데요. ** 같이 역참조를 두번한다던가 ( ) 괄호를 막 여러개 씌우고 연산자를 중첩시킬 수 있기 때문에 점점 읽기 어려워집니다.
(물론 읽기 어렵게 만드는 코드가 좋다는 것은 아닙니다)
요약과 강의 추천
C++ 포인터 기초를 전체적으로 빨리 흝어봤습니다. 디테일하게 설명하기 보다 전체 그림이 이렇다고 스윽 지나가는 듯한 내용으로 작성했는데요. 약간 수박겉핥기 일수도 있습니다. 직접 코드를 치고 컴파일을 해보지 않으면 이해가 어려울 수도 있습니다. 모든 프로그래밍은 실습을 해야 하죠. 참 프로그래밍을 배운다는게 쉬운 일이 아닙니다.
특히 포인터 관련해서는 문법 자체가 복잡하고 메모리에 대한 개념이 머리속에 없는 상황에서는 그냥 뜬구름에 불과한 말 들입니다.
다행히 인터넷에는 이미 많은 양질의 자료와 강의 영상이 공유되고 있습니다.
포인터에 대해 뼛속까지 알고 싶은 분들은 아래 참고문서와 유튜브 영상의 수업을 참고하시길 바랍니다. freeCodeCamp의 포인터 강의는 Full Course 하나가 3시간 40분에 달할 정도로 내용이 많습니다. 애초에 블로그 포스팅 한장만 읽어서는 C++ 포인터 세계에 대하여 이해할 수 없습니다.
추천 강의는 NewBoston 의 Bucky 와 The Cherno , javidx9 의 포인터 강의(아래 참고 영상)는 매우 추천합니다. 이들은 전세계 인터넷 강의 레전드 입니다.
특히 NewBoston 같은 경우 IT계열 E-Learning 시장의 판도를 바꿔놓을 정도의 영향을 미친 것으로 유명합니다. 그들의 특징은 전부 5분 10분 이내에 대학교 수업에서 2시간 동안 강의하는 것을 압축해서 전달한다는 것입니다. 정말 놀라운 강의입니다.
최근에 보기 시작한 호주의 The Cherno 가 좀 세련된 Pointer 강의를 하는 편이고,
인도 IT 강사 Telusko의 강의는 인도 스타일 답게 원초적인 의문을 해결하며 설명을 잘하는 것으로 유명합니다. Telusko는 파키스탄 등 인도와 친화적인 국가에서 많은 팬들이 있습니다.
확실히 이런 원초적인 공학 수업은 인도사람들이 탁월한 것 같습니다. 인도 강의들은 대체적으로 어떤 배경지식이 없이 들을 때 좋은 강의들이 많고 미국이나 유럽쪽 사람들은 어느정도 지식이 있다는 것을 전제로 세련된 말투로 강의를 하는게 특징입니다.
C++이지만 C의 포인터 수업을 들어도 전혀 상관이 없는데 C언어나 C++이나 똑같은 메모리에서 작업을 하는 것이기 때문입니다. C++이 객체지향을 제외한 부분에서 C를 계승하기도 하기 때문에 대부분 C라이브러리도 C++에서 사용이 가능합니다.
포인터를 사용하는 방식은 클래스 등 객체지향 프로그래밍을 제외하고 거의 같기 때문에 C의 포인터 수업을 들어도 상관이 없습니다.
포인터에 대해서는 이 C++ 자습서 시리즈에서도 계속적으로 다룰 것 입니다. 왜냐하면 거의 모든 C++의 주제에서 포인터를 사용합니다. 함수에서 클래스에서 자료구조에서 모두 포인터를 사용합니다. 상황에 따라 포인터를 사용하는 방법이 조금씩 달라지기 때문에 어떤 식으로든 계속 언급될 수 밖에 없습니다. C++ 포인터 기초는 그냥 전체 모습을 찰나에 보고 지나가는 것에 불과합니다.
이제 포인터 부터 C++은 어려워집니다. 여기서 부터는 많은 사람들이 중도에 포기한 길입니다. C++과정을 마치면 자기가 정말 IT기술과 코딩을 사랑하는지 확인할 수 있을 겁니다.
IT업계 인지도가 이전에 비해서 올라가고 있지만 그럼에도 대다수 사람들에겐 아직도 열정이 없이는 버티기 힘든 일이기 때문입니다. C++의 건투를 빕니다.
PS – 메모리에 관해 하고 싶은 말을 쓰다보니 좀 길어졌습니다만, 여기에 다 못담은 이야기는 참고문서와 영상을 잘 활용해서 학습하시길 바랍니다.
참고문서
Pointers – C++ Tutorials (cplusplus.com)
C++ Pointer Operators – Tutorialspoint
참고영상
Pointers in C / C++ [Full Course] – YouTube
What Are Pointers? (C++) – YouTube
Buckys C++ Programming Tutorials – 38 – Introduction to Pointers – YouTube
Pointers in C Theory – YouTube
첫번째 코드에서 myChar의 주소를 가져올때 왜 앞에 (void*)를 붙이나요?
c++ cout의 트릭 같은 것인데 (void*)를 안붙이면 주소가 아니라 아스키 문자인 ‘A’가 출력됩니다. 메모리 주소를 얻기 위해서 (void*)를 붙입니다.