모두의 코드 - 12 - 1, 2, 3. 포인터는 영희이다! (포인터)

2024. 9. 30. 00:10Programming Language/C

12 - 1. 포인터는 영희이다! (포인터)

전에도 C언어를 배우면서 포인트를 배울때 이해가 어렵고 머릿속이 복잡해서 놓아버릴 때가 여러번 있었다.

이번엔 제대로 배워서 머리에 박아 넣기를 바라면서 시작해보자..!!!

 

포인트를 이해하기 앞서

모든 데이터들은 메모리 상에 특정 공간에 저장되어 있다.

각 메모리의 특정한 공간을 방이라고 한다면, 각 방에 데이터들이 들어가는 것이다.

 

한 방의 크기를 보통 1바이트로 정의 된다.

우리가 만약 4바이트 짜리 int형 변수를 정의한다면 메모리 상에 4칸을 차지하게 된다.

 

프로그램이 작동할때 컴퓨터는 여러 방들에 있는 데이터들을 필요로 하게 된다.

그렇기에 어떤 방에서 데이터를 가져올 지 구분하기 위해서 각 방에 주소를 붙여 놓았다.

아파트의 호수와 비슷하게 말이다.

 

예를 들어 int 변수 a 를 정의 했다면 특정한 방에 a가 정의된다.

이때 0x152839는 임의로 지정한 방의 시작 주소이다.

int a = 123; 의 의미는 결과적으로 0x152839부터 시작해서 4바이트 공간을 확보한 다음에 123이라는 값을 저장 하라는 의미가 될 수 있다.

 

그렇다면 a = 10;으로 값을 갈음한다면 어떻게 될까 

컴파일러는 메모리 0x152839부터 시작하는 4바이트의 공간에 담긴값을 10으로 변경해라 라고 인식한다.

결과적으로 컴퓨터 내부에서는 올바르게 수행이 될것이다.

 

참고로 32비트 운영체제를 사용한다면 주소값의 크기가 32비트로 나타내어지기에 0x00000000 ~ 0xFFFFFFFF까지의 크기를 가지게 된다.

그렇기에 2비트의 32승 바이트 즉, RAM은 최대 4GB까지 밖에 사용할 수 없다라는 점.

이 때문에 32비트 운영체제에서는 RAM의 최대 크기가 4GB로 제한된다.

즉 32비트 운영체제에서는 4GB를 넘기는 RAM은 인식하지 못한다...

 

여기까지는 직관적이고 단순해서 이해하기 쉬울 것이다.

근데 C를 만든 사람은 포인터(Pointer)라는 것을 만들었다.

 

사실, 포인터는 우리가 앞에서 봤던 int나 char변수들과 전혀 다른것은 아니다.

포인터도 "변수"이다.

int형 변수가 정수 데이터, float형 변수가 실수 데이터를 보관했던것 처럼 포인터도 특정한 데이터를 보관하는 "변수"이다.

그럼 표인터는 뭘 보관하고 있을까?

 

특정한 데이터는 앞에서 봤던 특정 데이터가 저장된 주소값이다.

여기서 중요한 건 "주소값"을 저장한다는 것이다.

 

이점을 잘 생각하고 이제 포인터에 대해서 잘 알아보자.

 

포인터

포인터란 메모리 상에 위치한 특정 데이터의 (시작)주소값을 보관하는 변수이다.

우리가 변수를 정의할때 int나 char처럼 여러가지 타입이 있었는데 포인터에도 이 타입이 존재한다.

 

그 말은 포인터가 메모리 상의 int형 데이터의 주소값을 저장하는 포인터와, char형 데이터의 주소값을 저장하는 포인터가 서로 다르다는 이야기이다.

 

그렇다면 위에서는 32비트 운영체제에서는 무조건 주소값이 32비트, 4바이트를 가지기에 포인터는 다 똑같은 크기를 갖는것 아닌가 라고 생각할 수 있다.

이 부분에 대해서도 앞으로 이해해보자.

 

C언어에서 포인터는 다음과 같이 정의할 수 있다.

 

(포인터에 주소값이 저장되는 데이터의 형) *(포인터의 이름);

 

또는 

 

(포인터에 주소값이 저장되는 데이터의 형)* (포인터의 이름)

 

와 같이 정의할 수있다.

 

예를 들어 p라는 포인터가 int형 데이터를 가리키고 싶다고 한다면.

즉, 포인터p는 int형 데이터의 주소값을 저장하는 변수가 되는 것이다.

 

&연산자

그런데, 포인터를 정의했다면 값을 넣어야하는데, 우리가 데이터의 주소를 어떻게 알고 넣어줄까?

 

그건 &연산자를 사용해서 해결이 가능하다.

단항 연산자 &연산자는 피연산자의 주소값을 불러온다.

사용 방법은 그냥 

 

&(주소값을 계산할 데이터)

 

와 같이 사용하면 된다.

예를 들어 만약 a라는 변수의 주소값을 갖고 싶다면 

 

