열혈C - Chapter 13 포인터와 배열! 함께 이해하기

2024. 10. 7. 23:22Programming Language/C

13-1 포인터와 배열의 관계

 먼저 배열의 형태를 보자.

int arr[3];

이렇게 배열 arr을 선언 했다.

여기서 배열의 이름인 arr은 배열의 첫번째 요소의 주소값을 갖고 있게 된다

arr이 arr[0]을 가리키는것이고 arr은 arr[0]의 주소값을 갖고 있는 상수가 된다.

이 형태를 보면 

int num;
int* ptr;
ptr = #

의 코드와 비슷한 기능을 한다는 것을 알 수 있다.

 

여기서 포인터와 배열의 다른점은 

포인터 변수의 경우는 

int num1;
int num2; 
int* ptr;

ptr = &num1;
ptr = &num2;

와 같이 처음 넣었던 주소값을 다른 값의 주소값으로 변경이 가능하다는 점이다.

그러나 배열의 경우는 arr안에 있는 arr[0]의 주소값을 변경할 수 없다.

 

포인터 변수는 변수이고 arr은 상수라는 것이 차이점이다.

그래서 결국 포인터 변수와 배열의 이름은 이 차이말고는 아무런 차이점이 없다 

 

그렇기 때문에 

*ptr

처럼 배열의 이름에도 

*arr

도 사용할 수 있다.

 

이는 ptr이 int* 과 같이 주소값이 가리키는 값에 대한 타입에 대한 정보가 담겨있던것처럼 arr 또한 그 정보가 담겨 있다는 것을 의미한다.

 

그럼 arr이 int형 포인터인가? 그렇다...!!

 

그렇기에

*arr

은 arr[0]의 값에 접근하게 된다.

 

int arr[3] = {0, 0, 0};
*arr = 20;

을 한다면 arr[0] = 20을 한것과 같은 기능을 하게 된다.

 

그렇기에 기존에 배열을 가지고 했던 arr[0], arr[1], arr[2]와 같이 값에 접근했던 것처럼 ptr도 동일하게 접근이 가능하다.

 

정리해보자면

  1. 배열의 이름은 포인터 상수이다.
  2. 포인터 변수와 포인터 상수의 차이점은 값을 변경할 수 있는가, 없는가의 차이점 뿐이다.
  3. 그렇기에 포인터에서 사용했던 *연산자를 배열의 이름이 사용할 수 있고, 배열의 이름이 사용했던 [n]또한 포인터 변수가 사용할 수 있다.(상수 + 상수의 기능이 덧셈인데 변수를 사용한다고 해서 기능을 사용하지 못하는것은 아닌것과 비슷한 것이라고 생각하면 된다, 3 + 4, num1 + num2)

이를 조금더 정리해보자.

배열의 이름은 무엇을 의미하는가?

배열의 이름은 배열의 시작 주소값을 의미하는(배열의 첫번째 요소를 가리키는) 포인터이다.

단순히 주소 값이 아닌 포인터인 이유는 메모리 접근에 사용되는 * 연산이 가능하기 때문이다.

의 결과를 볼수 있다.

보면 arr과 arr[0]의 주소값인 &arr[0]과 동일하다는 것을 볼 수 있다.

배열 요소간 주소값의 크기는 4바이트임을 알 수 있다.(모든 요소가 붙어있다는 의미) 

 

1차원 배열 이름의 포인터 형

1차원 배열이름의 포인터 형 결정 방법

  • 배열의 이름이 가리키는 변수의 자료형을 근거로 판단
  • int형 변수를 가리키면 int * 형                  => int arr1[5]; 에서 arr1은 int* 형
  • double형 변수를 가리키면 double* 형     => double arr2[7]; 에서 arr2는 double* 형

arr1이 int형 포인터이므로 * 연산의 결과로 4바이트 메모리 공간에 정수를 저장하고 arr2는 double형 포인터이므로 * 연산의 결과로 8바이트 메모리 공간에 실수를 저장한다.

 

포인터를 배열의 이름처럼 사용할 수 도 있다

