* 바쁘신 분들을 위한 3줄 요약
1. +, - , < , > 등의 연산자는 프로그램 밖에서도 실제로 사용해봤지만, 포인터 연산자(*, &)는 그렇지 않습니다.
2. 포인터 연산자( * )를 가지고 Type에 사용, 변수에 사용을 혼용한다.
3. 포인터 변수를 선언과 동시에 초기화 할 때, 동작을 오해하기 쉽다.
포인터(Pointer)는 왜 어려울까요? C나 C++을 처음 배우는 사람들의 대부분은 Pointer에서 큰 장벽을 만나고는 합니다.
요즘은 python같이 high level로 프로그래밍을 하게 되면 메모리 주소를 건들지 않고도 개발을 쉽게 할 수 있지만, 그래도 수많은 초보자들의 발목을 잡는 유명한 Pointer 자체에 대해서 한 번은 언급하고자 합니다.
제 처음 강의의 기억들과 책의 내용을 참조하여 만든 한 초보자의 C++ 코딩 입문기 이야기를 들어보시기 바랍니다.
1. Console창에 Hello world를 찍어본다.
#include <iostream>
using namespace std;
int main(){
cout << "hello world!" << endl;
return 0;
}
코딩을 하나도 모르는 저는, C++ 초급강의를 듣게 되었습니다. 내용이 어려워 보여 우선은 강사가 시키는 대로 합니다. 이미 세팅이 다 되어있는 컴퓨터에, 강사가 알려준 코드를 그대로 적고 F5 key를 누릅니다. #include, <iostream>, using, namespace, std, int main(), cout, <<, endl, return 0과 같은 무수히 많은 이해되지 않는 영어기호들 사이에, "hello world!" 라는 유일하게 현실세계에서도 사용하는 문법이 존재합니다. 이 부분을 "hello"로 바꿔서 실행해보니 잘 됩니다. 생각대로 움직이니 참 신기합니다.
2. 다양한 데이터 형식을 배우고, 사칙연산과 같은 여러 연산자를 적용해 본다.
#include <iostream>
#include <string>
using namespace std;
int main(){
bool flag = true;
flag = false;
string text = "abcde";
int a = 1;
long long b = 2;
b++;
a = b + 3;
cout << a << endl; // 6
cout << flag << endl; // 0(false)
cout << text << endl; // abcde
}
int, bool, short, long long, string 등 다양한 데이터 형식을 배워봅니다. string을 쓰기 위해서 #include <string> 이 추가된 것이 뭔가 어렵지만 현실세계에서도 사용해 봤던 + - / * = 연산자들이 등장해서 그나마 이해하기 쉽습니다. 쉬운 문법과 함께 점점 코딩에 빠져들기 시작합니다. 곱하기가 X가 아니라 *를 쓰는 것도 왠지 익숙합니다.
3. 조건문(if, else, switch, case)을 배운다.
int a = 10;
if(a == 10){
cout << "number is ten" << endl;
} else if(a < 10){
cout << "number is under ten" << endl;
} else {
cout << "number is over ten" << endl;
}
외워야 하는 것들이 슬슬 많아지기 시작합니다. if else는 영어를 할 줄 알아서, 무슨 뜻인지 직관적으로 이해가 됩니다. == 연산자는 처음 봅니다. 알고 보니 예전에 배웠던 "=" 는 대입한다는 뜻이고, "=="가 같다는 뜻이라는군요. 좀 바보 같습니다. 같다를 a = b로 두고, 대입을 a ← b라고 했으면 좀 더 오해 없이 코드를 작성했을 텐데 말이죠. 아 물론 제가 if(a = 10)으로 작성해서 그런 건 아닙니다.
4. 반복문(for, while)을 배운다.
int a = 3;
for(int i=0; i<a; i++){
cout << i << endl;
}
while(a>0){
cout << a << endl;
a--;
}
for문은 익숙해지기 어렵습니다. i가 어떻게 변화되는지 직관적으로 알기도 어렵고, 자꾸 for(i=0, i<a, i++)로 쓰게 됩니다. 그래도 익숙해지면 세미콜론을 실수 없이 적게 될 것입니다. while은 자꾸 무한 루프에 빠지는 게 좀 난감합니다. 그래도 노력하면 어떻게든 이해되는 선에서 동작을 하니 참 재밌습니다.
5. 배열(Array)을 배운다. (+Vector)
#include <iostream>
#include <vector>
using namespace std;
int main(){
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for(int arr_element : arr){
cout << arr_element << endl;
}
vector<int> v;
for(int i = 0; i < 10; i++){
v.push_back(i*10);
}
for(int v_element : v){
cout << v_element << endl;
}
return 0;
}
오늘은 Array를 배우는 날입니다. 분명 arr[10]으로 시작했는데 arr[0]부터 arr[9]까지 사용라고 합니다. 누가 이렇게 바보같이 만들었을까요? arr[10]은 arr[1]부터 arr[10]이어야만 할 것 같은데 말이죠. 익숙해질 때까지 익혀보도록 합니다. 그다음에는 살짝 vector를 소개해줍니다. vector<int>란 단어가 멋있어서 호감이 갑니다. for문도 이제는 익숙해서 vector와 같이 사용해봅니다.
6. 소문으로만 듣던 포인터(Pointer)를 배운다.
갑자기 강사가 한숨을 쉬기 시작합니다. 많이들 어려워하는 구간이라고 해서 긴장이 좀 됩니다. 하지만 괜찮습니다. 저는 vector도 쓸 줄 아는 사람이니깐요.
#include <iostream>
using namespace std;
int main() {
int x = 10;
int *ptr = &x;
*ptr = 20;
cout << "x = " << x << endl; // x = 20
return 0;
}
죄송합니다. 하나도 모르겠습니다. 저 *기호는 분명히 곱하기였는데 왜 갑자기 데이터 형식을 나타내는 int와 변수명 ptr 사이에 끼어들었는지 모르겠습니다. 게다가 &는 and라는 뜻 아닙니까? 그런데 갑자기 변수 앞에 붙어버리니, 변수의 메모리 주소값을 받아오는 연산자라고 합니다. 그러면서 강사님이 갑자기 메모리를 그리기 시작합니다. 메모리 한가운데에 x라는 변수가 있다고 하고는 x의 변수가 위치한 곳에 0xABCDEF같은 이상한 숫자를 적기 시작합니다. 이게 x의 주소라고 합니다. 사실 여기까지는 시간이 걸리긴 했지만 무슨 뜻인지는 이해했습니다.
하지만 결국 저는 int *ptr을 이해하지 못하고, 강의를 마무리하였습니다. int *ptr = &x라고 했으니 *ptr에는 0xABCDEF라는 주소가 들어갔을 거 같은데, 그다음줄에서는 *ptr에 x주소값이 아닌, x value를 변경하고 있습니다. 이해하기 어려운 부분이라 강사님의 말을 최대한 깔끔하게 정리해서 다음과 같이 요약해 보았습니다.
[강사님]
1. &에 대한 설명 : 변수(value)에 &를 쓰면 주소(address)를 받아옵니다.
2. *에 대한 설명 : 주소(address)를 할당받은 변수(pointer)에 *를 쓰면 그 주소(address)에 있는 값(value)을 확인 및 변경할 수 있습니다.
이에 따른 제 생각은 다음과 같습니다.
[학생이 포인터 개념을 정리하면서 드는 생각]
1. 변수에 & 사용 → 주소값 확인
2. 주소에 * 사용 → 변숫값 확인
3. x는 변수 → &x는 주소
4. *ptr를 변수처럼 취급 → 따라서 역으로 ptr은 주소. 따라서 위의 예시 코드에서는 ptr은 x의 주소.
정리하니 뭔가 이상합니다. 그렇다면 애초에 int ptr = &x로 적었어야 하는 것이 아니었을까요? ptr을 주소로 쓰려 하니 그대로 &x를 입력하면 그만일 텐데요. 저는 무엇을 잘못 이해하고 있는 걸까요? 이해가 다 되지도 않았는데 강사님은 갑자기 int* ptr, int *ptr, int * ptr 이 3가지 표현이 다 같은 거라고 합니다. 이게 포인터를 이해하는데 무슨 도움이 되는 걸까요? 다음 강의에는 Array에도 포인터를 써보고, 참조형 변수를 배운다고 하는데, 벌써부터 겁이 납니다. 결국, 너무나도 어려운 Pointer 문법은 저에게 프로그래밍의 흥미를 사라지게 하는데 충분했습니다.
위의 학생은 C++ 강의를 잘 듣다가 포인터에서 결국 흥미를 잃고 포기하게 됩니다. 포인터에서 좌절하는 이러한 문제는 저뿐만 아니라 대다수가 겪는 문제일 것입니다. 위 예시에서 아래와 같은 의미가 담긴 문장은 강조되었습니다.
1. 현실에서의 경험과 프로그래밍 문법이 서로 섞여 들어가면서 충돌할 때.
2. 코드를 직관적으로 읽으면 본인이 작성하는 게 맞는 것 같은데, 프로그램은 전혀 안 따라 줄 때.
포인터는 위의 두 가지 문제를 다 가지고 있지만 사실 더욱 큰 문제를 가지고 있습니다.
보통의 코드는 선언시 초기화 할 때, 그리고 다시 값을 대입 할 때 비슷한 문장으로 읽게 됩니다.
int a = 0; // 숫자로 초기화
a = 1; // 숫자를 대입
하지만 포인터는 좀 다릅니다. 1번 문장에서는 주소로 초기화 하고, 2번 문장에서는 숫자를 대입합니다.
int a = 10;
int *a_ptr = &a; // 1번 문장. a의 주소로 초기화
*a_ptr = 20; // 2번 문장. 숫자를 대입
앞으로는 1번 문장과 2번 문장으로 명칭해서 설명드리겠습니다.
1번 문장에는 *a_ptr에 주소로 초기화 하고 2번 문장에는 *a_ptr에 값을 넣는 것처럼 보입니다.
이 부분이 포인터의 첫인상을 어렵게 만드는 주범입니다. 좀 더 정확한 원인은 다음과 같습니다
1번 문장, 2번 문장 둘 다 같은 * 연산자를 사용하고 있지만,
1번 문장에서는 타입 선언, 2번 문장에서는 포인팅 동작으로 쓰입니다.
1번 문장에서 포인터를 왜 쓸까요?
int a_ptr = &a
라는 문장으로 a_ptr에 a 주소를 넣으면 그만 아닐까요? 어차피 주소는 숫자 아닌가요?
아닙니다! C/C++에서는 메모리 주소값을 int 변수로 초기화하는 것을 금지하고 있습니다
메모리 주소값은 오직 포인터 형식 변수에 초기화할 수 있습니다.
그리고 포인터 형식 변수에 int를 저장할 수 없습니다.
포인터 형식 변수는, 오로지 주소를 저장하는 것을 목적으로 합니다.
위의 문장이 마음속에 강력하게 자리 잡는다면 아래의 코드가 왜 안되는지 알 수 있습니다.
int a_ptr = &a
a_ptr는 정수형식이고, a의 주소로 초기화 하려 했으니 안됩니다. 주소값이 숫자인 것은 맞지만 형식은 주소값입니다, 주소값 형식은 int에 저장할 수 없다는 규칙이 있습니다.
1번 문장의 이해를 돕기 위해, 초기화와 대입을 나누어 다음과 같이 분리될 수 있습니다
int a = 10;
// 1번 문장을 분리
int *a_ptr; // a_ptr는 int변수 주소Type임을 선언(int pointer type)
a_ptr = &a; // a_ptr에 int변수인 a의 주소를 저장
// 2번문장
*a_ptr = 20; // a_ptr가 보관하는 주소(a주소)에 있는 값에 20이란 숫자를 대입
문장을 나눠 버리니 이해가 좀 더 쉬워졌습니다.
1. 1번 문장의 나눠진 첫 번째 문장은 a_ptr이 포인터 형식으로 선언,
2. 다음 문장은 포인터로 선언된 변수에 a주소를 대입하고 있습니다.
이 두 문장을 합쳐버리면서 * 사용법 해석에 문제가 생겨버린 것입니다.
필자는 Type 선언과 초기화 문장이 합쳐진 문법이 Pointer를 이해하기 어려운 주된 원인이라 생각합니다.
1번 문장이 이해가 되었다면 이제 2번 문장은 쉽게 이해가 될 것입니다.
int a = 10;
int *a_ptr = &a; // 이때의 *는 포인터 선언을 의미
*a_ptr = 20; // 이때의 *는 포인터가 가진 주소의 값을 불러오는 의미
이렇다 보니 포인터 선언 부분을 해석을 하는 데는
int *a_ptr = &a;
보다는
int* a_ptr = &a;
로 보는 것이 더욱 이해하기 쉽습니다. 형식은 int형 포인터이고, a_ptr에 &a값을 초기화하는 느낌이 더 강하기 때문입니다. 이런 이유로 강사들은 int *a_ptr이나 int* a_ptr이나 int * a_ptr이 전부 같다고 하는 것이었습니다. 하지만 실제로 권장되는 pointer 문법 띄어쓰기는 여전히 다음과 같습니다.
int *a_ptr = &a;
마지막으로, 포인터 예제로 항상 나오는 유명한 swap 함수를 해설하면서 이 글을 마치도록 하겠습니다.
#include <iostream>
using namespace std;
void swap(int *a, int *b){
int temp = *b;
*b = *a;
*a = temp;
return;
}
int main(){
int num1 = 10, num2 = 20;
cout << "num1 : " << num1 << ", num2 : " << num2 << endl; // num1 : 10, num2 : 20
swap(&num1, &num2);
cout << "num1, num2 is swapped" << endl; // num1, num2 is swapped
cout << "num1 : " << num1 << ", num2 : " << num2 << endl; // num1 : 20, num2 : 10
return 0;
}
pointer를 배우면 이 swap 함수 예제를 배우면서 마무리를 합니다. 함수에 숫자만 던져주면 그 주소에 있는 변수를 수정할 수 없기 때문에 주소값을 던져준다는 의미로 말이죠, 하지만 포인터를 처음 접하는 사람들에게는 swap(&num1, &num2)와 swap(int *a, int *b)에서 이미 멘탈이 터질 것입니다. 복사 붙여넣기를 할 수는 있어도, 의미를 완전히 이해하지 못하면 포인터를 제대로 활용하지 못합니다.
이번에는 제가 macro를 사용하여 * 연산자의 의미를 보다 쉽게 알려드리는 코드를 작성해 보았습니다. 천천히 읽으면서, * 연산자가 어떻게 변환 되었는지 확인해 보시기 바랍니다.
/**
* Macro 기능을 활용하여 실제로 돌아가는 pointer 예제 코드를 만들었습니다.
* 하지만 실사용성이 없으니, 개념 이해를 위한 코드로만 사용합시다.
*/
#include <iostream>
#define visit(X) *X // X는 변수입니다. 주소에 접근해 값을 얻습니다.
#define address(X) &X // X는 변수입니다. 변수의 주소를 얻습니다.
#define addrType(T) T* // T는 type입니다. 주소 변수를 선언할 때의 문법 입니다.
using namespace std;
void swap(addrType(int) a, addrType(int) b){ // int값이 저장된 주소 2개를 받습니다.
int temp = visit(b); // int형 temp를 선언한 후, b주소에 방문해서 얻은 int값으로 초기화 합니다.
visit(b) = visit(a); // b주소에 방문해서, a에 방문해서 얻은 값을 대입합니다.
visit(a) = temp; // a주소에 방문해서, temp에 아까 저장한 값을 대입합니다.
return;
}
int main(){
int num1 = 10, num2 = 20;
cout << "num1 : " << num1 << ", num2 : " << num2 << endl;
swap(address(num1), address(num2)); // swap함수 형식대로 주소 2개를 넘겨줍니다.
cout << "num1, num2 is swapped" << endl;
cout << "num1 : " << num1 << ", num2 : " << num2 << endl;
return 0;
}
어떤가요? 선언할 때의 포인터 연산자와, 방문할때의 포인터 연산자를 구분하니 코드가 훨씬 쉬워 보이지 않나요?
포인터를 어려워하시는 분들이 이 글을 읽고, 포인터 개념의 이해도가 생겼으면 하는 바램입니다. 감사합니다.