&a

 

와 같이 사용하면 된다.

 

단순하게라도 하나의 프로그램을 짜보자.

 

코드를 실행해보면

라는 값이 출력된다.

참고로 이 값은 컴퓨터에 따라서 결과가 다르게 나올 수 있다고 한다.

그리고 실행할 때마다 값이 변경될 것이다.

 

%p를 통해서 &a의 값을 16진수로 출력하라고 코드를 입력했는데 보면 0x가 안나오지만 이부분은 그냥 잘라내고 보여주는것 같다.

 

아무튼 & 연산자를 통해서 특정 데이터의 메모리 상의 주소값을 알 수 있다는 사실을 알았으니 포인터에 그 값을 넣어보자.

이걸 실행해보면 

똑같이 나온다.

당연히 p에 &a로 주소값을 넣어줬기 때문이다.

참고로 한 번 정의된 변수의 주소값은 변경되지 않는다.

따라서 printf에서 포인터 p에 저장된 값과 변수 a의 주소값이 동일하게 나오게 된다.

 

* 연산자

여태까지 포인터는 특정한 데이터의 주소값을 보관한다라는점, 그리고 이 때 포인터는 주소값을 보관하는 데이터의 형에 * 를 붙임으로써 정의되고, &연산자로 특정한 데이터의 메모리상의 주소값을 알아올 수 있다까지의 내용이였다.

 

&연산자가 어떤 데이터의 주소값을 얻어내는 연산자라면 꺼꾸로 주소값에서 해당 주소값에 대응되는 데이터를 가져오는 연산자가 필요할것이다. 이게 바로 * 연산자이다.

 

*연산자도 &연산자와 마찬가지로 단항연산자로 사용될때 위에서 말한 그 기능을 수행하는 연산자로써 일하게 된다.

이걸 실행해보면 

와 같은 값이 나온다.

 

마지막으로 *와 관련된 예제 하나를 더 보자.

보면 기존에 a = 2;로 값을 변경했던 것을 *p = 3;으로 변경해보았다.

p에 넣었던 주소값에 들어 있던 데이터를 3으로 변경해보려고 하는 시도이다.

결과 값을 보면 

값이 변경된 것을 볼 수 있다.

 

결국 *p = 3은 a = 3 과 같은 결과를 내는것을 알 수 있다.

 

포인터라는 말 자체의 의미를 다시 한번 생각해보면 아래와 같은 모양과 같다.

참고로 값은 임의로 정했다고 한다.

 

포인터 p에 어떤 변수 a의 주소값이 저장되어 있다면 포인터 p는 변수 a를 가리킨다고 말한다.

포인터 또한 엄연한 변수이기에 특정한 메모리 공간을 차지한다.

따라서 포인터 또한 포인터의 주소를 갖고 있게 된다.

 

그럼 아까 생각했던 궁금증 포인터에는 왜 타입이 존재할까?

 

포인터에는 왜 타입이 있을까

아까 포인터는 주소값만 보관하는데 왜 타입이 필요하고 주소값은 어차피 32비트면 시스템에서 항상 4바이트고 64비트 시스템에서는 8바이트인데 그냥 pointer라는 타입을 만들면 안됐을까?

 

만약 pointer라는 타입이 있다면 

빨간줄은 무시해주길 바란다

메모리에 a를 위해서 4바이트 공간을 마련해주고 마찬가지로 p를 위해서 메모리 상에 8바이트 공간을 마련해줬다.

그리고 p에 a의 주소값을 잘 전달해줬을 것인데 문제가 되는 부분은 *p = 4이다.

포인터 p에는 변수 a의 주소값이 저장되어 있을것이다.

문제는 a가 메모리에서 차지하는 모든 주소들의 위치가 있는게 아니라 시작 주소만 들어가 있다는 점이다.

 

따라서 *p라고 했을때 컴퓨터는 메모리에서 얼마만큼 읽어들여야할지를 알 길이 없다.

 

그런데 

를 하면 아 a는 int형이고 그렇기에 p안에 있는 주소값에서 int형 만큼의 크기를 읽으라고 컴퓨터한테 이해시켜주는 것이다..!!

 

포인터도 변수다

위 코드를 실행하면

와 같은 결과를 얻는다.

 

앞에서 말했던 것 처럼 포인터는 변수이다.

즉 포인터에 들어간 주소의 값은 변경이 가능하다는 의미이다.

 

12 - 2. 포인터는 영희이다! (포인터)

근데 이 포인터 왜 배울까?

int a와 int p가 있을때 p가 a를 가리킨다면 a = 3이라고 하지 궂이 *p =3이라고 할 필요가 없지 않은가

나중에 알겠지만 포인터는 C언어에서는 정말 중요한 일을 하게 된다.

지금 이야기해도 이해가 안될것이라 나중에 이해해보도록 하고 다른 이야기부터 해보자.

 

상수포인터

전에 상수에 대해서 배운것 기억 나는가?