int arr[3] = {1, 2, 3};
arr[0] += 5;
arr[1] += 7;
arr[2] += 9;
. . . .

arr은 int형 포인터이니 int형 포인터를 대상으로 배열접근을 위한 [index]연산을 진행한 것과 같다.

 

실제로 포인터 변수 ptr을 대상으로 ptr[0], ptr[1], ptr[2]와 같은 방식도 메모리 공간에 접근이 가능하다.

포인터 변수를 이용해서 배열의 형태로 메모리 접근이 가능하다는 것을 확인할 수 있다.

 

13-2 포인터 연산 

포인터 연산은 포인터를 대상으로 하는 연산이다.

포인터 연산은 포인터 변수와 배열의 이름도 포함된다.

포인터 연산에 해당하는 연산은 덧셈, 뺄셈, 증가, 감소연산이 가능하다.

 

포인터 연산은 포인터 변수의 타입에 따라서 연산의 결과가 달라진다.

증가를 예를 들어 말해보면 

int num = 10;
int* ptr = #
ptr = ptr + 1;

과 같은 연산을 할때 

 

 int * ptr에 저장된 주소값이 0x0002일때 ptr + 1 의 결과는 1 증가하는게아니라  4가 증가한 0x0006이 된다.

하나 더 예를 들어보자면 int * ptr이 아닌 double* ptr의 경우는 ptr + 1을 한다면 8이 증가한다.

 

결국 타입에 따라 증가하는 값이 다르고 그 증가하는 값은 sizeof(포인터가 가리키는 값의 타입) 만큼의 값이 된다는 의미이다.( 포인터가 가리키는 값의 타입의 크기만큼 늘어난다는 의미.)

 

왜에 대해서는 의문을 갖지 말고 사용방법에 대해서 고민해보자.

 

int arr[3];
int * ptr = arr;

과 같은 코드를 만들었다고 생각해보자 

그러면 ptr에 arr을 넣어 arr[0]의 주소값을 넣어줬기에 ptr또한 ar[0]를 가리키고 있게 될 것이다.

만약 이때 

*ptr = 10;

을 하면 arr[0] = 10; 과 같은 기능을 수행하면서 arr의 첫번째 요소에 10이라는 값을 넣어주게 될것이다.

이떄 ptr에 + 1을 해준다면

ptr++;

ptr에 들어 있는 주소값에 sizeof(int)의 크기만큼을 더하게 될 것이고 ptr이 가리키던 배열의 요소는 4씩 공간을 차지하고 있기에 기존 arr[0]의 주소값에서 4를 더하는 주소값이 나타내는 것은 arr[1]이 될것이다.

 

결국 ptr++를 한 결과는 ptr이 arr[0]를 바라보는게 아니라 arr[1]을 바라보게 끔 변경한것과 동일하다.

 

이 상태에서 

*ptr = 20;

을 하면 arr[1] = 20; 과 동일한 기능을 수행한다.

그리고 또 ptr의 값을 1증가시킨 후 30을 저장한다면 

ptr++;
*ptr = 30;

과 같은 코드를 작성해줄 수 있다.

 

여기서 덧셈과 다르게 ptr의 주소값에서 2를 뺀 후에 가리키는 값을 +30한다면

ptr -= 2;
*ptr += 30;

이 결과는 ptr에서 sizeof(int) * 2를 한 값인 8을 빼게 될 것이고, 그 결과 arr의 3번째 요소를 가리키던 ptr이 arr의 첫번째요소인 arr[0]를 가리키게 될 것이고, 그 값을 30더한 것이기에 arr[0] = 40;이 될 것이다.

 

이제 이 내용을 코드와 함께 아래에서 정리해보자.(복습한다고 생각)

포인터를 대상으로 하는 증가 및 감소 연산


포인터 변수에 저장된 값을 대상으로 하는 증가 및 감소연산을 진행할 수 있다.(곱셈, 나눗셈 등등은 불가..)

그리고 이것도 포인터 연산의 일종이다.

 

부분의 결과를 보면 주소값이 sizeof(int), sizeof(double)만큼 크기가 커진 것을 알 수 있다.

