이번에는 c언어에서 가장 중요하지만 사람들이 많이 어려워 하는 개념인 '포인터'에 대해서 알아보자.
포인터(Pointer)란 "메모리 주소"를 저장하는 변수이다. (말 그대로 주소'만' 저장할 수 있다)
예를 들어, int num = 10; 이라는 변수를 선언했다고 가정해보자.
그렇다면 메모리 공간 중 하나에 10을 저장하고 있을 것이고, 메모리 공간의 주소가 있을 것이다.
메모리 주소를 1000 이라고 한다면 변수 이름은 num, 값은 10, 주소는 1000이 된다.
여기서 포인터는 1000이라는 주소를 저장하는 변수이다.
포인터 변수를 선언하는 방법은 int* ptr; 와 같이 포인터 변수를 선언할 때는 담고자 하는 자료형에 *를 붙여서 선언한다.
int *ptr; int* ptr; int * ptr; 과 같이 별의 위치 상관없이 모두 동일하다. (본인이 편한 방식으로 사용하면 된다)

포인터 변수에 & 주소 연산자를 사용해서 다른 변수의 주소를 가져와 대입하였다.

num의 주소와 포인터 변수에 저장된 주소가 같은 것을 확인할 수 있다.
포인터의 크기는 자료형이 아니라 운영체제 시스템에 따라 크기가 달라진다.
32비트 시스템의 경우 4바이트로, 64비트 시스템이면 8바이트로 모두 동일한 크기를 가지고 있다.
자료형에 따라 선언하는 자료형이 달라지는 이유는 가리킬 주소가 어떤 자료형을 갖는지 알려주기 위함이다.
포인터 연산을 할 때에는 그 주소로 찾아가 자료형에 맞게끔 사용하여야 한다.
포인터 변수는 주소값을 담는 변수이기 주소가 아닌 다른 값으로 초기화 할 수 없다.
예외로 NULL(0) 로도 초기화가 가능하지만, 변수를 초기화하지 않았을 때의 초기값은 쓰레기값이 들어가 있다는 것을 알고 있을 것이다. 그러므로 선언후 바로 다른 변수의 주소값을 넣더라도 되도록이면 NULL로 초기화 해주는 것을 권장한다
포인터는 그저 주소를 저장하는 것으로 끝나는 것이 아닌, 그 주소에 접근하여 변수를 직접 변경 시킬 수 있는 강력한 기능이 있다.
A변수의 주소를 저장한 포인터 변수에 접근하면 결국 A와 포인터 변수는 링크되어 있기 때문에 A의 값도 변경할 수 있다는 것이다.
주소에 접근하여 값을 불러올 때는 역참조 연산자(*)을 사용하면 된다.

num의 주소가 저장되어 있는 ptr에 접근(*)하여 *ptr = 200; 문장으로 num의 값을 200으로 변경하였다.

코드를 실행하면 직접 num을 변경하지 않았음에도 불구하여 num의 값이 200으로 변경된 것을 확인할 수 있다.
여기서 포인터의 선언, 접근, 초기화 등 *을 붙이는 방법에 대해 다시 한번 정리하자면

결론적으로 *은 선언할 때와 값에 접근할 때만 붙여서 사용한다.
역참조 연산자 *도 결국 연산자이므로, 연산자 우선 순위로 인해 다른 값이 도출되는 경우도 발생한다.
예를 들어 (*ptr)++과 *ptr++ 연산의 차이는 어떻게 될지 알아보자

예시로 int형 배열 arr1과 arr2를 선언하고 각 원소로 10, 20, 30으로 초기화 했다.
그리고 포인터 변수 p1과 p2를 선언하고 각각 arr1, arr2 배열의 첫번째 주소를 대입하였다.
(배열의 첫번째 주소는 변수 앞에 &를 붙이지 않아도 된다는 것은 알고 있을 것이다)
이후 (*p1)++, *p2++ 연산을 하고 각 배열의 원소와 포인터 변수가 가리키는 값을 출력하였다.
코드를 실행해보면