어떤 데이터를 상수로 만들기 위해선 const라는 키워드를 붙여주면 된다고 했었다.

const는 단순히 이 데이터는 불변이야! 라고 선언하는 것과 마찬가지이다.

그래서 값을 변경하는 것도 불가능하다.

그러면 포인터처럼 이 상수는 왜쓰는건지 의문이 들 것이다.

상수는 프로그램을 만들면서 발생하는 실수도 줄여주고, 실수를 했더라도 에러를 잡는데 많은 도움을 준다.

 

아래 문장을 보자.

즉 double형 변수 PI를 3.141592라는 값을 가지도록 선언하면 PI의 값은 절대 바뀌지 않는 값이 된다.

그런데 나중에 코드를 작성하다 무심코 

와 같이 PI의 값을 변경하는 코드를 작성했다고 보자.

 

그러면 에러가 발생하면서 코드에 문제가 발생하는 부분을 바로 수정할 수 있을 것이다.

근데 만약 저게 상수가 아니라 그냥 변수로 선언 되었다면 에러가 발생하지 않게 되면서 우린 PI = 10 이라는 부분을 찾을때까지 또 그게 문제라는 것을 인식할때 까지 문제를 해결할 수 없게 될 것이다.

그래서 절대 바뀌지 않을것 같은 값에는 무조건 const 키워드를 붙여주는것을 습관시 해야한다.

 

아무튼 포인터에서도 const를 붙일 수있는지 생각해보자.

 

이러면 당연히 에러가 발생할 것이다.

그럼 위 오류가 왜 발생하는지에 대해 이야기 하기전에 

이 문장이 어떤 의미를 가지는지 생각해보자.

저 const는 int a 에 붙은게 아니라 int * pa에 붙은 것이다.

그니까 a의 값을 변경하면 안되는게 아니라 pa를 통해서 pa가 가리키는 변수의 값을 변경하지 말라는 것이다.

a의 값은 a를 직접 변경한다면 문제 없이 변경된다는 것이다.

그러면 

이건 왜 가능할까?

이는 아래의 코드를 보면서 이해해보자.

이걸 실행해보면 

동일한 에러를 출력한다.

대신 에러가 발생한 위치가 다르다.

 

왤까...??

일단 포인터의 정의 부분부터 이야기해보자.

잘 보면 int *을 가리키는 pa라는 포인터를 정의했다,

그런데 이번에는 const키워드가 int*의 앞이 아니라 int * 와 pa사이에 놓여 있다.

이건 const 키워드의 의미 그대로 생각해보면 간단하다.

그냥 pa의 값이 변경되면 안된다는 의미이다.

 

그런데 제일 처음 포인터를 배울때는 포인터가 데이터의 주소값을 가리킨다고 했도 위 경우 a의 주소값이 pa에 저장되는 것이다.

따라서 이 pa가 const라는 의미는 pa의 값이 절대로 바뀔 수 없다는 것인데 pa는 포인터가 가리키는 변수의 주소값이 들어 있기에 pa는 처음 가리키는 a 말고는 다른 어떤것도 가리킬 수 없게 만든다는 의미가 된다.

 

그래서 

pa가 b를 가리키게 변경하려고 했던 이 부분에서 에러가 발생하는 것이다.

여기서 에러가 발생하지 않는 이유는 pa가 가리키는 걸을 변경하지 말라했지 pa가 가리키는 것의 값을 변경하지 말라곤 안했기 떄문이다.

 

그 두개를 합쳐보면

둘다 변경 불가능한 포인터가 된다.

 

포인터의 덧셈

포인터의 덧뺄셈을 배워보자.

이 코드를 실행해보면

라는 출력된 값을 볼 수 있다.

 

pa + 1을 했더니 0000006109EFF6D4 에서 0000006109EFF6D8이 되었다.

0000006109EFF6D4 + 1 = 0000006109EFF6D8이라니..

 

여기서 추측 해볼 수 있는 것은 포인트의 형이 int* 이기 때문에 4비트뒤를 찾은것인가? 라고 생각할 수있다.

이 추측을 확인해보기 위해 포인터의 타입을 변경해가면서 확인해보자.

보면 pb(char)의 경우는 1늘어 났고, pc(double)의 경우는 8 늘어났다.

우리가 예상한대로 char타입이기에 1늘어 났고 double타입이기에 8늘어난 것을 알 수 있다.

근데 왜 포인터가 가리키는 형의 크기만큼 더할까?

이 내용은 뒤에 나올 것이다.

 

먼저 소제목처럼 포인터의 뺄셈은 가능할지, 포인터끼리의 덧셈은 허용이 되는 것일까?

먼저 포인터의 뺄셈을 확인해보자.

포인터의 뻴셈은 덧셈과 동일하게 동작하는 것을 볼 수 있다.

 

그럼 포인터끼리의 덧셈을 한번 해보자.

포인터 끼리의 덧셈은 허용하지 않는다.

 

사실 포인터끼리의 덧셈은 의미가 없으면서 필요하지도 않다.

