Chapter 10 포인터

포인터

이제 대망의 포인터이다

C언어를 배웠거나 배우고 있는 사람들은

포인터에 대해 익히 들어 보셨을 것이다.

이해하기 가 힘들기로 악명이 높은 포인터

하지만, 이 기록물 과

함께 라면 큰 무리 없이

이해 할 수 있었으면 한다.

포인터를 이해하기 앞서

앞서 Chapter 3 에서

이야기 하였지만

모든 데이터들은 메모리 상에

특정한 공간에 저장 되어 있다.

우리는 앞으로 편의를 위해,

메모리의 특정한 공간을

’방’ 이라고 하겠습니다. 즉,

각 방에는 데이터들

이 들어가게 되는 것이다.

보통 사람들은 한 방의

크기를 1 바이트 라 고 생각한다.

우리가 만약 int 형 변수를 정의

한다면 4 바이트 이므로 메모리

상의 4 칸을 차지하게 된다.

그런데 말인데. 프로그램 작동 시

컴퓨터는 각 방에 있는 데이터를

필요로 하게 됩니다. 따라서,

서로 구분하기 위해 각 방에

고유의 주소(address) 를 붙여 주었다.

우리가 아파트에서 각 집들을

호수로 구분하는 것 처럼 말이다.

예를 들어 우리가 아래와 같은

int 변수 a 를 정의하였다면

특정한 방에 아래 그림

처럼 변수 a 가 정의된다.

int a = 123; // 메모리 4 칸을 차지하게 한다.

이 때, 0x152839 는 제가 아무렇게나

정한 이 방의 시작 주소 이다.

참고로, 0x 가 뭐냐고 물어 보는

사람들이 있을 텐 데,

이전 기록에서도 이야기 하였지만

16 진수라고 표시한 것입니다.

즉, 16 진수로 152839

(10 진수로 1386553)

라는 위치에서 부터 4 바이트

의 공간을 차지하며 123 이라는

값이 저장되어 있게 하라는 뜻이다.

그렇다면 아래와 같은 문장은

어떻게 수행 될까?

a = 10;

사실 컴파일러는 위 문장을

아래와 같이 바꿔주게 된다.

메모리 0x152839 위치에서 부터

4 바이트의 공간에 있는

데이터를 10 으로 바꾸어라!

결과적으로, 컴퓨터 내부에서는

올바르게 수행되겠지.

참고적으로 말하는

이야기 이지만 현재

(아마 이 블로그에 접속하는)

(사람들 중 99% 이상이)

(많은 사람들은 32 비트)

(운영체제를 사용하고 있습니다.)

(이 32 비트에서 작동되는)

(컴퓨터들은 모두 주소값의)

(크기가 32 비트)

(즉, 4 바이트.. 까먹었다면)

(2 – 3 Chapter 참조)

로 나타내집니다.

즉 주소값이

0x00000000

0xFFFFFFFF

까지의 값을

가진다는 것이다.

조금 총명하신 분들이라면

32 비트로 사용할 수 있는

주소값의 가지수는

2 의 32 승 바이트,

즉 RAM 은 최대 4 GB

까지 밖에 사용할 수 없다는

사실을 알 수 있습니다.

맞다. 이 때문에 32 비트

운영체제에서는 RAM 의

최대 크기가 4 GB 로

제한되어있다

(즉, 돈을 많이 들여서)

(RAM 을 10GB 로 만들어도)

(컴퓨터는 4 GB)

(까지 밖에 인식하지 못합니다···.)

여기까지는 상당히 직관적이고

단순해서 이해하기 쉬웠을 것입니다.

그런데 C 를 만든 사람은

아주 유용하면서도 골때리는 것을

하나 새롭게 만들었습니다.

바로 ’포인터(pointer)’ 입니다.

영어를 잘하는 분들은

이미 아시겠지만 ’포인터’라는

단어의 뜻이 ’가리키는 것

(가르켜지는 대상체를)

(말하는 것이 아닙니다)’

이란 의미를 가지고 있다.

사실, 포인터는 우리가 앞에서 보았던

int 나 char 변수들과 다른 것이

전혀 아니다.

포인터도 ’변수’ 입니다.

int 형 변수가 정수 데이터,

float 형 변수가 실수 데이터를

보관했던 것 처럼,

포인터도 특정한

데이터를 보관하는 ’변수’다.

그렇다면 포인터는 무엇을

보관하고 있을까?

바로, 특정한 데이터가 저장된

주소값을 보관하는 변수이다.

여기서 강조할 부분은

’주소값’ 이라는것

여기서 그냥 머리에

세이브 하길 바란다.

이전에 다른 책들에서

배운 내용을

싹 다 잊어 버리고 그냥

알라의 요술봉(RPG-7)이나

파운쳐슈렉 or M203유탄으로

파계승 마냥 파괴하면 된다

포인터에는 특정한 데이터가

저장된 주소값을 보관하는

변수 라고 말이지.

크게 외치세요. ’주소값!!!!!’

암튼, 뇌가 완전히 세뇌되었다고

생각하면 다음 단계로

넘어가도록 하겠다.

아직도 이상한 잡념이 머리에 남아

있다면 크게 숨을 호흡하시고

주소값이라고 10 번만 외쳐 보도록하자..

자. 다 되었다. 이제 포인터의

세계로 출발해 보도록 하자.

다시 한 번 정리하자면

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

우리가 변수를 정의할 때 int 나

char 처럼 여러가지 형(type)들이

있었습니다. 그런데 놀랍게도

포인터에서도 형이 있었다.

이 말은 포인터가 메모리 상의

int 형 데이타의 주소값을 저장하는

포인터와, char 형 데이터의 주소값을

저장하는 포인터가 서로

다르다는 말이다.

응?? 여러분의 머리속에는 아래와

같은 생각이 번개 처럼 스쳐 지나갈것이다.

    아까 포인터는 주소값 을 저장하는 거래며. 근데 우리가 쓰는

    컴퓨터에선 주소값 이 무 조건 32 비트, 즉 4 바이트 래며! 그러면

    포인터의 크기는 다 똑같은것 아냐? 근데 왜 포인터가 형(type)을

    가지는 건데?!

포인터를 아주 조금만 배우면 왜

포인터에 형(type) 이 필요한지

알게 될것이다. C 언어에서

포인터는 다음과 같이 정의할 수 있다

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

혹은 아래와 같이 정의할 수 도 있습니다.

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

예를 들어 p 라는 포인터가

int 데이터를 가리키고 싶다고 하면

int* p; // 라고 하거나
int* p; // 로 하면 된다

라 하면 올바르게 된다.

즉 위 포인터 p 는 int 형

데이터의 주소값을 저장하는

변수가 되는 것이다.

& 연산자

아직 2% 부족하다.

포인터를 정의하였으면

값을 집어 넣어야 하는데,

도대체 우리가 데이터의 주소값을

어떻게 아냐는 말인가?

걱정 말자. 바로 & 연산자를

사용하면 된다. 그런데, 아마

복습을 철저하게 잘하신

분들은 당황할 수 도 있다.

왜냐하면 & 가 AND 연산자이기

때문이다.

(Chapter 4 참조)

그런데, & 연산자를 사용하기

위해서는 두 개의 피연산자를

사용해야 한다. 즉,

a& b; // 괜찮음
a& // 오류

와 같이 언제나 2 개가

필요 하다는 것이다.

그런데, 여기에서 소개할

& 연산자는 오직 피 연산자 가

1 개인 연산자 이다.

(이러한 연산자를)

(단항(unary) 연산자라 합니다)

따라서 위의 AND 연산자와

완전히 다르게 해석된다.

단항 & 연산자는 피 연산자 의

주소 값 을 불러온다.

사용하는 방법은 그냥

&/* 주소값을 계산할 데이터 */

예를 들어서 어떤 변수

a 의 주소 값 을 알고 싶다면

&a

로 사용하면 된다

한 번 프로그램을 만들어

보도록 하자.

 오전 7:43:56에 2021. 12. 14.에서 복원된 세션 콘텐츠 

Windows PowerShell
Copyright (C) Microsoft Corporation. All rights reserved.

새로운 크로스 플랫폼 PowerShell 사용 https://aka.ms/pscore6

PS D:\□□□□□_fFF□\□□□g□□□m□□□\F\> gcc -o test test.c
PS D:\□□□□□_fFF□\□□□g□□□m□□□\F\> ./test
0x7fff80505b64 
PS D:\□□□□□_fFF□\□□□g□□□m□□□\F\>

와 같이 나옵니다.

참고로, 여러분의 컴퓨터에 따라

결과가 다르게

나올 수 도 있습니다.

사실, 아무리 뻘짓을 해도

전혀 다르게 나올 것이다.

더 놀라운 것은 실행할 때

마다 결과가 달라질 것이다.

2 번째 실행한 것

PS D:\basic_file\Programming\C> ./test
0x7ffe37d03104
PS D:\basic_file\Programming\C> 

위와 같이 나오는 이유는

나중에 설명하겠지만 주목할 것은

어떠한 값이 출력 되었다는 것이다.

printf("%x \n", &a);

위 문장에서 &a 의 값을 16 진수

형태 (%p) 로 출력하라고 명령하였다.

근데요. 눈치가 있는 사람이라면 금방

알겠지만 위에서 출력 된 결과는

8 바이트 (16 진수로 16 자리)가 아니다!

(여러분의 컴퓨터는 다를 수 있습니다.)

제가 지금 64 비트 운영체제를 사용하고

있는데도 불구하고!

그렇다면 뭐가 문제인가?

사실, 문제는 없습니다.

단순히 앞의 0 이 잘린 것 이다.

주소값은 언제나 8 바이트 크기,

즉 16 진수로 16 자리 인데 앞에

0 이 잘려서 출력이 안된 것일 뿐이다.

따라서 변수 a 의 주소는 아마도

0x000007ffe37d03104가

될 것이다.

아무튼 위 결과를 보면,

적어도 제 컴퓨터 상에선

int 변수 a 는 메모리 상에서

0x7ffe37d03104를

시작으로 4 바이트의 공간을

차지하고 있었다는 사

실을 알 수 있다.

자, 이제 & 연산자를 사용하여

특정한 데이터의 메모리 상의

주소값을 알 수 있다는 사실

을 알았으니 배고픈

포인터에게 값을 넣어 보도록 하자.

/* 포인터의 시작 */
#include <stdio.h>
int main() {
  int *p;
  int a;

  p = &a;

  printf("포인터 p 에 들어 있는 값 : %p \n", p);
  printf("int 변수 a 가 저장된 주소 : %p \n", &a);

  return 0;
}