그리고 그 값이 

실제 ptr1, ptr2의 값을 변경시키는 것이 아니라는 것을 알 수 있다.

 

물론

의 경우는 ptr1 = ptr1 + 1, ptr2 = ptr2 + 1  과 같이 덧셈의 결과를 다시 넣어주기 때문에 그값이 증가 된것을 볼 수 있다.

 

이 결과를 통해서 

  • int형 포인터 변수 대상의 증감 연산시 sizeof(int)의 크기만큼 값이 증감한다.
  • double형 포인터 변수 대상의 증감 연산시 sizeof(double)의 크기만큼 값이 증감한다

라는 것을 알 수 있고 이를 보았을때

  • type형 포인터 변수 대상의 증감 연산을 하면 sizeof(type)의 크기만큼 증감한다

라는 결과를 알 수 있다.

 

포인터를 대상으로 하는 증가 및 감소 연산

 

여기서 봐야할 점은 

ptr이 arr[0]을 가리키기에 *ptr을 한다면 arr[0]의 값을 출력해야하는데 ptr + 1 한다면 arr[1]을 가리키기에 *(ptr + 1)을 한다면 arr[1]의 값을 출력해줘야 한다는 것이다.

동일하게 *(ptr + 2) 또한 동일하다.

이때 ptr이 arr[0]를 가리키고 있을때 ptr++를 한것도 동일한 기능을 한다.

ptr이 arr[0]을 가리킬때 ptr++를 한다면 ptr = ptr + 1을 한것과 동일한거고 그러면 ptr이 ptr + 1인 arr[1]의 주소값이 되면서 ptr은 arr[1]을 가리키게 되는 것이다.

그러면서 *ptr를 한다면 자동으로 ptr이 arr[0]를 바라볼때의 *(ptr + 1)과 동일한 결과를 출력한다는 것을 알 수 있다

이에 또 ptr++한다면 *ptr은 ptr이 arr[0]를 바라볼때의  *(ptr + 2)와 동일한 결과를 출력한다는걸 예상할 수 있다.

결국 위 코드의 결과는 

와 같은 결과를 출력함을 알 수 있다.

 

중요한 결론 - arrr[i] == *(arr + i)

배열의 이름도 포인터이니 포인터 변수를 이용한 배열의 접근 방식을 배열의 이름에도 사용할 수 있다.

그리고 배열의 이름을 이용한 접근 방식도 포인터 변수를 대상으로 할 수 있기에 결론적으로 arr이 포인터 변수의 이름이건 배열의 이름이건 arr[i] == *(arr + i) 라는 결론을 낼 수 있다.

 

그래서 최종적으로 

와 같이 코드를 출력해보면 

이렇게 예상한대로의 결과를 얻을 수 있다.

 

13-3 상수 형태의 문자열을 가리키는 포인터

이번에는 문자열을 포인터변수와 배열을 이용해서 선언하는 방법을 알아보자.

 

먼저 배열을 이용해서 문자열 선언방법을 보자.

배열에 문자열을 저장하는 방법은

char str[20] = "abcd";

와 같이 사용하면 된다.

 

그렇다면 str에 문자열을 변경하기 위해서 

str = "efgh";

와 같이 사용을 할수는 없다.

 

나중에 문자열의 값을 변경하는 함수를 배울 것이니 이런 방법은 사용하면 안된다는 것을 알고 있자.

 

이게 불가능한 이유는 배열의 이름은 포인터이기 때문에 그 값을 바꿀 수 없는 상수형태이기 때문이다.

str은 배열의 이름으로써 배열의 첫 요소를 가리키는 포인터 상수로써 위 코드는 str이란 배열에 "efgh"을 저장해라라는 의미로 해석하는게 아니라 str이라는 포인터 상수에 "efgh"를 저장해라 라는 의미로 해석 된다는 것이다.

그리고 str은 이미 상수이기 때문에 대입연산을 할 수 없다라는 것이 주된 이유이다

 

그래서 가장 일반적인 문자열의 사용 방법은 문자열을 넣기 위한 char 배열을 선언과 동시에 문자열을 초기화해주는 방법이다.