두 주소값을 더해서 나오는 값은 이전 포인터들이 가리키는 변수와 아무런 관련도 없는 메모리속의 임의의 지점이기 떄문이다.

그러면 포인터에 정수를 더하는 것은 왜 돼는 것일까?

그건 아래에서 설명하겠다.

 

근데 한가지 특이한 점은 포인터끼리의 뺄셈은 가능하다는 점이다.

이것도 왜 그런지는 나중에 확인해보자.

 

그리고 또 한가지 

여기서 pa에 저장되어 있는 값(pa가 가리키고 있는 변수의 주소)을 pb에 대입했다.

따라서 pb도 pa가 가리키던 변수의 주소값을 가지게 되는 것이다.

결과적으로 pa, pb모두 a를 가리키게 되는데 주의해야할 점은 pa와 pb의 형이 같아야한다는 점이다.

pa가 int * 라면 pb도 int*이여야 한다는 것이다.

만약 형이다르다면 형변환을 해줘야만 하는데 이것도 나중에 다뤄보도록 하자.

 

** 포인터는 개념이 너무 많아서 나중에 나중에 하는 것들이 많은데 일단 이해는 나중에 하고 피부로 느끼면서 익숙해지도록 해보자.

사실 개념은 예전에 배웠는데 까먹은 것 뿐이니까...

 

배열과 포인터

C언어를 배우면서 가장 놀라운점은 이 서브 타이틀에서 볼 수 있을 것이다.

이전에 포인터의 연산은 왜 이런식인지에 대한 그냥 넘어 갔는데 이제 그 답을 얻을 수 있을 것이다.

 

이전 배열에서(11강) 배열은 변수가 여러개 모인것으로 생각할 수 있다고 이야기 했었다.

그런데 바로 배열의 각 원소는 메모리 상에 연속되게 놓인다는 특징이 중요하다고 했었다.

만약 

이런 배열을 정의한다면 메모리 상에서 

와 같이 나타난다.

 

메모리 상에서 연속된 형태로 나타난다는 것이다.

하나의 원소는 int형 변수이기에 4바이트를 차지하게 된다.

이 코드를 통해서 주소값이 4바이트씩 자리를 차지하는지 확인해보면 

4씩 늘어나는 것을 확인할 수 있다.

 

그러면 포인터로도 배열의 원소에 쉽게 접근이 가능하지 않을까?

배열의 시작 부분을 가리키는 포인터를 정의한 다음에 포인터에 1을 더하면 그 다음 원소를 가리키게 될것아닐까

 

이런게 가능한 이유는 포인터가 자신이 가리키는 데이터의 형의 크기를 곱한 만큼 덧셈을 수행하기 때문이다.

즉 p라는 포인터가 int a를 가리킨다면 p + 1을 할때 p의 주소값은 사실 1*4가 더해지게 되고 p + 3을 하면 p의 주소값에 3 * 4인 12가 더해진다는 것이다.

 

이걸 코드로 한번 작성해보면

그리고 이 코드를 실행해보면 

모든 값이 정확히 일치하는 것을 볼 수 있다.

 

그렇다면 *을 사용하게 되면 결국 arr[3]으로 값을 가져오는 것과 *(parr + 3)은 동일한 역할을 하게되지 않을까?

이걸 실행해보면

동일하게 접근이 가능한 것을 볼 수 있다.

즉 prrr + 3을 수행하면 arr[3]의 주소값이 되고, 거기에 *을 붙여주면 arr[3]과 동일하게 된다는 것이다.

 

배열의 이름의 비밀

아마 배열을 사용하다 보면 

이렇게 배열의 어떤 요소를 지정하지 않고 그냥 출력해본 오류를 범해본 경험이 있을 것이다.

그런데 사실 이 값은

이걸 실행해보면 

그냥 arr을 출력하면 사실 arr[0]의 주소값을 출력하는것과 마찬가지라고 생각하면 된다.

 

결국 배열에서 배열의 이름은 배열의 첫번째 요소의 주소값을 나타내고 있다는 사실을 알 수 있다.

그렇다면 배열의 이름이 배열의 첫 번째 요소를 가리키는 포인터라고 할 수 있을까?

아니다..!

 

배열은 배열이고 포인터는 포인터이다

예를 들어 sizeof를 사용하는 코드를 살펴보자.

기억을 상기해보면 sizeof는 크기를 알려주는 연산자로

이 코드를 실행해보면

와 같이 나온다.

 

sizeof를 arr 자체에 그대로 썼을때 배열의 실체 크기가 나온다.

우리의 arr배열에는 int 원소가 6개 있기에 24가 될것이다.

반면에 parr에 sizeof연산자를 사용하니 배열 자체의 크기가 아니라 그냥 포인터의 크기를 알려준다(64비트 컴퓨터는 지금 처럼 8바이트가 나온다.)

 

따라서 배열의 이름과 첫 번째 원소의 주소값은 엄밀히 다른 것이다.