왜 이런 결과가 나왔을까?
이것은 연산자 우선순위에 의해 값이 변경된 결과이다.
(*p1)++의 경우 먼저 포인터 변수 p1에 접근(*) 하여 1을 증가(++)했기 때문에
10이 저장된 arr[0]을 참조한 *p1에 1을 증가시켜 arr[1]은 11이 저장되므로, *p1을 출력하면 11이 출력된다.
*p2++의 경우 증감 연산자(++)가 역참조 연산자(*)보다 우선순위가 높으므로 먼저 p2에 저장되어 있는 arr2의 주소에 1을 증가시킨다.
C언어 기초-16 <배열> 편에서 다루었던 내용인데, 배열의 주소는 연속되어 있기 때문에 배열의 자료형 만큼 메모리가 할당된다.(여기서 배열의 주소에 1을 증가시켰다는 것은 int형 배열에서는 4byte만큼 떨어져 있다는 것을 의미한다)
arr2 배열의 첫번째 주소가 1000이라고 가정하면, p2에 저장된 주소값 또한 1000이 저장되어 있고, 여기에 1을 증가(++) 시켰으므로 4byte가 떨어진 1004의 주소를 가리킬 것이다.
최종적으로 역참조 연산자(*)를 사용하여 1004의 주소에 '접근'했으므로 *p2를 출력하면 배열의 2번째 원소인 20이 출력된다.
그런데 굳이 포인터를 사용하여 주소에 접근하는 간접적인 방법을 사용할 필요없이, 그냥 변수에 값을 변경하는 방식으로 바로 사용하면 되지 않을까?
포인터는 함수를 사용할 때 진가를 발휘하는데, C언어 기초-21 <함수> 편에서 본 함수에서 선언한 변수를 다른 함수에서 인자로서 호출하기만 가능할 뿐, 원래 함수에서 선언된 변수의 값은 변경할 수 없었다.
하지만 여기서 포인터를 사용하여 메모리의 주소를 넘겨주면 다른 함수 내에서도 주소를 통한 변수의 값 변경이 가능해진다.


매개변수로 int값을 불러온 다음, 호출된 값에 100을 대입하는 ChangeValue( )함수를 선언하였다.
[매개변수와 인자는 이름이 같을 수 있지만 다른 값이다]
main( )함수에서 num 변수에 10을 대입하고 ChangeValue( ) 함수에 num을 인자로 넘겨주었다.
하지만 num을 출력해보면 값이 변경되지 않고 10이 출력된다.
일반 함수는 값을 '복사'하여 전달하므로 원본의 값은 변경되지 않는다.
이번에는 포인터를 사용하여 주소를 전달해보자

매개변수에는 int형 변수의 주소를 요구하도록 하고, 받아온 주소에 접근 (*)하여 값을 100으로 변경하는 ChangeValue( )함수를 선언했다.
main( )함수에서는 num에 10을 대입하고 ChangeValue( )함수를 사용하여 num의 주소를 넘겨주었다.

함수에 의해 주소에 있는 원본 값이 100으로 직접 변경 되었다.
여기서 중요한 개념으로, 인자를 전달하는 방식에는 Call by Value와 Call by Reference가 있다.
Call by Value의 경우 기본적으로 C언어에서 지원하는 방식이며, 첫 번째 예시처럼 함수에서 값을 복사해서 전달하는 방식이 필요한 경우 사용하면 된다.
Call by Reference의 경우 두 번째 예시처럼 함수에서 값을 바로 전달하는 대신 주소값을 전달하여 원본을 수정하는 방식을 말한다.
사실 엄밀히 따지자면 주소값 자체를 복사해서 넘겨주는 것이므로 이것도 Call by Value라고 할 수는 있다. 또한 이렇게 주소값을 복사해서 넘겨주는 것을 Call by Address방식이라고 한다.
C언어에서는 Call by Reference를 공식적으로 지원하지는 않으나, 의미적으로 Call by Address를 이용해서 Call by Reference와 같이 사용할 수 있기 때문에 일반적으로 C언어에서 포인터를 이용해 주소값을 넘겨주는 방식을 Call by Reference라고 설명하는 곳도 많다.
요약하자면 C언어에서 함수 인자 전달 방식은 원칙적으로 모두 Call by Value이나, 포인터를 사용하여 Call by Reference를 구현할 수 있다.
다음은 상수포인터이다.
상수 포인터란 포인터를 이용하여 어떤 대상을 상수화 시키는 것을 의미한다.
*을 기준으로 const가 어디에 붙느냐에 따라 잠기는 대상이 달라진다.

이처럼 int* ptr 앞에 const를 붙여 상수화를 시킨 값은 포인터가 가리키는 주소를 통해 값을 변경시키지 못한다

이와 같이 자료형 다음에 const를 선언하게 되면 포인터를 통한 값 변경만 가능하고 ptr이 가리키는 주소값을 변경할 수 없다
(이 포인터가 오직 num1 변수만을 가리키며, 절대 다른 변수를 가리키지 않겠다고 생각한 경우)
쉽게 생각해서 const 바로 뒤에 오는 것이 무엇이냐에 따라 상수화의 대상이 달라진다고 생각하면 된다

const int* const ptr = #처럼 const를 두 개 붙이면 포인터를 통한 값 변경도, 포인터가 가리키는 주소 변경도 불가능하다
[이 모든 방식은 변수 자체가 상수화가 된 것은 아니기 때문에 변수를 직접 변경시키는 것은 가능하다]
포인터는 *이 1개만 붙는 것이 아니라 2개 이상 붙을 수도 있는데, 이를 이중 포인터 라고 한다.
별이 2개 붙는 이중 포인터(**)는 "포인터 변수의 주소를 저장하는 포인터" 이다.