그러면 이렇게 선언된 문자열은 변수의 성향을 지닌 문자열이라고 한다.

그 이유는 

str[0] = "f";

와 같이 문자열의 일부를 변경할 수 있기 때문이다.

 

동일하게 포인터를 사용해서도 문자열을 선언할 수 있다.

포인터의 경우는 문자열을 넣어주기 위해서는 

char * str = "abcd";

와 같이 사용하면 된다.

 

그런데 이전에 int형 포인터에 값을 넣을때 포인터에 이렇게 바로 값을 넣게 되면 그 값 그대로를 넣는게 아니라 그 값을 주소값으로 인식했었는데 문자열의 경우는 조금 다르다.

문자열을 저렇게 넣는다면 대입연산이 진행되기 이전에 문자열을 메모리공간에 자동으로 할당 시키고 그 문자열의 가장 첫번째 요소의 주소값을 반환한다.

그렇기 때문에 문자 하나의 주소값을 저장하는 것이기에 char형 포인터 변수에 저장하는 것이 타당함을 알 수 있다.

(결국 그 포인터 변수를 사용해서 조작하는 것은 char형 데이터 하나이기 때문)

이게 배열에 문자열을 넣는것과 char형 포인터 변수에 문자열을 넣어주는 것의 차이점이다

 

정리하자면 배열의 경우는 메모리공간을 마련한 후에 문자열을 넣어주고 char형 포인터 변수의 경우는 문자열이 자동으로 메모리공간에 할당된 이후에 주소값을 반환하여 포인터 변수에 담기는 것이다.

 

여기서 결정해야할 것은 내가 문자열을 변수의 형태로 선언하겠다(문자열의 일부를 변경하거나 가공할 생각이 있다)라면 반드시 char str[]과 같이 배열의 형태로 선언해줘야 하고 문자열을 가공이 아니라 상수의 형태로 그냥 단순히 참조만 하겠다고 한다면 char * str 과 같이 포인터 변수 형태나 char str[]의 배열이나 상관없이 사용해도 된다.

 

물론 상황에 따라 다르겠지만 우선 일차적으로 이런 사용구분을 갖고 사용하는게 좀더 편할 수 있을 것이다.

나중에 좀 더 이해가 깊어지면 구분할 자신만의 방법이 생길것이다.

 

이제 정리된 내용을 가지고 다시한번 복습해보자.

 

두 가지 형태의 문자열 표현

char str1[] = "My String";
char * str2 = "Your String";

이란 코드를 작성했을때 이 코드에서 문자열의 저장 방식은 

와 같다.

 

str1은 문자열이 저장된 배열, 즉 문자 배열이다. 따라서 변수의 성격을 지닌 문자열이다.

반면 str2는 문자열의 주소값을 저장한다. 즉 자동 할당된 문자열의 주소값을 저장하기에 상수 성향의 문자열이다.

 

이를 이해하기 위해서 예시를 하나 보자.

이전에 우리가 자동으로 할당되는 것이

int num = 3 + 5;

와 같은 코드를 볼 수 있다.

여기서 3과 5는 자동으로 메모리공간 어디엔가 할당 되고 이걸 가지고 덧셈 연산을 한 후에 그 값을 반환했었다.

이때 3과 5에 접근해서 변경할 수 없기에 상수라고 말한다.

이와 동일하게 

char * str2 = "Your String";

또한 메모리공간 어디엔가 자동으로 할당되기에 상수형태를 지닌 문자열임을 알 수 있고 이 값을 문자열을 저장한 배열같이

char str1[] = "My String";
str1[0]= "N";

이렇게 문자열의 일부를 변경할 수 없다는 것이다.

 

그런데 

char * str = "Your Team";

과 같이 선언된 char형 포인터 변수에 

str = "Our Team";

와 같이 코드를 작성하면 이건 허용을 해준다.

 