그럼 왜 두 값을 출력했을때 같은 값이 나왔을까?

 

그 이유는 C언어 상에서 배열의 이름이 sizeof연산자나 주소값 연산자(&)와 같이 사용될(ex, &arr) 경우를 빼면 배열의 이름을 사용시 암묵적으로 첫 번째 원소를 가리키는 포인터로 타입이 변환되기 때문이다.

 

그렇기에 arr이 sizeof랑도 주소값 연산자랑도 사용되지 않았기에 arr은 첫번째 원소를 가리키는 포인터로 타입 변환되었기에 &arr[0]와 일치하게 되는 것이다.

 

[]연산자의 역할

[]가 연산자라는 말에 놀랐는가?

사실 연산순위에서 한번 확인했을 것이다.

그런데 앞서 포인터의 연산을 배우면서 []연산자의 역할을 대충 짐작할 수 있을 것이다.

사실 컴퓨터는 C에서 []라는 연산자가 쓰이면 자동적으로 위 처럼 바꿔 처리하게 된다.

우리가 arr[3]으로 사용하는게 사실은 *(arr + 3)으로 바꿔 처리되고 있던 것이다.

 

그리고 arr은 + 연산자와 사용되기 때문에 앞서 말했던 것 처럼 첫번째 원소를 가리키는 포인터로 변환되어서 arr +3이 포인터 덧셈을 수행하게 된다.

그리고 이건 배열의 4번쨰 요소를 가리키게 될 것이다.

 

따라서 다음과 같은 것도 가능하다.

arr[3]이 *(arr + 3)이라면 3[arr]은 *(3 + arr)이기 때문에 결국 3[arr]은 arr[3]과 *(3 + arr)과 동일한 기능을 수행하게 될 것이다.

결과를 확인해보면 

우리 생각대로 잘 수행된것을 볼 수 있다.

 

포인터의 정의

앞에서 말하기를 int를 가리키는 포인터를 정의하기 위해서 

를 모두 쓸 수 있다고 했던거 기억 나는가?

근데 잘 보면 int* p보다 int *p를 더 많은 사람들이 사용한다는 사실이 있는데, 그 이유는 뭘까?

 

우리가 int형 변수를 여러개 한 번에 선언하려고 했을때 int a, b, c, d; 처럼 사용했었는데 포인터의 경우는 변수를 여러개 선언 하기 위해서는 

이런식으로 선언해야 한다.

물론 int* p,* q,* r로 해도 상관은 없는데 그렇게 하면 int *p, q, r 처럼 문제를 만들 수 있다.

int *p, q, r 는 p만 포인터이고 q, r의 경우는 그냥 int형 변수를 선언한 것이 된다.

 

12 - 3. 포인터는 영희이다! (포인터)

먼저 배열은 배열이고 포인터는 포인터지만 sizeof와 주소값연산자(&)를 제외하면 배열의 이름은 첫번째 원소를 가리킨다.

arr[i]와 같은 문장은 사실 컴파일러에 의해서 *(arr + 1)로 변환된다는 점을 이해하고 시작해보자.

 

1차원 배열 가리키기

이전에 말했듯이 int arr[10]; 이라는 배열을 만든다면 위 두 경우를 제외하곤 arr이 arr[0]을 가라키는 포인터로 타입 변환된다고 했었다.

 

그렇다면 다른 int * 포인터가 이 배열을 가리킬 수 있지 않을까?

그럼 이 코드에서 잘 봐야할 코드는 parr = arr; 부분이다.

앞서 말했듯 arr은 배열의 첫 번째 원소를 가리키는 포인터로 변환되고 그 원소의 타입이 int이므로 포인터의 타입은 int * 이 될것이다.

 

그래서 parr = arr; 은 parr = &arr[0];과 완전히 동일한 문장이 된다.

따라서 parr을 통해서 arr을 이용했을때와 동일하게 배열의 원소에 편하게 접근이 가능한 것이다.

 

이 코드를 실행하면

와 같은 결과를 보여준다.

 

먼저 

int형 1차원 배열을 가리킬 수 있는 int * 포인터를 정의하고 배열 arr의 첫 주소값을 전달했다.

그다음 while문을 살펴보면

 parr - arr 이 9이하일 동안 돌아가게 된다.

sum에 parr이 가리키는 원소의 값을 더했는데 sum += (*parr); 문장은 sum = sum + *parr과 동일할 것이고 

parr++를 하게 되면 주소값에 1*(포인터가 가리키는 타입의 크기)가 더해지게 된다.

 

즉 int형 포인터이므로 4가 더해져서 배열의 다음 웤소를 가리키게 된다.

결국 저 작업을 반복하면 parr은 원소를 하나씩 확인하면서 parr - arr <= 9가 될때(parr이 arr 보다 9인덱스 앞의 것 그니까 parr이 arr[9]를 보고 있는것보다 작거나 같은 값일 동안, 혹은 arr[10]을 바라보기 직전까지 ) 정지하게 된다.

 

