10. 포인터 (2)
1. 포인터와 배열
이전의 C++ 8. 배열과 구조체 포스트에서 배열에 대해 설명할 때, 배열은 인접하는 메모리 영역을 차지한다고 언급했었다. 배열의 메모리 공산은 연속적인 형태를 가지며, 메모리의 주소 또한 연속적이다.
이를 이용하면 배열의 첫 번째 요소의 주소를 사용해 이후의 요소에 접근할 수 있다.
이 때 알아두어야 할 것이 포인터 주소 연산의 특징이다. 포인터의 주소에 대해서 연산을 진행할 때, 일반적인 정수의 덧셈과는 조금 다르게 이루어진다.
가령, int* pint = nullptr
라는 포인터가 있다면, pint
는 0의 주소값을 가지고 있을 것이다. 이 때 pint
에 2를 더하면 어떻게 될 까?
일반적인 정수라면 변수에 2를 더해진 값으로 연산이 진행 될 것이다. 하지만 포인터의 경우 “주소”를 나타내는 변수이기 때문에 2를 더한다는 것은 “2개의 객체 뒤의 주소” 를 의미한다.
따라서, pint
에는 2가 아닌 int
자료형의 데이터 크기 4바이트를 곱하여 + 8
이 연산된다.
int* pint = nullptr; // 주소: 0x0000000000000000
pint += 2; // 주소: 0x0000000000000008
이는 배열에서의 포인터 사용에 중요하게 쓰인다.
배열의 경우, 배열의 이름은 해당 배열의 첫 번째 요소의 주소값을 가진다. 즉, Array[0] == *Array
이며, 배열 선언 시 동시에 배열의 이름으로 포인터가 선언된다고 생각할 수 있다.
이를 다시 해석해보면, Array[n]
은 “배열 Array
의 시작 주소에서 n
만큼 이동하라” 라는 의미로 볼 수 있다.
위의 포인터 주소 연산과 같이 생각해보면 배열 안 요소의 주소 연산이 이해가 쉬울 것이다.
2. 포인터 응용
-
포인터의 캐스팅
이전 포스트 C++ 9. 포인터 (1) 에서 void 포인터에 대해 설명할 때 다음과 같이 설명했다.
“하지만 void 포인터는 해석을 포기해버렸기 때문에 간접 참조 연산자 *를 이용한 접근이 불가능하며, 다른 포인터 변수에도 (원칙적으로는) 할당이 불가능하다.”
포인터는 선언된 자료형과 일치하는 포인터만 할당할 수 있다는 특징을 가지고 있고, void 포인터는 어떤 형태의 포인터든지 전부 받아내기에 다른 포인터로의 할당이 컴파일 단계에서 막혀 있다.
그럼 다른 포인터로 할당도 못하는 void 포인터를 어떻게 사용하라는 건지, 다른 자료형의 주소값을 읽어서 의도치 않은 오류가 발생할 수 있다는 가정은 왜 했던 건지 의문이 생길 것이다.
c++ 컴파일러에서 원칙적으로는 다른 자료형의 포인터에 할당이 불가능한 것은 맞지만, c++은 프로그래머의 자율성을 보장해 주기 위해 이러한 문법을 무시하고 할당할 수 있는 방안을 마련해 두었는데, 그 방법이 “캐스팅(형변환)” 이다. 캐스팅에 관하여 다루면 설명할 내용이 많아지니 추후에 별도로 다루는 것으로 하고, 간단하게 이름 그대로 “형을 바꿔준다” 라는 의미로 이해하고 있으면 된다.
캐스팅은 기본적은 지료형들 외에도 포인터에서도 사용이 가능하다. 포인터가 선언된 자료형을 강제로 바꾸어 할당시킬 수 있도록 하며, 이를 통해 void 타입 포인터도 다른 자료형의 포인터에 할당할 수 있다.
캐스팅은 변수 앞에 (자료형 또는 자료형*)
의 형태로 사용이 가능하다.
int a = 20;
void* pvoid = &a;
int* pa = (int*)pvoid; //void 타입 포인터 pvoid를 int 타입 포인터로 캐스팅, 그 후 int 타입 포인터 pa에 할당.
이렇게 캐스팅을 통해서 문법을 무시하는 것은 c++에서 “예외적인 상황”에서 쓰라고 미리 마련해 둔 우회 루트 같은 것이기에, 적재적소에 필요할 때만 사용하자.
-
함수의 반환과 입력에서의 포인터
함수를 사용할 때, 반환 타입을 명시하여 선언한다는 것을 기억할 것이다. 함수는 스택 메모리 공간에서 생성 및 소멸되고, 함수가 종료될 때 반환값을 cpu의 레지스터 메모리에 잠시 넣어서 스택 메모리에서 소멸되어도 반환값을 호출한 곳에 전달할 수 있도록 한다.
또한 함수에서 입력값을 받을 때, 스택 메모리 공간에 함수의 공간을 할당받고, 그 안에 입력값에 해당하는 변수의 공간을 만든다.
이 경우 함수의 반환값이나 함수의 입력값이 일반적인 원시 자료형이 아닌 엄청 큰 바이트 용량을 가지는 구조체라고 생각해보자.
이러한 경우, 레지스터 메모리가 감당하기에 너무 크거나 입력값의 공간을 할당해서 복사 받기에 너무 많은 공간을 차지해 비효율적이라는 문제가 발생한다.
이러한 문제를 해결하기 위해 두 가지 의문이 들 수 있을 것이다:
- 함수의 반환을 레지스터 메모리를 거치지 않고 직접 전달할 수는 없나?
- 함수가 입력받는 값을 공간을 할당해서 복사하지 않고 그냥 직접 접근해서 사용할 수는 없나?
첫번째 경우 다음과 같이 구현이 가능하다:
void Solution1(int _a, int _b, int* _out) {
...
}
함수를 호출하고 입력값을 받는 과정에서, 호출자가 호출 공간에 미리 반환을 받을 변수를 준비해 두고, 이 주소를 입력값으로 받는 것이다.
이 경우 함수에서는 포인터로써 메모리 주소를 이용해 간접 참조를 수행하며 함수는 반환값이 void 로 지정되어 없으므로 레지스터 메모리를 거치지 않고 직접 전달이 가능해진다.
두번째 경우도 비슷하게 구현이 가능하다:
void Solution2(void* _c, void* _d) {
int* pint1 = (int*)_c;
int* pint2 = (int*)_d;
...
}
함수의 입력값에서 함수 내에서 사용될 객체를 포인터로써 받으면 된다.
이 경우 함수에서 해당 객체를 사용할 때 해당 주소를 간접 참조하여 사용하므로 스택 메모리의 공간을 효율적으로 사용하는 데 도움이 된다.
-
함수 포인터
함수 또한 포인터를 이용해 입력받을 수 있으며, 이를 이용해 특정 함수를 구현할 때 상황에 따라 다른 함수를 사용하거나 함수를 입력인자로써 받고 싶을 경우 함수 포인터로써 받을 수 있다.
함수 포인터는 반환타입(포인터 변수 이름)(입력인자 타입)
의 형태로 선언되며, 아래의 예시와 같이 구현할 수 있다.
void bubblesort(int& _arr) {
//...
}
void selectsort(int& _arr) {
//...
}
void quicksort(int& _arr) {
//...
}
void sort(int& _arr, void(*_sortfunc)(int&)) { // 입력 인자로 배열과 정렬 알고리즘 함수를 받는 정렬함수
//...
}
3. const 키워드와 포인터
-
const 키워드
const 키워드는 “constant(상수)” 의 줄임말로, 키워드 뒤에 오는 객체 또는 변수를 “상수”로써 취급한다.
그래서 const 를 이용해 선언되는 변수는 이후에 할당 등을 통해서 값을 변경할 수 없고, 오직 값을 가져올 수만 있다.
다만 진짜 상수가 되는 것은 아니다. 엄밀히 말하면 c++에서 문법적으로 살수로써 ‘취급’ 하는 것이고 상수에 해당하는 문법의 보호를 받는 것이다. 진짜 상수(예를 들어 10, 3.14 같은 수)는 “코드 상에 존재” 하는 것이며, 상수로 취급되는 const 변수의 경우 컴파일 과정에서 컴파일러가 “이 변수는 이제 상수로써 취급한다” 라고 기억해 두는 것이다.
이를 포인터에서 사용 시 컴파일러가 문법적으로 상수로써 취급하기 때문에 평범하게 &
연산자로 주소를 가져와서 접근하는 것은 문법 오류로 취급된다. 사용자가 const 변수에 주소로 접근하여 값을 변경시켜 버리면 상수 취급의 의미가 사라져 버리기 때문이다(컴파일러는 바보가 아니다).
하지만 앞의 포인터의 캐스팅 부분에서 언급했듯이 프로그래머가 강제로 캐스팅해서 문법을 무시하고 할당을 하는 것은 가능하다.
const int a = 10;
int* pint = &a; //문법 오류가 발생, 컴파일 불가능
int* pa = (int*)&a; //캐스팅을 통한 예외적 문법 무시, 컴파일 가능
*pa = 30; //30이 a에 할당된다
다만 이 경우 출력함수를 사용하면 30이 아닌 10이 출력되게 된다.
std::cout << a << std::endl; //10 출력
printf("%d", a); //10 출력
그 이유는 아까 말했듯이 컴파일러가 “이 변수는 이제 상수로써 취급한다” 라고 기억해 두는 것이기 때문이다. 최적화를 위해, 출력 단계에서 a
의 값을 직접 가져오는 것이 아니라 미리 레지스터 메모리에 기억해 둔 상수값 10을 가져오는 것이다. 컴파일러 입장에서는 a
는 상수로 취급되었고, 변경 될 일이 없기 때문이다(변경한 것은 다시말하지만 사용자가 문법을 무시해가며 강제로 변경한 것이다).
-
volatile 키워드
이를 해결해 주는 키워드로 volatile 이라는 키워드를 const 앞에 붙여 사용하면 컴파일러에게 “이 변수는 언제든 외부적 요인으로 인해 그 값이 변경될 수 있으니, 최적화를 수행하지 말고, 변수 참조 할 때마다 매번 해당 메모리를 참조해서 받아가라” 라고 전달해 준다.
volatile 키워드는 현재 프로그램의 작동 흐름과 관계 없이 외부적 요인으로 변수 값이 변경될 수 있을 때 자주 사용된다.
주로 사용되는 경우는 다음과 같다:
- 하드웨어 제어
- MMIO(Memory-mapped I/O)
- 인터럽트 서비스 루틴
- 멀티 스레드 환경
-
const 포인터
포인터와 const 키워드를 사용 시, const 키워드의 위치에 따라 포인터가 동작하는 방식이 달라지게 된다.
앞서 설명한 바와 같이 const 키워드는 뒤에 오는 객체 또는 변수를 “상수”로써 취급한다고 했다. 이를 염두해 두면 내용의 이해가 쉬울 것이다.
const 키워드가 포인터와 사용되는 경우는 3가지이다. 포인터 자료형의 앞에 붙는 경우, 포인터 변수 앞에 붙는 경우와 두 경우가 모두 해당되는 경우이다.
코드로 나타내면 다음과 같다:
//case 1
const int* i;
//case 2
int* const i;
//case 3
const int* const i;
첫번째 케이스의 경우 const 는 *i
를 상수화한다. *i
가 상수 취급되므로 j = *i
와 같이 주소 i
에 접근하여 내용을 가져오는 것은 가능하지만, *i = k
와 같이 “상수”로 취급되는 *i
의 값을 변경시키는 것은 불가능하다.
단, 주소를 저장하고 있는 i
자체는 상수로 취급되지 않으므로 포인터가 가리키는 주소는 다른 변수의 주소를 받아서 변경하는 것이 가능하다.
정리하면 다음과 같다:
- 포인터가 가리키는 메모리 주소 안의 데이터 변경 불가
- 하지만 다른 주소를 가리키도록 변경은 가능
보통 첫번째 케이스는 함수에서 입력값을 받을 때, 함수 사용자에게 “이 함수는 사용자가 입력하는 주소의 데이터를 변경하지 않고 그냥 읽기만 할 것입니다” 라고 명시 해 주는 용도로 많이 사용된다.
(함수 내부에서 캐스팅으로 강제 변경이 되지만, 이는 함수 사용자의 뒤통수를 때리는 짓이니 이러한 코드는 지양하자.)
두번째 케이스는 i
를 상수화한다. 첫번째 케이스와 달리 이번에는 “주소” 자체가 상수화된 것이기에 해당 포인터는 처음에 초기화 될 때 할당받은 메모리 주소만을 가리키게 된다.
이후에는 메모리 주소를 바꿔 다른 주소를 가리킬 수는 없지만, 메모리 주소 안에 있는 데이터는 변경이 가능하다.
정리하면 다음과 같다:
- 처음에 초기화 될 때 할당받은 주소만을 가리키며, 이후에 변경 불가
- 하지만 가리키는 해당 주소 안의 데이터는 변경 가능
세 번째 케이스는 위 두 케이스를 합친 것으로, 가리키는 주소도 변경할 수 없고 안의 데이터도 변경할 수 없다.