이는  "Our Team" 또한 어차피 메모리공간 어딘가에 자동으로 할당이 된 상태이고 이중 "O"의 주소값을 반환해서 str에 저장하는 것이기 때문에 str이 가리키는 문자열을 변경하는게 아니라 str에 저장된 주소값을 갈음한것이기에 문제 없는 코드로써 의미가 있는 코드이다.

그런데 그렇다고 해서 이런형태로 코드를 작성하는것이 좋은 방법이라는 것은 아니다.

 

그렇다면 배열의 경우는 어떨까?

char str[] = "Your Team";
str = "Our Team";

배열의 경우는 str = "Our Team"이라는 문장을 허용하지 않는다.

이것을 str이라는 배열에 Our Team을 저장해라 라고 해석할 수 있는데 C언어가 이렇게 해석해주지 않기 때문에 이런 대입을 허용하지 않는다.

C언어는 str이라는 배열의 이름이고 이건 상수의 성격을 띈다고 이야기 했었고 그 str이라는 배열의 이름이자, 포인터 상수에는 값을 변경시키는 것이 불가능하다고 했었다.

또한 이 str이라는 배열의 이름을 사용해서 덩어리로 값을 넣어주는 코드를 허용하지 않는다.

 

두 가지 형태의 문자열 포현의 예

 

변수 성향의 str1에 저장된 문자열은 변경이 가능하다

반면 상수의 성향을 지닌 str2에 저장된 문자열은 변경이 불가능하다.

이걸 출력하면 

와 같이 세번째 printf문을 에러로 인해 출력하지 않는다.

str2[0] = 'X'; 를 주석해주면 

처럼 결과를 볼 수 있다.

 

간혹 상수 성향의 문자열 변경도 허용하는 컴파일러가 있으나, 이런 형태의 변경은 바람직하지 못한것이다.

(간혹 상수 형태의 문자열을 변수로 인식하는 경우도 있다.)

 

어디서든 선언할 수 있는 상수 형태의 문자열

위에서 말했던것 처럼 우리가 문자열을 저장할 공간을 직접 마련하지 않는다면 문자열은 자동으로 할당된다고 했었다.

이 개념을 생각하면서 아래 내용을 이해해보자.

 

char * str = "Const String";

이 코드는 "Const String"이 자동으로 할당 된 이후에 그 주소값을 반환한다

char * str = 0x1234;

 

문자열이 먼저 할당된 이후에 반환되는 주소값이 저장되는 방식이다.

 

printf("Show your string");

또한 동일하게 "Show your string"이 어디에 담겨서 넣어지는게 아니라 문자 그 자체를 전달한다면 문자열이 자동으로 할당된 다음에 Show your string의 가장 첫번째 요소인 S의 주소값을 반환하게 된다.

printf(0x1234);

와 같이 주소값을 반환한 후에 printf()가 실행되는 것이다.

문자열은 선언된 위치로 주소값이 반환된다.

 

WhoAreYou("Hong");

과 같이 함수에 전달하는 문자열만 봐도 함수의 매개변수의 형(type)을 짐작할 수 도 있다.

Hong이란 문자열을 어디에 담아서 전달하는게 아니라 그 문자 그자체를 전달한다면 그 문자열은 자동으로 메모리공간에 할당되고 맨 첫번째 요소인 H의 주소값을 WhoAreYou에게 전달하는 것일 거고 그렇기에 이걸 받아주는 매개변수의 형은 이걸 사용할 수 있는 char * str 임을 짐작할 수 있다.

void WhoAreYou(char * str){. . . .}

 

정리해보자면

 

문자열을 저장한 배열

  • 배열에 문자열을 넣는것은 배열에 공간을 먼저 만들고 문자열을 그 공간에 할당해주는 것이다. - ex) char str[] ="Test";
  • 변수의 성격을 가진다.
  • 문자열의 일부 요소를 변경이 가능하다. - ex) str[0] = 'S';
  • 배열의 이름을 이용해서 문자열을 변경하는것은 불가능하다.  - ex) str = "String"; (불가능)