(**parr이 10 이상의 값을 보면 에러를 발생시키기에 9까지만 보게 한거임..)

 

아니 이럴거면 그냥 arr++를 하면 되는거 아닌가?

 

근데 사실 배열의 이름이 첫번째 원소를 가리키는 포인터로 타입변경이 된다고 했을때, 이건 단순히 배열의 첫번쨰 원소를 가리키는 주소값 자체가 될 뿐이다.

그렇기에 arr++라는 문장은 C컴파일러 입장에서 (0x7fff1234)++를 수행해라 라는것과 마찬가지인 것이다.

이건 에초에 말이 안되는 문장되기에 사용이 불가능한 것이다.

 

포인터의 포인터

포인터의 포인터는 

 

int ** p; 

 

와 같이 정의된다.

 

위 int를 가리키는 포인터를 가리키는 포인터라고 할 수 있다.

예제를 한번 보자.

의 결과를 출력해보면 

이 나온다.

 

위에 보이는것 과 같이 같은행의 값은 모두 같다.

사실 위 예제는 그렇게 어려운건 아니다.

포인터를 제대로 이해하면 말이다.

 

일단 ppa는 int* 를 가리키는 포인터이기 때문에 

와 같이 이전에 포인터에서 했던것 처럼 동일하게 해주면 된다.

ppa에는 pa의 주소값이 들어가게 될것이다.

 

따라서 

이 문장이 같은 값을 출력하는걸 알 수 있다.

 

그리고 이제 두번째 문장을 보면 pa가 a를 가리키고 있으니까 pa 는&a와 동일할 것이다. 그리고 ppa에 있는 pa의 내부에 있는 값이 &a이기 때문에 pa랑 *ppa와 동일한 값이될것이다.

그래서 *ppa와 pa와 &a는 동일한 값이 된다.

 

그리고 a의 값은 *pa로 접근이 가능하고 pa는 *ppa와 동일하기에 *pa는 *(*ppa)와 동일하다 

그렇기에 **ppa와 *pa와 a는 동일한 값을 바라보고 있다.

 

이 관계를 그림으로보면 

과 같다.

 

배열 이름의 주소값

배열의 이름에 sizeof연산자와 &주소값 연산자를 사용할때 뺴고는 전부 다 포인터로 암묵적 변환이 이루어진다고 했었다.

그렇다면 주소값 연산자를 사용하면 어떻게 될까?

 

위 코드를 실행하면

이런 값이 출력된다.

 

여기서 

&arr은 어떤 것을 의미할까?

이전에 arr은 int *로 암묵적으로 변환된다고 했으니까 &arr은 int **이 될까?

아니다. 

암묵적 변환은 주소값 연산자가 앞에 올때는 이뤄지지 않는다.

 

arr이 크기가 3인 배열이기에, &arr을 보관할 포인터는 크기가 3인 배열을 가리키는 포인터가 되어야할 것이다.

그리고 C언어 문법상 이를 정의하는 방식은 위와 같다.

 

여기서 parr을 정의할때 *parr을 꼭 ()로 감싸줘야만 하는데 그렇게 하지 않고 

이렇게 선언한다면 int* 포인터 3개를 가진 배열로 인식해서 의미가 달라진다.(나중에 포인터 배열에서 좀 더 자세히 다룰것이다.)

 

아무튼 parr은 크기가 3인 배열을 가리키는 포인터이기에 배열을 직접 나타내기 위해서는 * 연산자를 통해서 원래의 arr을 참조해야한다.

 따라서 (*parr)[1]과 arr[1]은 같은 문장이 되는 것이다.

 

한가지 재밋는 점은 parr과 arr은 같은 값을 가진다는 점이다.

arr과 parr 모두 배열의 첫 번째 원소의 주소값을 출력한다.

이는 arr자체가 어떤 메머리 공간에 존재하는게 아니기 때문인다.

 

이건 사실 B언어라는 언어에서 C언어가 파생되었기 떄문에 이런 일들이 있는 것이다.

B언어에서는 사실 실제 배열이 있고 그 배열을 가리키는 포인터가 따로 있었다.

B언어에서는 arr과 arr[0], arr[1]은 각각 다른 메모리를 차지하는 것들이였고 arr이 실제로 arr[0]을 가리키는 포인터였다.

그래서 arr의 값을 출력하면 실제로 arr[0]의 주소값이 나왔고 &arr은 arr의 주소값이 나왔었다.

따라서 B언어에서 arr과 &arr은 다른 값을 출력했을 것이다.

 

그러나 C언어에서는 비효율적으로 배열을 정의할 때 배열의 시작점을 가리키는 포인터로 공간을 낭비하지 않게 조금 이상하지만 메모리 공간을 효율적으로 쓰게 되는 배열 - 보인터 관계를 만들게 되었다고 한다.

 

2차원 배열의 []연산자

2차원 배열이 메모리 상에서 어떻게 표현되는지 먼저 보도록 하자.

int a[2][3];

 