실행해 보면 많은 이들이 예상했던 것처럼···

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test    
포인터 p 에 들어 있는 값 : 0061FF18  
int 변수 a 가 저장된 주소 : 0061FF18 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

똑같이 나온다. 어찌 보면 당연한 일.

p = &a;

에서 포인터 p 에 a 의 주소를

대입하였기 때문이다.

참고로, 한 번 정의된 변수의

주소값은 바뀌지 않는다.

따라서 아래 printf 에서

포인터 p 에 저장된 값과

변수 a 의 주소값이 동일하게

나오게 된다. 어떤가. 쉽지 않는가?

* 연산자

현재 까지 우리가 배운 바로는

포인터는 특정한 데이터의 주소값을 보관한다.

이 때 포인터는 주소 값을 보관하는

데이터의 형에 * 를 붙임으로써 정의되고,

& 연산자로 특정한 데이터의 메모리 상의

주소값을 알아올 수 있다 였다.

& 연산자가 어떠한 데이터의

주소값을 얻어내는 연산자라면

거꾸로 주소값에서 해당 주소값에

대응되는 데이터를 가져오는 연산자가

필요하겠지? 이 역할은 바로

* 연산자가 수행한다!

잠깐만. * 연산자는 이미

곱셈 연산자로 사용되고 있지 않는가?

맞다. 다만, * 연산자가 피 연산자

두 개에 작용할 때만

곱셈 연산자로 해석된다. 즉,

a * b; // a 와 b 를 곱한다.
a *; // 오류!
*a; // 단항 * 연산자

와 같이 해석된다.

* 연산자의 역할을 쉽게 풀이하자면

“나(포인터)를 나에게 저장된 주소값에

위치한 데이터로 생각해줘!”

의 역할을 수행한다.

한 번 아래 예제를 보도록하자.

/* * 연산자의 이용 */
#include <stdio.h>
  int main() {
    int *p;
    int a;

    p = &a;
    a = 2;

    printf("a 의 값 : %d \n", a);
    printf("*p 의 값 : %d \n", *p);

  return 0;
}

가 된다.

int *p;
int a;

일단 int 데이터를

가리키는 포인터 p 와

int 변수 a 를 각각

정의하였다.

평범한 문장이다.

그리고 포인터 p 에 a 의 주소를

집어 넣어보았다.

그리고 a 에 2 를 대입하였다

printf("a 의 값 : %d \n", a);
printf("*p 의 값 : %d \n", *p);

일단 위의 문장은 단순 한다.

a 의 값을 출력하란 말이다.

당연하게도 2 가 출력 된다.

그런데, 아래에서 *p 의 값을

출력하라고 했습니다.

* 의 의미는 앞서, 나에 저장된

주소 값 에 해당하는 데이터로

생각하시오! 로 하게 하는

연산자라고 하였다.

따라서 *p 를 통해 p 에

저장된 주소(변수 a 의 주소)에

해당하는 데이터,

즉 변수 a 그 자체를 의미

할 수 있게 된다.

다시 말해 *p 와 변수 a 는

정확히 동일하다.

즉, 위 두 문장은

아래 두 문장과 백 프로 일치한다.

printf("a 의 값 : %d \n", a);
printf("*p 의 값 : %d \n", a);

마지막으로 * 와 관련된

예제 하나를 더 살펴 보도록 하자.

/* * 연산자 */
#include <stdio.h>
  int main() {
    int *p;
    int a;

    p = &a;
    *p = 3;

    printf("a 의 값 : %d \n", a);
    printf("*p 의 값 : %d \n", *p);

  return 0;
}

아마 많은 여러분들이

예상했던 결과 이길 바란다!

p = &a;
*p = 3;

위에서도 마찬가지로 p 에 변수 a

의 주소를 집어 넣었습니다.

그리고 *p 를 통해

“나에 저장된 주소(변수 a 의 주소)에

해당하는 데이터(변수 a) 로

생각하시오” 를 의미하여

*p = 3 은 a = 3 과 동일한

의미를 지니게 되었습니다.

어떤가. 간단하지 않는가?

이로써 여러분은 포인터의 50% 이상을

이해하신 것입니다∼!

Congratulations!

ଘ ( ≡ ω≡.)੭* ੈ

자. 그럼 포인터 라는

말자체의 의미를

생각해 보도록하자.

int 변수 a 와 포인터

p 의 메모리 상의

모습을 그리면 아래와 같다.

참고로 주소 값은 제가 임의로 정한 것이다.

포인터 p 에 어떤 변수 a 의 주소 값이

저장되어 있다면 포인터 p 는

변수 a 를 가리킨다 라고 말한다.

포인터 또한 엄연한 변수이기 때문에

특정한 메모리 공간을 차지한다.

따라서 위 그림과 같이 포인터도

자기 자신만의 주소를 가지고 있다.

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

여기 까지 왔다면 아마 다음과

같은 의문을 가질 수 있을 것이다.

   포인터가 주소값만 보관하는데 왜 굳이 타입이 필요할까?

   어차피 주소값은 32 비트 시스템에서 항상 4 바이트이고,

   64 비트 시스템에서는 8 바이트 인데 그냥

   pointer 라는 타입을 만들어버리면 안될까?

아주 좋은 질문 이다.

pointer 라는 타입이 있다고 생각해고

아래의 코드를 살펴보도록 하자

int a;
pointer *p;
p = &a;
*p = 4;

컴퓨터 입장에서 위 코드를 어떤 식으로

해석할 지 생각해 보도록 하자.

int a;
pointer *p;
p = &a;

위 세 문장 까지는 아주 좋습니다.

메모리에 a 를 위해서 4 바이트 짜리

공간을 마련해줬고, 마찬가지로

p 를 위해 메모리 상에 8 바이트

짜리 공간을 마련하였다.

그리고 p 에 a 의 주소 값을

잘 전달하였다.

문제는 아래 문장 이다.

*p = 4;

포인터 p 에는 명백히 변수 a 의

주소 값 이 들어 있습니다.

여기서 문제는 a 가 메모리에서

차지하는 모든 주소들의 위치가

들어 있는 것이 아니라

시작 주소 만 들어가 있다는 점이다.

따라서, *p 라고 했을 때

컴퓨터는 메모리에서

얼마만큼 을 읽어 들어야

할지 알 길이 없다. 한편

int a;
int *p;
p = &a;
*p = 4;

라고 한다면 어떨까?

컴퓨터는 포인터 p 가

int * 라는 사실을 보고 이 포인터는

int 데이터를 가리키는 구나!라고

알게 되어 시작 주소로 부터

정확히 4 바이트를

읽어 들어 값을 바꾸게 된다.

포인터도 변수다