조금 헷갈릴 수 있기 때문에 코드를 하나씩 살펴보자.
num에는 7을 저장, ptr은 &num(num의 주소)를 대입, pptr에는 &ptr(ptr의 주소)를 대입했다.
ptr에 저장된 주소에 접근하기 위해서는 *을 붙여야 하는 것은 이제 잘 알고 있을 것이다.
이중 포인터에 접근하는 방식은 앞의 것과 크게 다를 것이 없다.
pptr에 &ptr이 저장되어 있고, pptr에 접근(*pptr)하면 &ptr에 접근하므로 &num(주소)을 불러온다.
여기서 한번 더 접근하게 되면(**pptr), 즉 &num에 접근하므로 결과적으로 num에 저장되어 있는 7을 불러오게 된다.
*을 붙일 때마다 접근한다고 생각하면 된다.
그렇다면 결국 &num, ptr, *pptr이 가리키는 값이 7로써 모두 같다.

이중포인터 이외에도 삼중포인터, 사중포인터 처럼 참조연산자를 여러 개 찍어서 그 이상의 다중포인터를 사용하는 것도 가능하지만
3중포인터 이상은 현실적으로 거의 사용하진 않는다
포인터 배열(Array of Pointers) vs 배열 포인터(Pointer to an Array)
포인터 배열이란, 말 그대로 배열이지만 배열 안에 숫자가 아니라 '주소들'로 가득 들어있는 배열이다.

일반 배열 선언 방법에 *을 붙여서 포인터 배열임을 표시하면 된다.

주소 외에 다른 값은 넣을 수 없다.
이제 배열 포인터에 대해 알아보자
배열 포인터란, 일반 배열을 통째로 가리키는 하나의 포인터이다.

배열 포인터를 선언하기 위해서는 괄호를 붙여야 하는데, 그 이유는 대괄호가 *보다 우선순위가 높기 때문이다.
괄호를 붙이지 않으면 앞서 설명했던 포인터 배열이 된다.
배열 포인터를 선언한 이후에는 같은 크기의 다른 배열의 주소를 넣어야 하므로 ptr = matrix 행을 통해 시작 주소를 대입하였다.
(배열의 첫번째 주소는 &를 붙이지 않아도 된다는 것은 이제 다 알고 있을 것이다)
여기서 또 한가지 의문점으로 "주소에 접근할 때는 *을 붙여야 하는데 왜 배열의 요소에 접근할 때는 *을 붙이지 않지?"라는 생각이 들 수 있다.
이것도 배열의 특징 중 하나로, 대괄호 연산자[ ] 안에 이미 *(역참조)의 기능이 들어가 있기 때문이다.
C언어에서 arr[i]라고 쓰는 것은 간결함을 위해 만든 일종의 축약형이다. 실제로는 컴파일러가 arr[i] == *(arr + i)로 번역한다.


심지어 덧셈의 교환법칙(a + b = b + a)로 인해 *(arr + 1) = *(1 + arr)도 되므로, 이러한 코드도 가능하다.


물론 실무에서 이렇게는 쓰지 않지만, 문법적인 원리를 증명하는 하나의 소재이다.
마지막으로 함수 포인터(Function Pointer)에 대해 알아보자
C언어에서는 변수만 메모리에 올라가는 것이 아니라, 커스텀 함수 코드도 메모리에 올라간다. 그렇다면 그 함수에도 시작 주소가 있으므로 포인터에 주소를 저장할 수 있다. 함수 포인터는 말 그대로 함수의 주소를 저장하는 포인터이다.

주소를 저장했음에도 &hello가 아닌 hello로 사용할 수 있었던 이유는
이것 또한 배열과 같이 배열 이름 자체가 첫 번째 칸의 주소였던 것처럼, 함수 이름 자체가 함수 코드가 시작되는 주소이기 때문이다.
그리고 함수에 접근하여 호출한다고 해서 *fptr( )로 작성하면, 컴퓨터는 함수 호출 소괄호( )를 *보다 먼저 처리한다.
그래서 fptr( )라는 함수를 먼저 실행하고, 그 함수가 돌려준 리턴 값에 *을 붙여서 접근한다.
만약 함수가 리턴 값이 없거나(void) 일반 정수면 컴퓨터는 컴파일 에러를 발생시킨다.
따라서 굳이 *을 사용해서 함수를 호출하고 싶다면, 반드시 (*fptr)( );과 같이 소괄호로 *fptr를 먼저 묶어주어야 한다.
그렇다면 매번 호출할 때마다 (*fptr)( );라고 사용해야 하는가?
C언어를 개발한 사람들이 이 문제를 해결하기 위해 한가지 기능을 더 만들었다.
컴퓨터는 어차피 fptr이 함수 주소를 담고 있는 포인터 변수라는 것을 이미 알고 있기 때문에, 굳이 (*fptr)( )로 사용하지 않고 fptr( )라고만 사용해도 알아서 함수 주소로 점프한다.

결국 컴파일러 내부적으로 이 세가지 표현을 완전히 똑같은 명령으로 처리한다.
VR게임 개발을 위한 언리얼엔진/C++ 공부한 내용 끄적이기...
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!