이전에도 말했던것 처럼 이차원 배열은 1차원 배열이 여러개 있다고 생각하면 된다 

위 코드도 int a[3] 배열이 2개가 메모리에 연속적으로 존재한다고 생각해보면 된다.

 

그러나 2차원 배열이라고 메모리에 2차원으로 존재하는 것은 아니고 컴퓨터의 메모리 구조는 1차원이기에 항상 선형으로 펼쳐져 있다.

실제 프로그램을 짜서 각 원소들의 주소값을 찍어보면 메모리에 위 처럼 연속적으로 존재함을 알 수 있을 것이다.

 

그렇다면 위 2차원 배열에서 arr[0]과 같은 것은 뭘 의미할까

위 코드를 실행해보면 

와 같은 결과값을 볼 수 있다.

보면 arr[0]과 arr[0][0]의 주소값이 같고 arr[1]과 arr[1][0]의 주소값이 같은것을 볼 수 있다.

이걸 통해 알 수 있는 사실은 기존 1차원 배열과 동일하게 sizeof연산자와 사용되지 않을 경우 arr[0]은 arr[0][0]을 가리키는 포인터로 암묵적으로 타입 변환되고 , arr[1]은 arr[1][0]을 가리키는 포인터로 타입변환된다는 뜻이다.

 

따라서 sizeof를 사용했을 경우 2차원 배열의 열의 갯수를 계산할 수 있다.

와 같이 나온다.

먼저 전체 배열에 sizeof연산을 할 경우 당연히 배열의 전체 크기가 나오게 된다.

(sizeof연산으로 2*3*4(바이트) = 24바이트의 크기를 갖는 다고 나온다.)

그리고

이 문장에서 sizeof(arr[0])을 하면 arr[0]의 내부에 있는 arr[3]의 크기, 즉 0번째 행의 길이에 맞는 크기가 나올 것이기에 12가 나오게 될것이다.

그리고 sizeof(arr[0][0])을 하면 arr[0][0]의 크기인 4가 나오게 되면서 3 인 열의 갯수가 나오게 된다.

아까 말했다 싶이 sizeof(arr[0])를 하게 되면 포인터로 타입 변환을 시키지 않기 때문에 sizeof(arr[0])은 마치 sizeof에 1차원 배열을 전달한것과 같은 일을 하게 된다.

 

행의 경우는 동일하게 arr의 크기인 24가 나오고 거기에 arr[0]의 크기로 나누면 전체 2차원 배열안에 1차원 배열이 몆개 있는지를 확인해서 그 크기를 반환하고 그게 바로 전체 행의 크기가 된다.

 

이떄 arr[0][0]의 형이 int 이므로 arr[0]은 int* 형이 될것이고, 마찬가지로 arr[1]도 int* 형이 될것이다.

그럼 하나의 의문이 있다.

만약 2차원 배열의 이름을 포인터에 전달하기 위해서는 해당 포인터의 타입이 뭐가 될까?

arr[0]은 int*가 보관할 수 있으니까 arr은 int **이 보관할 수 있을까?

 

왜냐면 우리가 위에 포인터의 포인터에서 배운것 처럼 int*를 가리키는 포인터는 int**이기 때문에..?

 

아니다.

 

포인터의 형(type)을 결정짓는 두가지 요소

먼저 포인터의 형을 결정짓는 두가지 요소에 대해 이야기 하기전에 위에서 배열의 이름이 왜 int** 형이 될 수 없는가에 대해서 먼저 이야기해보자.

만일 int** 형이 될 수 있다면 맨 위에서 했던것 처럼 int ** 포인터가 배열의 이름을 가리킨다면 배열의 요소에 자유롭게 접근할 수 있어야만 할 것이다.

 

코드로 한번 확인해보자.

이 코드를 실행해보면 

에러가 발생한다.

이걸 무시하고 실행결과를 보면

나는 첫번째 결과만 나오긴했다.

 

강사님의 경우는 실행결과가 

arr[1][1] : 5 
[1]    8834 segmentation fault (core dumped)  ./test

와 같았고 이건 전에 배열을 배웠을때 봤었던 내용으로 초기화되지 않은 값에 접근할때 발생했던 오류이다

전 배열에서는 int arr[3];을 선언하고 arr[10] = 2; 와같이 허가되지 않은 공간에 접근하기만 해도 이런 오류가 발생한다 했었다.

이것도 마찬가지이다.

 

parr[1][1]에서 이상한 메모리 공간의 값에 접근했기에 발생한 일이다.

그렇다면 왜 이상한 공간에 접근하게 된걸까?

 

먼저 int arr[10]이라는 배열에서 x번째 원소의 주소값을 알아내는 방법을 생각해보자.

만일 이 배열의 시작 주소를 그냥 arr이라고 한다면 arr[x]의 주소값은

 

\(arr + 4x\) 

 

와 같이 나타낼 수 있다.

 

이번엔 int arr[a][b]라고 정의된 2차원 배열을 생각해보자.