문자열을 저장한 포인터 변수

  • 문자열이 메모리 공간에 자동으로 할당 되고 그 문자열의 첫번째 요소의 주소값을 포인터 변수에 저장하는 것이다. - ex) char * str = "Test";
  • 상수의 성격을 가진다.
  • 문자열의 일부 요소의 변경이 불가능하다.(저장된 문자열은 상수이기 때문) - ex) str[1] = 'S'; (불가능)
  • 포인터가 다른 문자열을 가리키게 문자열 전체를 변경하는 것은 가능하다. - ex) str = "String" (가능)

또한 직접 공간을 할당해서 저장한 문자열이 아니라면 모든 문자열은 자동으로 메모리 공간에 할당 되며 그 문자열 자리에 문자열의 첫번째 요소의 주소값을 반환한다.

ex) char * str = "Test";   =>    char * str = 0x1234;

      printf("Hello!");        =>    printf(0x1234);

      Hello("Hi");              =>    Hello(0x1234);

 

13-4 포인터 변수로 이뤄진 배열: 포인터 배열

이번 내용은 이전에 공부한 배열의 내용을 조금만 확장하면 쉽게 이해할 수 있을 것이다.

 

하나의 배열이 선언 되었을때 

int arr[3];

이 배열은 int형 변수로 이루어진 배열을 선언하겠다는 의미이고 이건 여러개의 동일한 자료형을 선언하기 위해 사용하는 것이 배열이다.

그렇기에 그 대상이 변수라면 얼마든지 배열을 선언할 수 있다.

또한 그렇기에 자료형이 int형 포인터로 이루어진 배열 또한 선언하는것도 문제 없이 가능하다.

int* arr[3];

이렇게 선언하면 int형 포인터 변수 3개로 이루어진 배열 arr이 된다.

 

포인터 배열의 이해

int * arr1[20];    // 길이가 20인 int형 포인터 배열 arr1
double * arr2[30]; // 길이가 30인 double형 포인터 배열 arr2

의 코드를 보면 

이렇게 num1, num2, num3를 선언하고 이 주소값을 배열로 넣어주고 있다.

주소값을 넣어주기에 포인터 배열을 선언해서 값을 넣어줘야함을 알 수 있다.

그리고 배열의 요소 하나 하나는 num1, num2, num3를 가리키고 있고 그 값을 꺼내기 위해서는 *를 사용해서 

와 같이 사용해야한다.

그래서 이 코드의 실행 결과는 

이고 메모리에서 

의 형태를 가지고 있게 될 것이다.

물론 num1과 num2와 num3는 나란히 나열된 형태로 위치하지 않을 수도 나란히 위치할 수 도 있다.

arr[0], arrr[1], arr[2]는 배열이기 때문에 나란히 나열된 형태로 위치할 것이다.

 

이게 중요한건 아니고 여기서 중요한건 포인터 배열이라고 해서 일반 배열의 선언과 차이가 나지 않는다는 것이다.

변수의 자료형을 표시하는 위치에 int나 double을 넣는 대신 int*나 double*가 올 뿐이다.

 

문자열을 저장하는 포인터 배열

 

 

char * strArr[3] = {"Simple", "String", "Array"};

 

이건 배열 처럼 문자열을 저장할 공간이 할당된게 아니다.

단순하게 생각만 해도 Simple\0, String\0, Array\0을 저장하려면 3으로는 턱도 없는 사이즈이다.

이건 그냥 문자열의 주소값 3개만 넣을 수 있는 공간을 할당한것이라고 봐야하기에 문자열을 저장하는 공간을 마련한게 아니다.

 

*사실 문자열을 저장하는 메모리 공간을 마련하는 경우는 char str[20] = "adbc";와 같이 하나의 문자열을 선언하면서 그 문자열을 저장하는 배열을 선언하는 경우를 제외하고는 어떤것도 문자열을 저장하는 메모리 공간을 마련해주지 않는다.

 

그렇기 때문에 문자열들은 자동으로 할당되어 그 첫요소의 주소값을 반환하게 된다.

char * strArr[3] = { 0x1234, 0x1354, 0x1534}; //반환 주소값은 임의로 결정했다.

그래서 위와 같은 형태가 될 것이고 이게 메모리에 저장되는 형태는 

와 같이 될것이다.