/* 포인터도 변수이다 */
#include <stdio.h>
  int main() {
    int a;
    int b;
    int *p;

    p = &a;
    *p = 2;
    p = &b;
    *p = 4;

    printf("a : %d \n", a);
    printf("b : %d \n", b);
    return 0;
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
a : 2 
b : 4
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>
p = &a;
*p = 2;
p = &b;
*p = 4;

사실, 이런 예제까지

굳이 보여주어야 하나

하는 생각이 들었지만 그래도 혹시나

하는 마음에 했다.

앞에서도 말했듯이 포인터는 변수다.

즉, 포인터에 들어간 주소값이

바뀔 수 있다는 것.

위와 같이 처음에 a 를

가리켰다가,

(즉 p 에 변수 a 의)

(주소값이 들어갔다가)

나중에 b 를 가리킬 수

(즉 p 에 변수 b 의 주소값이 들어감)

있다는 것이다.

뭐 특별히 중요한 예제는

아니였습니다만.

나중에 상수 포인터, 포인터

상수에 대해 이야기 하면서

다시 다루어 보도록 하겠다.

마지막으로, 기록을 마치며 여러분에게

포인터에 대해 완벽히 뇌리에

꽂힐 만한 동화를 들려드리겠습니다.

예전에 대략 2 년 전에

A, B, C라는 세 명의 사람이

OO 아파트에 살고 있었다.

int chul, sue;
int *C;

그런데. C는 B를 너무나 좋아한 나머지

자기집 대문 앞에 큰 글씨로

“우리집에 오는 것들은 모두 A주세요”

라고 써 놓고 A주소를 적어 놓는다

C = &chul;

어느날 택배 아저씨가

C네 집에

물건을 배달하러

왔다가 C의 메세지를

보고 B네에 가져다 주게 됩니다.

*C = 3; // 사실 chul = 3 과 동일하다!

C에 짝사랑이 계속 되다가

어느날 C는 A 보다

더 미남인 B를 보게 된다.

결국 C는 마음이 변심하고

B를 좋아하기로 했죠. C는

자기대문 앞에 있던

메세지를 떼 버리고

“우리집 에 오는 것은 모두 B네 주세요.”

라 쓰고 B의주소를 적었다.

C = &sue;

며칠이 지나 택배 아저씨는

물건을 배달하러

C네에 왔다가 메세지를 보고

이번엔 B네에 가져다준다.

C = 4; // 사실 sue = 4 와 동일하다

상수 포인터, 포인터의 덧셈 뺄셈, 배열과 포인터

지난 기록에서 포인터의 기본 중의

기본이라 할 수 있는 것 들에 배워보았다.

다시 정리해 보자면 포인터는 특정한

데이터의 메모리 상의 (시작) 주소값을

보관하는 변수다.

제가 C 언어를 배우면서 포인터를

배울 때 가장 많이 든 생각은

근데 말야. 이거왜 배워?

이였다. 맞다. 여러분들도 위와

같은 생각이 머리속에

끊임없이 맴돌 것 이다.

int a; 와 int *p; 가

있을 때 p 가 a 를

가리킨다고 하면 a = 3; 이라하지

*p = 3; 과 같이 귀찮게 할 필요가

없잖는가? 하지만 나중에

가면 알겠지만 포인터는 C 언어에서

정말로 중요한 역할을 담당하게 될 것이다.

포인터의 중요한 역할에 대해

지금 이야기 하는 것은

무리라고 생각한다.

일단, 포인터가 뭔지만 알아 놓고 이

걸 도대체 왜 배우는지에

대해선 나중에 이야기 하도록 하자.

상수 포인터

이전에 11 – 1 강에서 상수에

대해 잠깐 언급한 것이

기억이 나는가?

그 때 저는 어떠한 데이터를

상수로 만들기 위해 그 앞에

const 키워드를 붙여주면

된다고 했다. 예를들어서

const int a = 3;

과 같이 값이 3 인

int 변수 a 를

상수로 정의할 수 있습니다.

const 는 단순히 말해서

’이 데이터의 내용은

절대로 바뀔 수 없다’

라는 의미의 키워드 이다.

따라서, 위 문장의 의미는

’이 int 변수 a 의 값은

절대로 바뀌면 안된다!!!’

가 됩니다. 위와 같이

정의한 상수 a 를 아래 문장에

a = 4;

와 같이 하려고

해도 컴파일 시에

오류가 발생하게 된다.

왜냐하면 a 는 상수로 선언이

되어 있으므로 값이 절대로 변경될

수 없기 때문이다.

심지어 ’값이 변경될 가능성이

있는 문장’ 조차 허용되지 않는다.

예를 들어

a = 3;

이라고 한다면, a 의 값은

이미 3 이므로 a 의 값은

바뀌지 않는다.

그런데 왠일? 컴파일 해보면

오류가 출력된다.

왜냐하면 위 문장은 a 의

값이 바뀔 ’가능성’ 이 있기

때문이다.

즉, 컴파일러는 a 에 무슨

값이 들어가 있는지

신경 쓰지 않는다.

그냥 무조건 가능성이

있다면 오류를 출력한다.

여러분은 도대체 왜

상수를 사용하는지 의문을

가질 것이다.

하지만 상수는 프로그래밍

상에서 프로그래머들의 실수를

줄여주고, 실수를 했다고 해도

실수를 잡아내는데 중요한

역할을 하고 있다.

예를 들어 아래와

같은 문장을 본다.

const double PI = 3.141592;

즉 double 형 변수

PI 를 3.141592라는

값을 가지게 선언하였다.

왜 이렇게 해도 되냐면 실제로

PI 값은 절대로 바뀌지 않는

상수 이기 때문이다.

따라서, 프로그래머가

밤에 졸면서 코딩을

하다가 아래와 같이

PI = 10;

PI 의 값을 문장을 집어

넣었다고 해도 컴파일 시 오류가

발생하여 프로그래머는 이를

고칠 수 있게 된다.

반면에 PI 를 그냥

double 형 변수로 선언했다고 본다.

double PI = 3.141592;

그렇다면 프로그래머가 아래와

같은 코드를 잠결에 집어 넣었다면

PI = 10;

컴파일러는 이를 오류로 처리하지 않는다.

이는 엄청나게 큰일이 아닐 수 없다.

만일 고객들에게 원의 넓이를 계산하는

프로그램을 만들어 주었는데 잘못해서

이상한 값이 나오면 어떻겠는가?

물론 위와 같이 간단한 오류는

잡아내기 쉽지만 프로그램이 커지만

커질 수록 위와 같은 오류를 잡아내는

것은 여간 힘든 일이 아니다.

따라서, 우리는 ’절대로 바뀌지

않을 것 같은 값에는 무조건

const 키워드를 붙여주는 습관’

을 기르는 것이 중요하다.

아무튼. 이번에는 포인터에도

const 를 붙일 수 있는지

생각해 보도록 하자.

/* 상수 포인터? */
#include <stdio.h>
  int main() {
    int a;
    int b;
    const int* pa = &a;

    *pa = 3; // 올바르지 않은 문장
    pa = &b; // 올바른 문장

    return 0;
}

컴파일 해보면 오류가 발생한다.

컴파일 오류
error C2166: l-value가 const 개체를 지정합니다.

일단, 위 오류가 왜 발생 하였는지 에

대해 이야기 하기 앞서서 아래

문장이 무슨 의미를

가지는지 살펴 보도록 하자.

const int* pa =
&a; // int* pa 와 같이 정의해도 int *pa 와 같다는 사실은 다 알고 있죠?

여러분은 위 문장을 보면

다음과 같은 생각이 떠오를 것이다.

“저 포인터는 const int 형을 가리키는

포인터인데, 어떻게 int 형 변수 a 의

주소값이 대입 될 수 있지?

그러면 안되는 거 아니 야?”.

하지만, 제가 앞에서

강조해 왔듯이

const 라는 키워드는

이 데이터의 값은 절대로

바뀌면 안된다 라고

일러주는 키워드라고 하였다.

다시 말해, const int a 라는

변수는 그냥 int 형 변수 a 인데

값이 절대로 바뀌면 안되는

변수일 뿐이다. 따라서,

const int a 변수도 그냥

int 형이라 말할 수 있다.

(다만 ’변’수가 아닐 뿐)

따라서 const int* 의 의미는

const int 형 변수를 가리킨다는

것이 아니다. int 형 변수를

가리키는데 그 값을 절대로

바꾸지 말라 라는 의미다.

즉, pa 는 어떠한 int 형 변수를

가리키고 있다. 그런데 const가

붙었으므로 pa 가가리키는

변수의 값은 절대로 바뀌면

안되게 된다.

여기서 pa 가 라는 부분을

강조한 이유는 a 자체는

변수 이므로 값이 자유롭게

변경될 수 있기 때문이다.

하지만 pa 를 통해서 a 를

간접적으로 가리킬 때 에는

컴퓨터가 아, 내가 const 인

변수를 가리키고 있구나 로

생각하기 때문에

(const int* 로 포인터를)

(정의하였으므로)

값을 바꿀 수 없게 된다.

결과적으로 아래의

문장은 오류를 출력한다.

*pa = 3; // 올바르지 않은 문장

물론 a = 3; 과

같은 문장은 오류를

출력하지 않는다.

앞에서도 말했듯이 변수 a

자체는 const 가

아니기 때문이다.

pa = &b; // 올바른 문장

그렇다면 위 문장은

옳은 문장이다.

왜 일까?

(아마 당연하다고 생각하면)

(여러분은 훌륭한 학생들 입니다)이는

아래 예제와 함께

설명하도록 하겠다.

/* 상수 포인터? */
#include <stdio.h>
int main() {
  int a;
  int b;
  int* const pa = &a;

  *pa = 3; // 올바른 문장
  pa = &b; // 올바르지 않은 문장

  return 0;
}

역시 컴파일 해보면

 컴파일 오류
 error C2166: l-value가 const 개체를 지정합니다.  

앞서 보았던 오류와

동일한 오류가 뜹다.

그런데 위치가 다르다.

앞에서는 위 문장에서 오류가

발생했는데 이번엔

아래에서 발생한다.

일단, 포인터의 정의

부분 부터 이야기 해봅시다.

int* const pa = &a;

차근차근 봐 보면, 우리는

int* 를 가리키는 pa 라는

포인터를 정의하였다.

그런데 이번에는 const

키워드가 int* 앞에 있는것이

아니라 int* 와 pa 사이에

놓이고 있다. 뭐지?

하지만 이 것은 const 키워드의

의미를 그대로 생각해 보면 간단하다.

pa 의 값이 바뀐 안된다는 것이다.

그런데 제일 처음에 포인터를 배울 때

강조했듯이, 포인터에는 가리키는

데이터의 주소값, 즉 위 경우 a 의

주소값이 pa 저장되는 것.

따라서, 이 pa 가 const 라는

의미는 pa 의 값이 절대로

바뀔 수 없다는 것인데,

pa 는 포인터가 가리키는 변수의

주소값이 들어 있으므로 결과적으로

pa 가 처음에 가리키는 것 (a) 말고

다른 것은 절대로 건드릴 수 없다는 것이다.

 pa = &b; // 올바르지 않은 문장

결론적으로 위 문장은

오류를 출력 하게 된다.

왜냐하면 pa 가 다른 변수를

가리키기 때문이다

(즉 pa 에 저장된 주소 값 을 바꾸므로)

반면에 위의 예제에서 오류가 났던

문장은 올바르게 돌아간다.

*pa = 3; // 올바른 문장

왜냐하면 pa 가 가리키는

값을 바꾸면 안된다는

말은 안했기 때문이다.

(그냥 int*) 한 번 위에

나와있던 것을 모두 합쳐 보면

/* 상수 포인터? */
#include <stdio.h>
  int main() {
    int a;
    int b;
    const int* const pa = &a;

    *pa = 3; // 올바르지 않은 문장
    pa = &b; // 올바르지 않은 문장

  return 0;
}

와 같이 되겠다.

어떠한가? 쉽지 않는가?

포인터의 덧셈

이번에는 포인터의

덧셈과 뺄셈에 대해서

다루어 보도록 하겠다.

앞에서도 강조하였지만 지금 하는

작업들이 무의미해 보이고

쓸모 없어 보이지만 나중에

정말로 중요하게 다루어 진다.

조금만 힘내보도록 하자

(아마도 C 언어에서)

(가장 재미 없는 부분 일듯.)

/* 포인터의 덧셈 */
#include <stdio.h>
  int main() {
    int a;
    int* pa;
    pa = &a;

    printf("pa 의 값 : %p \n", pa);
    printf("(pa + 1) 의 값 : %p \n", pa + 1);

  return 0;
}

성공적으로 컴파일 해보면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test    
pa 의 값 : 0061FF18
(pa + 1) 의 값 : 0061FF1C       
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

여러분의 출력 결과는

위에 나온 결과와 다를 수 있다.

다만, 두 수의 차이는 4 일 것입이.

(16 진수임에 유의하세요)

(; 50 에서 4c를 빼면 4!)

아마 여러분은 출력된 결과를

보면서 깜짝 놀랐을 것이다.

우리는 분명히

printf("(pa + 1) 의 값 : %p \n", pa + 1);

에서 pa + 1 의 값을 출력하라고

명시하였다.

제가 앞에서도 이야기 하였듯이

pa 에는 자신이 가리키는 변수의

주소값이 들어간다.

따라서, pa + 1 을하면

0x7ffd6a32fc4c에

1 이 더해진

0x7ffd6a32fc4d가

아니라, 4 가 더해진

0x7ffd6a32fc50이 출력되었다.

이게 도대체 무슨 일인가?

0x7ffd6a32fc4c + 1 = 0x7ffd6a32fc50

이라고?

위 해괴한 계산 결과를

해결하기 앞서,

우리는 포인터의 형이

int* 라는 것을 알 수 있었다.

그런데 int 가 4 바이트

이니까…설마?

일단, 위 추측을 확인해보기 위해

int 포인터 말고도 크기가 다른

char 이다 double 등에

대해서 도 해보도록하자.

/* 과연? */
#include <stdio.h>
  int main() {
    int a;
    char b;
    double c;
    int* pa = &a;
    char* pb = &b;
    double* pc = &c;

    printf("pa 의 값 : %p \n", pa);
    printf("(pa + 1) 의 값 : %p \n", pa + 1);
    printf("pb 의 값 : %p \n", pb);
    printf("(pb + 1) 의 값 : %p \n", pb + 1);
    printf("pc 의 값 : %p \n", pc);
    printf("(pc + 1) 의 값 : %p \n", pc + 1);

  return 0;
}

성공적으로 컴파일 후 실행해 보면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test                               
pa 의 값 : 0061FF10 
(pa + 1) 의 값 : 0061FF14 
pb 의 값 : 0061FF0F       
(pb + 1) 의 값 : 0061FF10 
pc 의 값 : 0061FF00       
(pc + 1) 의 값 : 0061FF08 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

여러분의 출력 결과는

위에 나온 결과와 다를 수 있다.

우리의 예상과

정확하게 맞아 떨어졌다.

pb 의 경우 1 이 더해졌고,

pc 의 경우 8 이 더해졌다.

그런데, char 은 1 바이트,

double 은 8 바이트 이므로

모두 우리가 예상한 결과와 일치한다.

놀랍다. 하지만 머리 한 켠에는

또다른 의문이 남는다.

왜 하라는 대로 안하고 포인터가

가리키는 형의 크기 만큼 더할까?

사실 이에 대한 해답은 뒤에 나온다.

훌륭한 학생이라면 여러가지

모험을 해볼 것이다.

예를 들어 포인터의 뺄셈은

허용되는지, 포인터 끼리 더해도

되는지 등등..

우리도 한 번 궁금증을

해결해 보도록하자.

일단 직관적으로 포인터의 뺄셈은

허용될 것 같다.

왜냐하면 뺄셈은 본질적으로

덧셈과 다를 바 없기 때문이다.

(1 – 1 = 1 + (-1))

아무튼 해 보면 덧셈과

유사한 결과가 나타낸다.

/* 포인터 뺄셈 */
#include <stdio.h>
  int main() {
    int a;
    int* pa = &a;

    printf("pa 의 값 : %p \n", pa);
    printf("(pa - 1) 의 값 : %p \n", pa - 1);

  return 0;
}

성공적으로 컴파일 후 실행 해보면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
pa 의 값 : 0061FF18
(pa - 1) 의 값 : 0061FF14       
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

여러분의 출력 결과는 위에

나온 결과와 다를 수 있다.

역시 우리의 예상대로 4 가 빼졌다.

/* 포인터끼리의 덧셈 */
#include <stdio.h>
  int main() {
    int a;
    int *pa = &a;
    int b;
    int *pb = &b;
    int *pc = pa + pb;

  return 0;
}

아마 컴파일 해보면

아래와 같은 오류를 만날 수 있다.

컴파일 오류
 error C2110: ‘+’ : 두 포인터를 더할 수 없습니다.
 

왜 C 에서는 두 포인터끼리의

덧셈을 허용하지 않는 것일까?

사실, 포인터끼리의 덧셈은

아무런 의미가 없을 뿐더러

필요 하지도 않는다.

두 변수의 메모리 주소를

더해서 나오는 값은 이전에

포인터들이 가리키던 두 개의

변수와 아무런 관련이 없는

메모리 속의 임의의 지점이다.

아무런 의미가 없는 프로그램 상에

상관없는 지점을 말한다.

무언가, 설명이 불충분한 느낌이

들지만 아무 튼 포인터 끼리의

덧셈은 아무런 의미가 없기

때문에 C 언어에선 수행할 수 없다.

그렇다면, 포인터에 정수를 더하는

것은 왜 되는 것일까요.

아까도 말했듯이 이에 대해선

아래에서 설명해 드리겠다.

그런데 한 가지 놀라운 점은

포인터끼리의 뺄셈은 가능하다는

것이다.

왜 그런지에 대한 설명은

나중에 해보도록하겠다.

/* 포인터의 대입 */
#include <stdio.h>
  int main() {
    int a;
    int* pa = &a;
    int* pb;

    *pa = 3;
    pb = pa;

    printf("pa 가 가리키고 있는 것 : %d \n", *pa);
    printf("pb 가 가리키고 있는 것 : %d \n", *pb);

  return 0;
}

와 같이 나온다.

뭐 당연한 결과이다

pb = pa;

부분에서 pa 에

저장되어 있는 값

(즉, pa 가 가리키고 있는)

(변수의 주소값)을 pb 에

대입하였다.

따라서 pb 도 pa 가

가리키던 것의 주소값을 가지게

되는 것이다. 결과적으로 pb 와

pa 모두 a 를 가리키게 된다.

주의해야 될 점은 pa 와 pb 가

형이 같아야 한다는 점.

다시 말해 pa 가 int*면

pb 도 int* 여야한다.

만일 형이 다르다면

형변환을 해주어야 하는데

이에 대한 언급은 나중에.

배열과 포인터

  아마 이 단원을 읽다 보면 쇼크를 받을지도

  모르므로 심장이 약하신 분들은 의사와 함께 하십시오.

  (참고로 저의 경우 많이 놀라서 잠을 잘 못잤습니다)

 
 

본인이 C 언어를 배우면서 가장

감탄하고도 쇼킹했던 부분이

바로 여기였다.

물론, 모든 사람들이

그다지 놀라워

하는 것은 아니지만

저한테는 신선한 충격이였다.

아마 이 단원을 배운다면

앞서 ’포인터의 연산은 왜 이따

구로 하는 거야!’ 에 대한

답안을 찾을 수 있을 것.

배열은

변수가 여러개 모인 것으로

생각할 수 있다라고

이야기 했었다.

또다른 놀라운 특징이 있다.

바로 배열들의 각 원소는

메모리 상에 연속되게

놓인다는 점이다.

뭐, 놀랍지 않다면 말고요. 어쨋든,

int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

이라는 배열을 정의한다면

메모리 상에서 다음과

같이 나타낸다

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/3.png

즉, 위와 같이 메모리 상에

연속된 형태로 나타난다는 점이다.

한 개의 원소는 int 형 변수이기

때문에 4 바이트를 차지하게 된다.

물론, 위 사실을 믿지 못하시는

분들은 아래와 같이 컴퓨터를 통해

직접 확인해 볼 수 있다.

/* 배열의 존재 상태? */
#include <stdio.h>
  int main() {
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int i;

    for (i = 0; i < 10; i++) {
      printf("arr[%d] 의 주소값 : %p \n", i, &arr[i]);
  }

  return 0;
}

성공적으로 컴파일 하면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[0] 의 주소값 : 0061FEF4 
arr[1] 의 주소값 : 0061FEF8 
arr[2] 의 주소값 : 0061FEFC 
arr[3] 의 주소값 : 0061FF00 
arr[4] 의 주소값 : 0061FF04 
arr[5] 의 주소값 : 0061FF08 
arr[6] 의 주소값 : 0061FF0C
arr[7] 의 주소값 : 0061FF10
arr[8] 의 주소값 : 0061FF14
arr[9] 의 주소값 : 0061FF18
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 나타낸다.

여러분의 결과와 주소값은

약간 다를 수 있지만,

어쨋든 4 씩 증가하면 된 것이다.

아마 여기쯤 왔다면

여러분의 머리를 스쳐지나가는

생각이 들 것이다!

아! 포인터로도 배열의 원소에

쉽게 접근이 가능하겠구나!

(이 생각이 떠오르지 않는 사람은)

(아마 이 글을)

(다시 처음부터 읽으셔야 합니다.)

배열의 시작 부분을 가리키는

포인터를 정의한 뒤에 포인터에

1 을 더하면 그 다음 원소를

가리키겠군! 그리고 2 를 더한

그 다음 다음 원소를 가리킨다!!

위와 같은 일이 가능한

이유는 포인터는 자신이 가리키는

데이타의 ’형’ 의 크기를 곱한 만큼

덧셈을 수행하기 때문이다.

즉 p 라는 포인터가 int a;를

가리킨다면 p + 1 을 할 때

p 의 주소값에 사실은 1*4 가

더해지고,

p + 3 을 하면 p 의 주소값에

3 * 4 인 12 가 더해진다는 것이다.

한 번 이 아이디어를 적용시켜서

배열의 원소를 가리키는

포인터를 만들어 보도록하자.

/* 과연? */
#include <stdio.h>
  int main() {
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int* parr;
    int i;
    parr = &arr[0];

    for (i = 0; i < 10; i++) {
      printf("arr[%d] 의 주소값 : %p ", i, &arr[i]);
      printf("(parr + %d) 의 값 : %p ", i, (parr + i));

      if (&arr[i] == (parr + i)) {
        /* 만일 (parr + i) 가 성공적으로 arr[i] 를 가리킨다면 */
        printf(" --> 일치 \n");
    } else {
      printf("--> 불일치\n");
    }
  }

  return 0;
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[0] 의 주소값 : 0061FEF0 (parr + 0) 의 값 : 0061FEF0  --> 일치 
arr[1] 의 주소값 : 0061FEF4 (parr + 1) 의 값 : 0061FEF4  --> 일치 
arr[2] 의 주소값 : 0061FEF8 (parr + 2) 의 값 : 0061FEF8  --> 일치 
arr[3] 의 주소값 : 0061FEFC (parr + 3) 의 값 : 0061FEFC  --> 일치 
arr[4] 의 주소값 : 0061FF00 (parr + 4) 의 값 : 0061FF00  --> 일치 
arr[5] 의 주소값 : 0061FF04 (parr + 5) 의 값 : 0061FF04  --> 일치 
arr[6] 의 주소값 : 0061FF08 (parr + 6) 의 값 : 0061FF08  --> 일치
arr[7] 의 주소값 : 0061FF0C (parr + 7) 의 값 : 0061FF0C  --> 일치
arr[8] 의 주소값 : 0061FF10 (parr + 8) 의 값 : 0061FF10  --> 일치
arr[9] 의 주소값 : 0061FF14 (parr + 9) 의 값 : 0061FF14  --> 일치
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

정확히 모두 일치가 나온다.

위 소스코드가 이해가 안되는

분들이 있을 까봐 살짝

설명을 드리기는 하겠다.

parr = &arr[0];

parr 이라는 int 형을

가리키는 포인터는

arr[0] 이라는 int 형

변수를 가리킨다.

(배열의 각 원소는 하나의)

(변수로 생각할 수 있다는)

(사실은 까먹지 않았죠?)

printf("arr[%d] 의 주소값 : %p ", i, &arr[i]);
printf("(parr + %d) 의 값 : %p ", i, (parr + i));

이제, arr[i] 의

주소값과 (parr + i)의

값을 출력해보자.

만일 parr + i 의 값이 arr[i]의

주소값과 같다면 하단의

if-else 에서 일치가

출력되고 다르다면 불일치가

출력되게 된다.

그런데, 이미 예상하고

있던 바이지만 parr 이 int형

이므로 + i 를 하면 주소값에는

사실상 4*i 가 더해지게

되는 것이다.

이 때 arr[i] 의 주소값도 i 가

하나씩 커질 때 마다 4 씩

증가하므로

(int 형 배열이므로)

결과적으로 모든 결과가

일치하게 되는 것이다

이렇게 포인터에 정수를

더하는 것 만으로도 배열의

각 원소를 가리킬 수 있다.

그렇다면 * 를 이용하여

원소들과 똑같은 역할을

할 수 있게 되겠다.

마치 아래 예제 처럼.

#include <stdio.h>
  int main() {
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int* parr;

    parr = &arr[0];

    printf("arr[3] = %d , *(parr + 3) = %d \n", arr[3], *(parr + 3));
    return 0;
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[3] = 4 , *(parr + 3) = 4 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 동일하게

접근할 수 있게 된다.

즉 parr + 3 을 수행하면,

arr[3] 의 주소값이 되고,

거기에 * 를 붙여주면

* 의 연산자의 역할이 ’

그 주소값에 해당하는 데이터를

의미해라!’ 라는 뜻이므로

*(parr + 3) 은 arr[3] 과

동일하게 된다는 것.

어떤가? 놀랍지않는가?.

포인터의 덧셈이 왜 그렇게

수행되는지 속 시원하게

해결되는 것 같은가?

배열의 이름의 비밀

아마 여러분들 중 대다수는

배열을 처음 배울 때 다음과

같은 실수를 하신 경험이 있을 것이다.

#include <stdio.h>
 int main() {
   int arr[3] = {1, 2, 3};
   printf("%d", arr);
}

그러곤 1 도, 2 도, 3 도,

아닌 이상한 값이 나오는 것을

보고 당황하셨겠지.

그런데, 놀랍게도 그 때

출력되는 값은 아래와 같다.

#include <stdio.h>
  int main() {
    int arr[3] = {1, 2, 3};

    printf("arr 의 정체 : %p \n", arr);
    printf("arr[0] 의 주소값 : %p \n", &arr[0]);

  return 0;
}

성공적으로 컴파일 하면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr 의 정체 : 0061FF14
arr[0] 의 주소값 : 0061FF14     
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 나옵니다.

와우! 놀랍게도

arr 과 arr[0] 의 주소값이

동일하다.

따라서 배열에서 배열의

이름은 배열의 첫 번째

원소의 주소값을 나타내고

있다는 사실을 알 수 있다.

그렇다면 배열의 이름이

배열의 첫 번째 원소를

가리키는 포인터라고

할 수 있을까? 아니다!

주의 사항
 이 부분은 (저를 포함한) 많은 사람들이
 헷갈렷던 부분들 중 하나 입니다.
 포인터를 갓 배운 상태에서 읽어보면 이해가
 잘 가지 않을 수 도 있으니, 나중에 포인터와 조금
 친숙해진다면 꼭 다시 읽어보는 것을 추천합니다.

배열은 배열이고

포인터는 포인터이다.

예를 들어서 다음과 같이

sizeof 를 사용하는

코드를 살펴보도록 하자.

기억을 상기해보자면 sizeof 는

크기를 알려주는 연산자다.

#include <stdio.h>
  int main() {
    int arr[6] = {1, 2, 3, 4, 5, 6};
    int* parr = arr;

    printf("Sizeof(arr) : %d \n", sizeof(arr));
    printf("Sizeof(parr) : %d \n", sizeof(parr));
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
Sizeof(arr) : 24
Sizeof(parr) : 4
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 나온다.

printf("Sizeof(arr) : %d \n", sizeof(arr));

sizeof 를 arr 자체에

그대로 썼을 경우 배열의 실제

크기 가 나온다. 우리의 arr

배열에는 int 원소 6 개가

있으므로 크기가 24 가 되겠다.

반면에 parr 에 sizeof

연산자를 사용하였을 경우

printf("Sizeof(parr) : %d \n", sizeof(parr));

배열의 자체의 크기가

아니라 그냥 포인터의 크기를 알려준다

(64 비트 컴퓨터 이므로)

(출력된 것 처럼 8 바이트 겠지요).

따라서 배열의 이름과,

첫 번째 원소의 주소값은

엄밀히 다른 것 인 것이다.

그렇다면 도대체 왜 두 값을

출력 했을 때 같은 값이 나왔을까?

그 이유는 C 언어 상에서

배열의 이름이 sizeof 연산자나

주소값 연산자(&)와 사용될 때

(예를 들어 &arr)

경우를 빼면,

배열의 이름을 사용시

암묵적으로 첫 번째 원소를

가리키는 포인터로

타입 변환되기 때문이다.

그렇다면 이제 왜 아래

코드에서 배열의 시작 원소의

주소값이 나왔는지 이해가 가는가?

#include <stdio.h>
  int main() {
    int arr[3] = {1, 2, 3};

    printf("arr 의 정체 : %p \n", arr);
    printf("arr[0] 의 주소값 : %p \n", &arr[0]);

  return 0;
}

arr 이 sizeof 랑도,

주소값 연산자랑도

사용되지 않았기에,

arr 은 첫 번째 원소를

가리키는 포인터로 타입

변환되었기에, &

arr[0] 와 일치하게 된다.

[] 연산자의 역할

여러분들 중에서 많은 분들은

[] 가 연산자였다는

사실을 보고 깜짝 놀랐을 것.

그런데, Chapter 4 에서

연산 순위에 대해 이야기

하였을 때 눈썰미가

좋으신 분들은 [] 가

연산자로 나와있음을 보셨을 것.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/4.png

그런데, 우리는 앞서

포인터 연산이 어떻게

돌아가는지 배웠기 때문에

[] 연산자의 역할을

대충 짐작할 수 있었다.

/* [] 연산자 */
#include <stdio.h>
  int main() {
    int arr[5] = {1, 2, 3, 4, 5};

    printf("a[3] : %d \n", arr[3]);
    printf("*(a+3) : %d \n", *(arr + 3));
    return 0;
}

성공적으로 컴파일 했다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
a[3] : 4
*(a+3) : 4
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

음… 이미 앞에서 다룬 내용을

모두 이해했더라면 위 정도쯤은

쉽게 이해할 수 있을 것입니다.

사실 컴퓨터는 C 에서 [] 라는

연산자가 쓰이면 자동적으로

위 처럼 형태로 바꾸어서

처리하게 된다.

즉, 우리가 arr[3] 이라

사용한 것은 사실 *(arr + 3) 으로

바뀌어서 처리가 된다는 뜻이다

그리고 arr 은 + 연산자와

사용되기 때문에 앞서 말했듯이

첫 번째 원소를 가리키는

포인터 로 변환 되어서

arr + 3 이 포인터 덧셈을

수행하게 된다. 그리고 이는

배열의 4 번째

원소를 가리키게 되겠지.

따라서 다음과 같이

신기한 연산도 가능하다.

/* 신기한 [] 사용 */
#include <stdio.h>
int main() {
    int arr[5] = {1, 2, 3, 4, 5};

    printf("3[arr] : %d \n", 3 [arr]);
    printf("*(3+a) : %d \n", *(arr + 3));
    return 0;
}

성공적으로 컴파일 하면

 오후 6:26:51에 2021. 12. 14.에서 복원된 세션 콘텐츠 

새로운 크로스 플랫폼 PowerShell 사용 https://aka.ms/pscore6

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
3[arr] : 4 
*(3+a) : 4 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

3[arr] 은 무언가 조금

이상한 표현이다. 사실 이렇게

사용한다면 가독성도 떨어지고

한 번에 이해도 되지 않기에

대부분의 프로그래머들은

arr[3] 으로 사용할 것이다.

하지만, 앞에서도 [] 는

연산자로 3[arr] 을

*(3+arr) 로 바꿔주기

때문에 arr[3] 과 동일한

결과를 출력할 수 있게 된다.

포인터의 정의

앞에서 말하기를 int 를

가리키는 포인터를 정의하기

위해 다음의 두 문장을

모두 사용할 수 있다고 했다.

int* p;
int *p;

그런데 말이죠. 제 강좌

말도 다른 곳에서 C 언어를

공부했던 사람들이라면

아래와 같은 형식을 훨씬

많이 쓴다는 사실을

알 수 있었을 것이다.

int *p;

왜 일까?

우리가 int 형 변수를

여러개 한 번에 선언하려

했을 때 int a,b,c,d;라

하잖는가.

포인터 변수를 여러개 선언

하려면 아래와 같이 해야한다.

int *p, *q, *r;

물론

int *p, *q, *r;

게 해도 됩니다. 다만,

int* p;

꼴로 한다면 다음과

같이 실수 할 확률이

매우 커지게 된다.

왜냐하면 아래와

같이 한다면

int *p, q, r;

p 만 int 를 가리키는

포인터 이고, q, r 은

평범한 int 형 변수가 된다.

따라서, 앞으로 저는

제 강좌에서 모든 포인터들은

int *p;

꼴로 선언 하도록 하겠다.

포인터의 포인터

아마도 이번 기록이

최대의 난해할 듯 하다.

이번 기록을 잘 이해하냐,

이해 못하냐에 따라서

C 언어가 쉽다/어렵다가

완전히 좌우된다.

그러니 지금 졸린 사람들은

잠을 자고 쌩쌩할 때 오시거나

따뜻한 커피 or 코코아 한잔

하길 바란다.

(아마도 이 부분이 C 언어에서)

(가장 어려울 부분이)

(될 듯 하네요..)

잠깐 지난 시간에 배웠던

것을 머리속으로

상기시켜보도록하자. 일단,

배열을 배열이고 포인터는 포인터이다. 다만;

                   • sizeof 와 주소값 연산자와 함께 사용할

                     때를 제외하면, 배열의 이름은

                     첫 번째 원소를 가리 킨다.

                   • arr[i] 와 같은 문장은 사실 컴파일러에

                     의해 *(arr + i) 로 변환된다.

이 두 가지 사실을 머리속에

잘 들어 있겠지. 만일 위

두 문장을 읽으면서 조금이라도

의구심이 드는 사람은 바로

뒤로가기를 눌러서

이전 기록을 보시기 바란다.

1 차원 배열 가리키기

일단, 강의의 시작은 간단한

것으로 해보겠다.

이전해도 말했듯이

int arr[10]; 이라는 배열을

만든다면 앞서 이야기한

두 가지 경우를 제외한다면

arr 이 arr[0] 을 가리키는

포인터로 타입 변환 된다고 하였다.

그렇다면 다른 int* 포인터가

이 배열을 가리킬 수 있지 않을까?

한 번 프로그램을 만들어 보도록하자.

#include <stdio.h>
  int main() {
    int arr[3] = {1, 2, 3};
    int *parr;

    parr = arr;
    /* parr = &arr[0]; 도 동일하다! */

    printf("arr[1] : %d \n", arr[1]);    
    printf("parr[1] : %d \n", parr[1]);
    return 0;
}

성공적으로 컴파일 한다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[1] : 2
parr[1] : 2
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

일단, 중점적으로

볼 부분은 아래와 같다.

parr = arr;

바로 arr 를 parr 에 대입하는

부분이다. 앞에서 말했듯이

arr 은 배열의 첫 번째

원소를 가리키는 포인터로

변환되고, 그 원소의 타입이

int 이므로, 포인터의 타입은

int* 가 되겠다.

위 문장은 아래와

정확히 동일한 문장이 된다.

parr = &arr[0]

따라서, parr 을 통해서 arr

을 이용했을 때와 동일하게

배열의 원소에 마음껏 접근

할 수 있게 되는 것이 된다.

위 모습을 한 번 그림으로

나타내보면

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/5.png
 
참고적으로 한 방의 크기는 그림의 단순화를 위해 4 바이트로 하였다.
/* 포인터 이용하기 */
#include <stdio.h>
int main() {
    int arr[10] = {100, 98, 97, 95, 89, 76, 92, 96, 100, 99};

    int* parr = arr;
    int sum = 0;

    while (parr - arr <= 9) {
        sum += (*parr);
        parr++;
    }

    printf("내 시험 점수 평균 : %d \n", sum / 10);
    return 0;
}

성공적으로 컴파일 하면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
내 시험 점수 평균 : 94
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

일단, 포인터를 이용한

간단한 예제를 다루어보겠다.

int* parr = arr;

먼저, int 형 1 차원 배열을

가리킬 수 있는 int* 포인터를

정의하였다.

그리고, 이 parr 은

배열 arr 을 가리키게 된다.

while (parr - arr <= 9) {
    sum += (*parr);
    parr++;
}

그 다음 while 문을 살펴보도록하자.

while 문을 오래전에 봐서 기억이

안난다면 여기로 돌아가보도록하자!

이 while 문은 parr – arr 이

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

sum 에 parr 이 가리키는

원소의 값을 더했다.

+= 연산자의 의미는 알거라 생각된다

sum += (*parr); 문장은

sum = sum + *parr 와

같다는 것 알고 있으리라 생각한다

parr++;

parr 을 1 증가시켰다.

이전 강좌에서도 이야기

하였지만 포인터 연산에서

1 증가시킨다면, parr 에

저장된 주소값에 1

이 더해지는 것이 아니라 1 *

(포인터가 가리키는)

(타입의 크기)

가더해진다는 것이다.

즉, int 형 포인터 이므로

4 가 더해지게되서,

배열의 그 다음 원소를

가리킬 수 있게 됩니다.

아무튼, 위 작업을 반복하면

arr 배열의 모든 원소들의

합을 구하게 됩니다.

while 문에서 9 이하

일 동안만

반복하는 이유는,

parr – arr >= 10

이 된다면 parr

[10 이상의 값]

을 접근하게 되므로

오류를 출력하게 된다.

여기서 궁금한 것이 없는가?

우리가 왜 굳이 parr 을

따로 선언하였을까?

우리는 arr 이 arr[0]을

가리킨다는 사실을 알고

있으므로 arr 을 증가시켜서

*(arr) 으로 접근해도 되지

않을까?

한 번, arr 의 값을

변경할 수 있는지

없는지 살펴보도록하자.

/* 배열명 */
#include <stdio.h>
    int main() {
        int arr[10] = {100, 98, 97, 95, 89, 76, 92, 96, 100, 99};

        arr++; // 오류
        return 0;
}

컴파일 해보면

 주의 사항
 error C2105: ‘++’에 l-value가 필요합니다.

와 같은 오류를 만나게 된다.

배열의 이름이 첫 번째 원소를

가리키는 포인터로 타입 변경

된다고 했을 때, 이는 단순히

배열의 첫 번째 원소를 가리키는

주소값 자체가 될 뿐이다.

따라서 arr++ 문장은

C 컴파일러 입장에서

다음을 수행한 것과같다.

(0x7fff1234) ++;

이는 애초에 말이 안되는 문장 이다.

int **p;

위는 int 를 가리키는 포인터를

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

쉽게 머리에 와닿지 않는가?

당연하다. 이전 강좌의 내용도

어려워 죽겠는데 위 내용까지

머리속에 외울려면 얼마나 힘들겠는가?

그래서, 한 번 예제를 봅시다.

/* 포인터의 포인터 */
#include <stdio.h>
    int main() {
        int a;
        int *pa;
        int **ppa;

        pa = &a;
        ppa = &pa;

        a = 3;

        printf("a : %d // *pa : %d // **ppa : %d \n", a, *pa, **ppa);
        printf("&a : %p // pa : %p // *ppa : %p \n", &a, pa, *ppa);
        printf("&pa : %p // ppa : %p \n", &pa, ppa);

        return 0;
}

성공적으로 컴파일 했다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
a : 3 // *pa : 3 // **ppa : 3
&a : 0061FF18 // pa : 0061FF18 // *ppa : 0061FF18 
&pa : 0061FF14 // ppa : 0061FF14
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

여러분의 결과는 약간 다를 수 있다.

다만, 같은 행에 있는 값들이

모두 같음을 주목하도록하자

일단 위에 보시다 싶이

같은 행에 있는 값들은

모두 같다. 사실, 위 예제는

그리 어려운 것이 아니다.

포인터에 제대로 이해만

했다면 말이다.

일단 ppa 는 int* 를 가리키는

포인터 이기 때문에

ppa = &pa;

와 같이 이전의 포인터에서

했던 것 처럼 똑같이 해주면 된다.

ppa 에는 pa 의 주소값이

들어가게 된다.

printf("&pa : %p // ppa : %p \n", &pa, ppa);

따라서 우리는 위의 문장이

같은 값을 출력함을 알 수 있다.

위의 실행한 결과를 보아도

둘다 1636564 를 출력했었다.

printf("&a : %p // pa : %p // *ppa : %p \n", &a, pa, *ppa);

그리고 이제 아래에서

두 번째 문장을 봐 보도록하자.

pa 가 a 를 가리키고 있으므로

pa 에는 a 의 주소값이 들어간다.

따라서, &a 와 pa 는 같은

값이 되겠지. 그러면 *ppa 는

어떨까? ppa 가 pa 를

가리키고 있으므로 *ppa 를

하면 pa 를 지칭하는 것이

된다. 따라서 역시 pa 의 값,

즉 &a 의 값이 출력되게 된다.

printf("a : %d // *pa : %d // **ppa : %d \n", a, *pa, **ppa);

마지막으로 위의 문장을 살펴 보도록하자.

pa 가 a 를 가리키고 있으므로 *pa

를 하면 a 를 지칭하는 것이 되어

a 의 값이 출력된다.

그렇다면 **ppa 는 어떨까?

이를 다시 써 보면 *(*ppa) 가 되는데,

*ppa 는 pa 를 지칭하는 것이기

때문에 *pa 가 되서,

결국 a 를 지칭하는 것이 된다.

따라서, 역시 a 의 값이 출력되겠지.

어떤가? 간단하지않는가?

위 관계를 그림으로 그리면 다음과 같다.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/6.png
 

배열 이름의 주소값?

지난 강좌에서 배열 이름에

sizeof 연산자와 주소값

연산자를 사용할 때 빼고는

전부다 포인터로 암묵적 변환이

이루어진다고 하였다.

그렇다면 주소값 연산자를

사용하면 어떻게 되길래

그러는 것일까?

한 번 코드로 살펴보도록하자.

#include <stdio.h>
int main() {
    int arr[3] = {1, 2, 3};
    int (*parr)[3] = &arr;

    printf("arr[1] : %d \n", arr[1]);
    printf("parr[1] : %d \n", (*parr)[1]);
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[1] : 2  
parr[1] : 2 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 잘 나온다.

int (*parr)[3] = &arr;

&arr 은 도대체 무슨 의미를 가질까?

이전에 arr 은 int * 로 암묵적

변환된다고 하였으니까

&arr 은 int ** 가 되는 것일까?

아니다!! 암묵적 변환은 주소값

연산자가 왔을 때에는

이루어지지 않는다.

arr 이 크기가 3 인 배열 이기 때문에,

&arr 을 보관할 포인터는 크기가

3 인 배열을 가리키는 포인터가

되어야 할 것. 그리고 C 언어 문법상

이를 정의하는 방식은 위와 같다.

참고로 parr 을 정의할 때

*parr 을 꼭 () 로 감싸야만

하는데, 만일 괄호를 빼버린다면

int *parr[3]

와 같이 되어서 C 컴파일러가

int * 원소 3 개를 가지는

배열을 정의한 것으로

오해하게 된다

(아래 포인터의 배열 에서)

(좀 더 자세히 다룹니다)

printf("parr[1] : %d \n", (*parr)[1]);

parr 은 크기가 3 인 배열을 가리키는

포인터 이기 때문에 배열을 직접

나타내기 위해서는 * 연산자를

통해 원래의 arr 을 참조해야 한다.

따라서 (*parr)[1] 과 arr[1] 은

같은 문장이 되겠다.

한 가지 재미있는 점은 parr 과

arr 은 같은 값을 가진다는 점이다.

#include <stdio.h>
int main() {
   int arr[3] = {1, 2, 3};
   int(*parr)[3] = &arr;

   printf("arr : %p \n", arr);
   printf("parr : %p \n", parr);
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[1] : 2  
parr[1] : 2 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 나온다.

arr 과 parr 모두 배열의 첫 번째

원소의 주소값을 출력한다.

물론 두 개의 타입은 다르지만.

이는 당연한데, arr 자체가

어떤 메모리 공간에 존재하는

것이 아니기 때문이다.

이와 같이 C 언어가 변태적으로

동작하는 이유는 사실

그 역사에 숨어있다.

C 언어는 B 언어에 서 파생된 언어인데,

B 언어에서는 실제 배열이 있고,

배열을 가리키는

포인터가 따로 있었다.

B 언어에서 arr 과

arr[0], arr[1] 은 각기

다른 메모리를 차지하는 녀석들이고,

arr 이 실제로 arr[0] 를 가리키는

포인터 였다. 따라서 arr 의 값을

출력하면 실제로 arr[0] 의 주소값이

나왔고, &arr 은 arr 의 주소값이

나왔겠다. 따라서 B 언어에서 arr

과 &arr 은 다른 값을 출력했을 것

하지만 C 언어를 만든

데니스 리치 대마법사님은

B 언의 문법을 계승하되,

이와 같이 비효율적으로 배열을

정의할 때 배열의 시작점을 가리키는

포인터로 공간을 낭비하고 싶지 았다.

따라서 위와 같이 조금 이상하지만

그래도 메모리 공간을 효율적으로

쓰게 되는 배열 – 포인터 관계가

탄생하게된 것.

2 차원 배열의 [] 연산자

2 차원 배열의 [] 연산자에

대해선 제가 지난번 강좌에서

생각 해보기 문제로 내었던

것 같은데, 생각해보셨는가?

일단 이전의 기억을 더듬에서

다음과 같은 배열이 컴퓨터

메모리 상에 어떻게 표현되는지

생각 해보도록 하자.

int a[2][3];

물론, 이 배열은 2 차원 배열이므로

평면위에 표시된다고 생각할

수 도 있지만, 컴퓨터 메모리 구조는

1 차원 적이기 때문에

1 차원으로 바꿔서

생각해보도록하자.

되었는가?

그렇다면,

그림을 보도록하자.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/7.png
 

실제로 프로그램을 짜서 실행해

보면 메모리 상에 위와 같이

나타남을 알 수 있다.

한 번 해 보도록하자

일단, 위 그림에서 왼쪽에

메모리 상의 배열의

모습이 표현된 것은

여러분이 쉽게 이해하실 수

있으리라 믿는다.

다만, 제가 설명해야

할 부분은 오른쪽에 큼지막하게

화살표로 가리키고 있는 부분이다.

먼저 아래의 예제를 보도록하자.

/* 정말로? */
#include <stdio.h>
int main() {
  int arr[2][3];

  printf("arr[0] : %p \n", arr[0]);   
  printf("&arr[0][0] : %p \n", &arr[0][0]);

  printf("arr[1] : %p \n", arr[1]);
  printf("&arr[1][0] : %p \n", &arr[1][0]);

  return 0;
}

성공적으로 컴파일 했다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr[0] : 0x7ffda354e530
&arr[0][0] : 0x7ffda354e530
arr[1] : 0x7ffda354e53c
&arr[1][0] : 0x7ffda354e53c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

표현된 주소값은

여러분과 다를 수 있다.

arr[0] 의 값이 arr[0][0] 의

주소값과 같고,

arr[1] 의 값이

arr[1][0] 의 주소값과 같다.

이것을 통해 알 수 있는 사실은

기존의 1 차원 배열과 마찬가지로

sizeof 나 주소값 연산자와

사용되지 않을 경우,

arr[0]은 arr[0][0]을

가리키는 포인터로 암묵적으로

타입 변환되고,

arr[1]은 arr[1][0]을

가리키는 포인터로 타입

변환된다라는 뜻이되겠지.

 주의 사항

 1  차원 배열 int arr[] 에서 arr 과 &arr[0] 는
    그 자체로는 완전히 다른 것이였던 것처럼

 2  차원 배열 int arr[][] 에서 arr[0]과
    &arr[0][0] 와 다릅니다. 다만 암묵적으로
    타입 변환 시에 같은 것으로 변할 뿐입니다.

 

따라서 sizeof 를 사용하였을

경우 2 차원 배열의 열의

개수를 계산할 수 있다.

int main() {
  int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
  printf("전체 크기 : %d \n", sizeof(arr));
  printf("총 열의 개수 : %d \n", sizeof(arr[0]) / sizeof(arr[0][0]));
  printf("총 행의 개수 : %d \n", sizeof(arr) / sizeof(arr[0]));
}

성공적으로 컴파일 하였다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
전체 크기 : 24
총 열의 개수 : 3
총 행의 개수 : 2
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

와 같이 나온다.

먼저 전체 배열에 sizeof 를

할 경우 당연하게도 배열의

전체 크기가 나오게 된다.

그렇다면

printf("총 열의 개수 : %d \n", sizeof(arr[0]) / sizeof(arr[0][0]));

위 문장에서 sizeof(arr[0]) 를

하면 무엇이 나올까?

바로 0 번째 행의 길이

(총 열의 개수)가 나오겠지.

앞에서도 강조해왔듯이

sizeof 연산자의 경우

포인터로 타입 변환을 시키지

않기 때문에 sizeof(arr[0]) 는

마치 sizeof 에 1 차원 배열을

전달한 것과 같다.

그리고 그 크기 (3) 을 알려주겠지.

그리고 sizeof(arr[0][0])을

하게 된다면 int 의 크기인

4 를 리턴하게 되어서

총 열의 개수를 알 수 있게된다.

printf("총 행의 개수 : %d \n", sizeof(arr) / sizeof(arr[0]));

그리고 총 행의 개수는 당연히도

전체 크기를 열의 크기로

나눈 것이 된다. 이 때,

arr[0][0] 의 형이 int이므로

arr[0] 은 int* 형이 되겠고,

마찬가지로 arr[1] 도 int*형이

되겠다. 자 그렇다면 한 가지

질문을 해보겠다.

만일 2 차원 배열의

이름을 포인터에 전달하기

위해서는 해당 포인터의 타입이

뭐가 될까? arr[0]는 int *가

보관할 수 있으니까,

arr은 int ** 이 보관할 수 있을까?

당연하지. 당신이 위에서 설명했었다.

int* 를 가리키는 포인터는 int** 이라고

그런데 답은 아니오다.

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

먼저 포인터의 형을 결정하는

두 가지 요소에 대해 이야기

하기 전에, 위에서 배열의

이름이 왜 int** 형이 될 수

없는지에 대해 먼저

이야기 해보도록하자.

만일 int** 형이 될 수 있다면

맨 위에서 했던 것 처럼

int** 포인터가 배열의 이름을

가리킨다면 배열의 원소에

자유롭게 접근할 수 있어야만 할것이다.

/* 과연 될까? */
#include <stdio.h>
int main() {
  int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
  int **parr;

  parr = arr;

  printf("arr[1][1] : %d \n", arr[1][1]);
  printf("parr[1][1] : %d \n", parr[1][1]);

  return 0;
}

그런데 컴파일 시에 아래와

같은 경고가 출력한다

 컴파일 오류

 warning C4047: ‘=’ : ‘int **’의 간접 참조 수준이 ‘int (*)[3]’과(와)

    ,→ 다릅니다.

 
 
 
아무튼, 무시하고 실행해 보도록 하자.
 
 
 
https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/8.png
 
 

예전에 보았던 친근한 오류가 뜹니다.

무슨 뜻일까요? 예전에 배열에 대해

공부하였을 때 초기화 되지 않은 값 에

대해서 이야기한 적이 있었을 것이다.

이 때, int arr[3]; 이라했는데

arr[10] = 2; 와 같이 허가되지

않은 공간에 접근하기만 해도 위와

같은 오류가 발생한다고 했다.

초기화 되지 않은 값 에 대해서

이야기한 적이 있었을 것이다.

이 때, int arr[3]; 이라했는데

arr[10] = 2; 와 같이 허가되지

않은 공간에 접근하기만 해도 위와

같은 오류가 발생한다고 했다.

먼저, 일차원 배열에서 배열의 형과,

시작 주소값을 안다고 칠 때,

n 번째 원소의 시작 주소값을

알아내는 공식을 생각해보도록하자.

만일 이 배열의 형을 int 로 가정하고,

시작 주소를 x 라고 할 때,

(참고적으로 다 아시겠지만)

(int 는 4 바이트 n 번째)

(원소에 접근한다면)

((x + 4 * (n – 1) 로)

(나타낼 수 있죠)

와 같이 나타낼 수 있다.

이번에는 이차원

배열을 나타내보도록하자.

이 이차원 배열이 int arr[a][b];

라고 선언되었다면

(여기서 a 와 b 는 당연히 정수다)

아래와 같이 2차원 평면에

놓여 있다고 생각할 수 있다.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/9.png

참고적으로 행은 ’가로’ 이고,

열은 ’세로’다.

메모리는 선형(1차원)이므로

절대로 위와 같이 배열될 일은

없겠다. 위 이차원 배열을

메모리에 나타내기 위해서는

각 행 부터 읽어주면 된다.

즉, 위 배열은 아래와

같이 메모리에 배열된다.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/11%EC%A0%9C%EB%AA%A9_%EC%97%86________________________%EC%9D%8C.png
 

사실 위에서도 비슷한 그림이

나오지만 또 그린 이유는 머리에

완전히 박아 두라는 의미다.

즉, arr[0][0] 부터 arr[0][1]

…. arr[a-1][b-1] 순으로

저장되게 된다.

그렇다면 위 배열의 시작주소를

x 라 하고, int 형 배열이고,

arr[c][d] 라는 원소에

접근한다고 가정 해보도록하자.

그렇다면 이 원소의 주소값은

어떻게 계산될까?

일단, 위 원소는 (c+1) 번째 행의

(d+1) 번째 열에 위치해 있다고

생각할 수 있다.

(예를 들어서 arr[0][2] 는)

(1 번째 행의 3 번째 열에)

(위치해 있다)

그러면, 먼저 (c+1) 번째 행의

시작 주소를 계산해보도록하자.

간단히 생각해보아도

x + c * b * 4 라는

사실을 알 수 있다.

왜냐하면 4 를 곱해준

것은 int 이기 때문이고,

(c+1) 행 앞에 c 개의

행들이 있는데, 각 행들의

길이가 b 이기 때문.

그리고 이 원소가 (d+1)번째에

있다는 사실을 적용하면

((c+1) 행 시작주소) + d * 4라고

계산될 수 있다.

결과적으로

(x + 4 * b * *c + 4 * d가 된다)

가 된다. 참고적으로

이야기 하자면,

수학에서 곱하기 기호가

매우 자주 등장하므로 생각하는

경향이 있는데, 저도 매번 곱하기

기호를 쓰기 불편하므로 생략하도록

하겠다. 위 식은 아래의 식과

동일하다.

(x + 4bc + 4d 로 간추린다)

주목할 점은 식에 b 가

들어간다는 것

(1 차원 배열에서는 배열의)

(크기에 관한 정보가 없어도)

(배열의 원소에 접근할 수 있었는데)

(말이 1 차원 배열에서는)

(배열의 크기에 관한 정보가)

(없어도 배열의 원소에 접근)

(할 수 있었는데 말이죠)

다시 말해, 처음 배열 arr[a][b]를

정의했을 때의 b 가 원소의

주소값을 계산하기 위해 필요하다

는 것입니다. 우리는 이전의 예제에서

int** 로 배열의 이름을 나타낼 수 있다고

생각하였다.

하지만 이렇게 선언된 parr 으로

컴퓨터가 parr[1][1] 원소를 참조하려고

하면 컴퓨터는 b 값을 알 수 없기

때문에 제대로된 연산을 수행할 수

없게됩니다. 따라서, 이차원 배열을

가리키는 포인터는 반드시 b 값에

대한 정보를 포함하고 있어야 한다.

결론적으로 포인터 형을 결정하는

것은 다음 두 가지로 요약할 수 있다.

                               1. 가리키는 것에 대한 정보

                                 (예를 들어, int* 이면 int 를 가리킨다,

                                 char** 이면 char* 을 가리킨다 등등)

                               2. 1 증가시 커지는 크기

                                 (2 차원 배열에서는 b * (형의 크기) 를

                                 의미한다 1 증가시 커지는 크기

                                 (2 차원 배열에서는

                                  b * (형의 크기) 를 의미한다)

 
 

여기서 1 증가시 커지는 크기가

2 차원 배열에서는

b * (형의 크기) 를

의미하는지 궁금한 사람들이

있을 것이다. 한 번 해보도록하자.

/* 1 증가하면 ? */
#include <stdio.h>
int main() {
    int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};

    printf("arr : %p , arr + 1 : %p \n", arr, arr + 1);

    return 0;
}

성공적으로 컴파일 한다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
arr : 0061FF08 , arr + 1 : 0061FF14 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

16 진수의 연산과 친숙하지 않더라도

0x7fff6fc142bc – 0x7fff6fc142b0

(참고로 예전에도 이야기 했듯이)

(기록에서 16 진수로)

(나타내었다는 사실을)

(명시하기 위해 앞에 0x 를)

(붙인다고 했다.)

를 계산해 보면 0xC 가 나온다.

0xC 는 십진수로 12 다.

근데, 위 배열의 b 값은 3 이고

int 의 크기는 4 바이트 이므로,

3 * 4 = 12 가 딱 맞게 되는 것이다.

왜 그럴까? 사실, 그 이유는 단순하다.

거의 맨 위의 그림을 보면 이차원

배열에서 a 가 a[0] 을 가리키고 있는

그림을 볼 수 있다.

만일 1 차원 배열 b[3] 이

있을 때 b + 1 을 하면

b[1] 을 가리키잖는가?

2 차원 배열도

동일하게 a 가

1 증가하면 a[1] 을

가리키게 된다.

다시 말해 두 번째 행의 시작

주소값을 가리키는 포인터를

가리키게 된다는 것이다.

/* 드디어! 배우는 배열의 포인터 */
#include <stdio.h>
int main() {
   int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
   int(*parr)[3]; // 괄호를 꼭 붙이세요

   parr = arr; // parr 이 arr 을 가리키게 한다.

   printf("parr[1][2] : %d , arr[1][2] : %d \n", parr[1][2], arr[1][2]);

   return 0;
}

성공적으로 컴파일 한다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
parr[1][2] : 6 , arr[1][2] : 6 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

드디어, 2 차원 배열을 가리키는

포인터에 대해 이야기 하겠다.

2 차원 배열을 가리키는 포인터는

배열의 크기에 관한 정보가 있어야

한다고 했다. 2 차원 배열을

가리키는 포인터는

아래와 같이 써주면 된다.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/12%EB%B9%8C%EB%A6%AC%ED%97%A4%EB%A7%81%ED%84%B4_1.png

이렇게 포인터를 정의하였을 때

앞서 이야기한 포인터의 조건을

잘 만족하는지 보도록 하자.

일단, (배열의 형) 을 통해서

원소의 크기에 대한 정보를

알 수 있다. 즉,

가리키는 것에 대한 정보를

알 수 있게 된다. (조건 1 만족).

또한,

[2 차원 배열의 열 개수]를

통해서 1 증가시 커지는 크기도

알게 됩니다.

바로 배열의 형 크기

– 예를 들어 int 는 4, char 은 1 * (2 차원 배열의 열 개수) 만큼 커지게 된다.

int (*parr)[3];

위와 같이 정의한 포인터 parr 을

해석해 보면, int 형 이차원 배열을

가리키는데, 그 배열의 열의 개수가

3 개 이군요! 라는 사실을 알 수 있다

(정확히 말하면, int* 를)

(가리키는데, 1 증가시)

(3 이 커진다 라는 의미입니다)

어디서 위와 같은 형태의 포인터

정의를 보지 않으셨나요?

맞습니다. 저 parr 은 사실 크기가

3 인 배열을 가리키는

포인터 를 의미합니다.

그런데 이게 말이 되는게,

1 차원 배열에서 배열의 이름이

첫 번째 원소를 가리키는 포인터로

타입 변환이 된 것 처럼,

2 차원 배열에서 배열의

이름이 첫 번째 행 을 가리키는

포인터로 타입 변환이 되어야 한다.

그리고 그 첫 번째 행은 사실 크기가

3 인 1 차원 배열이다

뭔가 아다리가 맞는게 보이는가?

/* 배열 포인터 */
#include <stdio.h>
int main() {
   int arr[2][3];
   int brr[10][3];
   int crr[2][5];

   int(*parr)[3];

   parr = arr; // O.K
   parr = brr; // O.K
   parr = crr; // 오류!!!!

   return 0;
}

위 코드에서 parr 이 arr 과 brr 은

받을 수 있어도 crr 은

왜 못받는지 아실 수 있겠는가?

포인터 배열

포인터 배열, 말그대로 포인터들의

배열 입니다. 위에서 설명한

배열 포인터는 배열을 가리키는

포인터 였죠. 두 용어가 상당히

헷갈리는데, 그냥 언제나 진짜는

뒷부분 이라고 생각하시면 됩니다.

즉, 포인터 배열은 정말로 배열이고,

배열 포인터는 정말로 포인터 였죠.

/* 포인터배열*/
#include <stdio.h>
int main() {
  int *arr[3];
  int a = 1, b = 2, c = 3;
  arr[0] = &a;
  arr[1] = &b;
  arr[2] = &c;

  printf("a : %d, *arr[0] : %d \n", a, *arr[0]);
  printf("b : %d, *arr[1] : %d \n", b, *arr[1]);
  printf("b : %d, *arr[2] : %d \n", c, *arr[2]);

  printf("&a : %p, arr[0] : %p \n", &a, arr[0]);
  return 0;
}

성공적으로 컴파일 한다면

PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> gcc -o test test.c
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\> ./test
a : 1, *arr[0] : 1
b : 2, *arr[1] : 2
b : 3, *arr[2] : 3
&a : 0061FF10, arr[0] : 0061FF10 
PS D:\■■■■■_fFF■\■■■g■■■m■■■\F\>

마지막 출력결과는 여러분과

상이할 수 있으나 두 값이

같음을 주목 해 보도 록 하자.

일단, arr 배열의

정의 부분을 봐보도록하자.

int *arr[3];

위 정의가 마음에 와닿아는가?

사실, 저는 처음에 배울 때

별로 와닿지 않았다.

사실, 이전에도 말했듯이

위 정의는 아래의 정의와 동일하다.

int* arr[3];

이제, 이해가 되었는가?

우리가 배열의 형을 int, char

등등으로 하듯이, 배열의

형을 역시 int* 으로도 할 수 있다.

다시말해, 배열의 각각의 원소는

int 를 가리키는 포인터 형으로

선언된 것입니다. 따라서,

int 배열에서 각각의 원소를

int 형 변수로 취급했던 것처럼

int* 배열에서 각각의 원소를

포인터로 취급할 수 있다.

마치, 아래처럼.

arr[0] = &a;
arr[1] = &b;
arr[2] = &c;

각각의 원소는 각각 int 형

변수 a,b,c 를 가리키게 된다.

이를 그림으로 표현하면 아래와 같다.

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/13%EA%B0%88%EC%95%84%EB%A7%8C%EB%93%A0_%EC%8B%AC%EC%98%81.png

arr[0] 에는 변수 a 의 주소가,

arr[1] 에는 변수 b 의 주소,

arr[2] 에는 변수 c 의 주소가

각각 들어가게 된다.

이는 마지막 printf 문장에서도

출력된 결과로 확인 할 수 있다.

사실, 포인터 배열에 관한

내용은 짧게 끝냈습니다.

하지만, C 언어에서 상당히

중요하게 다루어지는 개념이다.

아직 여러분이 그 부분에 대해

이야기할 단계가 되지 않았다고

보아, 기본적인 개념만 알려

드린 것이다.

꼭 잊지 마시길 바란다.

자. 이제 배열을 향한

대장정이 끝이 났다.

여기까지 부담없이

이해하셨다면

여러분은 C 언어의 성지를

넘게 된 것!

사실, 여러분은 이 포인터를 무려

Chapter3 기록들을 연달아 보면서

’도대체 이걸 왜하냐?’ 라는 생각이

머리속에 끝없이 멤돌았을 것이다.

물론, 앞에서도 이야기 했지만 포인터는

다음 단계에서 배울 내용에 필수적인

존재입니다. 사실, 지금은 아무짝에도

쓸모 없는 것 같지만…

여기까지 스크롤을 내리면서도

마음 한 구석에 응어리가 있는

분들은 과감하게 포인터 기록를

처음 부터 읽어 보세요.

 

https://raw.githubusercontent.com/KuraiLuna/KuraiLuna.github.io/master/practical/C/%EC%94%B9%EC%96%B4%EB%A8%B9%EB%8A%94%20C%20%EC%96%B8%EC%96%B4/Chapter-10/%EC%98%A4%EB%8A%98%EB%8F%84_%EC%A2%8B%EC%9D%80_%ED%95%98%EB%A3%A8.png