여기서 arr[x][y]라는 원소를 참조하여 이 원소의 주소값은 어떻게 알 수 있을까?

 

앞에서 말했듯이 int arr[a][b]는 int arr[b]짜리 배열이 메모리에 a개 존재하는 것이라고 생각하면 된다.

그렇기에 arr[x][0]의 주소값은 x번째 int arr[b]짜리 배열이 될것이다.

그렇다면 arr[x][0]의 주소값은 \(arr + 4bx\) 가 된다.

왜냐면 arr[b]배열의 크기는 4b니까 x번째 배열의 시작 주소는 4bx가 되기 떄문이다.

따라서 arr[x][y]의 시작 주소값은 \(arr + 4bx + 4y \)가 된다.

여기서 중요한건 arr[x][y]의 주소값을 정확히 계산하기 위해서는 x, y뿐만 아니라 b가 뭔지 알아야한다는 것이다.

 

따라서 2차원 배열을 가리키는 포인터를 통해서 원소들을 정확히 접근하기 위해서는 

가리키는 원소들의 크기(타입에 따라 달라지는, 여기서는 int이기에 4)와 b의 값 두 정보가 포인터 타입에 명시되어 있어야지 컴파일러가 원소에 올바르게 접근할 수 있다는 것이다.

 

그렇다면 실제로 2차원 배열을 가리키는 포인터는 어떻게 생겼는지 살펴보자.

이 코드를 실행하면

와 같은 결과를 볼 수 있다.

 

2차원 배열을 가리키는 포인터는 배열의 크기에 대한 정보가 있어야 한다고 했다.

2차원 배열을 가리키는 포인터는 아래와 같이 써주면 된다 .

이렇게 포인터를 정의했을떄 앞서 포인터의 조건을 잘 만족할까 

일단 배열의 타입을 통해서 원소 크기 정보를 알 수 있고 2차원 배열의 갯수를 통해서 b의 값을 전달할 수 있게 되었다.

 

그런데 위 포인터의 정의를 전에 보지 않았는가?

맞다 저 parr은 사실 크기가 4인 배열을 가리키는 포인터를 의미한다.

그런데 이게 말이 되는게 1차원 배열에서 배열의 이름의 첫번째 원소를 가리키는 포인터로 타입 변환이 된 것처럼, 2차원 배열에서 배열의 이름이 첫번째 행을 가리키는 포인터로 타입변환이 되어야한다.

그리고 그 첫번째 행이 사실 크기가 4인 일차원 배열인것이다.

 

그러면

여기서 왜 crr은 parr에 넣지 못하는 지 알 수 있겠는가?

 

그러면

 

이 코드가 무슨일을 했던 것일까?

일단 parr에 arr의 주소가 들어가 있긴하다.

그런데 parr[1][1]이 어떻게 해석이 되는지 생각해보자.

 

먼저 parr[1][1]은 *(*(parr + 1) + 1 ) 과 동일한 문장인데 parr + 1을 하면 어떤 값이 될까

현재 parr은 int*를 가리키는 포인터이고 int*의 크기는 8바이트이기 때문에 parr + 1을 하면 실제 주소값이 8 증가하게 된다.

그렇기에 parr + 1 은 arr 배열의 세번째 원소의 주소값을 가지게 된다.

(왜냐면 int 는 4바이트이기에)

따라서 *(parr + 1)은 3이 될것이다.

 

그 다음에 *(parr + 1) + 1 을 하면 몇이 증가할까?

현재 (parr + 1)의 타입은 int *이다.

따라서 int의 크기만큼 4가 늘어나게 된다.

결국 *(parr + 1) + 1은 7이 될것이다 

그래서 결국 *(*(parr + 1) + 1)은 마치 주소값 7에 있는 값을 읽으라는 말과 동일하다.

그리고 해당 위치에는 프로그램이 읽어 올 수 없기에 오류가 발생하는 것이다.

 

포인터 배열

이제 주제를 바꿔서 마지막으로 포인터 배열에 대해 이야기하고자한다.

포인터 배열은 말 그대로 포인터들의 배열이다.

위에서 설명한 배열 포인터는 배열을 가리키는 포인터이고 얘네는 반대로 포인터들을 모아놓은 배열이다.

두 용어가 상당히 헷갈리는데 중요한건 언제나 뒷부분이다.

포인터 배열은 정말 배열이고, 배열 포인터는 정말 포인터이다.

이런 결과를 볼 수 있는데 

 

우리가 배열의 형을  int, char형으로 생성하듯이 배열의 형을 int *로 선언할 수 도 있다.

다시 말해서 배열의 각각 원소는 int를 가리키는 포인터형으로 선언된 것이다.

따라서 int 배열에서 각각 원소를 int형 변수로 취급했던것 처럼 int *배열에서 각각의 원소를 포인터로 취급할 수 도 잇다.

 

** 이 시점에서 책으로 된 강의의 한계점을 느껴 윤성우의 C강의로 다시 돌아가서 학습하고자 마음 먹음