본문 바로가기

개발공부(C, C++)

따배씨(따라 배우는 C언어_홍정모) Chapter10

CHAPTER 10

"행복으로 가는 한 쪽 문이 닫히면 다른 쪽 문이 열린다. 그러나 우리는 때때로 닫힌 문만 쳐다보느라 열린 쪽은 오랫동안 못보기도 한다." _Helen Keller

10.1 배열과 메모리

배열의 시작 인덱스가 0인 이유는, 인덱스라는 게 이 배열을 위한 메모리의 시작점과의 거리를 뜻하기 때문이다. 시작점과 시작점 사이의 거리는 0이겠지.

10.3 포인터의 산술 연산 (Pointer Arithmetic)

포인터에 어떤 상수 숫자(literal)를 더한다는 건, 그 숫자 * 포인터의 자료형 크기 를 더하는 것과 같다. 배열과 포인터를 같이 쓸 때 많이 쓰이는 특성이다.

양/음수 단항연산자와 포인터끼리의 덧셈은 허용되지 않는다. 그런데 포인터끼리의 뺄셈은 허용된다! 포인터끼리 뺄셈을 하면, 포인터의 메모리 주소 간의 거리 / 해당 포인터의 자료형의 크기 가 나온다. 즉 해당 자료형의 메모리 크기를 고려했을 때 몇 칸 떨어졌는지 알 수 있다. 이 역시 배열과 함께 쓸 때 자주 이용되는 특성이다. 포인터의 차이를 정수로 출력하는 printf 의 형식 지정자는 %td 이다. ex) printf("%td\n", ptr3 - ptr1);

10.4 포인터와 배열

후에 배울 동적할당과 긴밀히 연결되어 있기 때문에 제대로 공부해놓을 필요가 있다.

기본적으로 배열은 그 자체가 배열의 첫번째 원소의 메모리 주소 위치를 가리키는 포인터다. 그래서 & 연산자로 변수의 주소를 가져올 필요없이 변수를 바로 포인터에 저장할 수 있다. 같은 맥락에서 이 배열이 포인터는 마치 그 자신이 배열이 된 것처럼 작동한다.

double arr2[] = {1.0,2.0,3.0};
double *ptr = arr2;
*ptr = 3.0; // ptr[0] = 3.0; , arr[0] = 3.0; 과 동일

10.5 2차원 배열과 메모리

2차원 배열을 읽는 방법
뒤에서부터 읽는다고 생각하면 된다.

arr[2][3] = { {1, 2, 3},
              {4, 5, 6} }; 
// '3 이 2개' 처럼 뒤에서부터 읽는다. 

2차원 배열도 메모리는 1차원이다.
0번째 row 의 0

2 -> 1번째 row 의 0

2
순서로 연속적으로 배정된다.

메모리 주소가 1차원이라는 점을 이용하면, 포인터를 이용해 변수 하나만으로 2차원 배열을 순회할 수 있다.

// 포인터를 쓰지 않은 경우
for (int r = 0; r < 2; ++r)
{
  for (int c = 0; c < 3; ++c)
  {
    printf("%d ", arr[r][c]);
  }
  printf("\n");
}
printf("\n");

// 포인터를 쓴 경우
int* ptr = arr[0][0]; // arr[0] 은 3개 원소 모두를 포함해버림에 주의
for (int k = 0; k < 6; ++k) // 변수 단 하나!
{
  printf("%d ", ptr[k]);
}
printf("\n");

10.7 배열을 함수에게 전달해주는 방법

기본적으로 c 와 c++ 에서는 함수에 인자로 배열을 넣어주면 함수 자체가 넘어가는 게 아니라 그 배열의 시작 메모리를 가리키는 포인터가 넘어간다. 즉, 함수 내에서 매개변수로 받은 그 배열의 크기를 출력해보면 배열의 크기가 나오는 것이 아니라 포인터의 크기가 출력된다. 따라서 함수 내에서 배열의 순회 같은 것을 하고 싶으면, 배열의 길이가 얼마인지 따로 매개변수로 받아와야 한다.

위와 같은 이유로, 매개변수를 정의할 때, 아래 두 개는 차이가 없다. 다만 가독성을 위해 배열임을 알려주기 위한 [] 을 많이 쓴다.

  1. double average(double arr[], int n_arr)
  2. double average(double* arr, int n_arr)

아래 바디가 따로 있고, forward declaration 으로 프로토타입만 따로 빼놓은 경우라면 극단적으로 축약해서 배열을 받아옴을 나타낼 수도 있다.

  1. double average(double [], int n_arr)
  2. double average(double *, int n_arr)

10.8 두 개의 포인터로 배열을 함수에게 전달해주는 방법

원하는 배열의 부분을 함수 내에서 순회할 수 있도록 시작점과 끝점을, 포인터 산술연산을 이용한 두 개의 포인터로 함수에 넘겨줄 수 있다. (코드 참고)

#include <stdio.h>

double average(double* start, double* end)
{
  int count = end - start; // 포인터끼리의 뺄셈 연산
  double avg = 0.0;
  while (start < end)
  {
    avg += *start++; // 먼저 avg 에 더하고 start 증가
    // count++; 2개의 포인터를 이용함으로써 필요없어진 코드
  }
  avg /= (double)count;
  return avg;
}

int main()
{
  double arr1[5] = {10,13,12,7,8};
  printf("Arr = %f\n", average(arr1,arr1+5)); // 포인터에 상수 덧셈연산
  return 0;
}

10.10 const 와 배열과 포인터

const 로 배열을 선언하면 일반적인 인덱싱으로는 원소를 변형시킬 수 없다. 그런데 배열의 포인터로 주소에 접근하면 변화가 가능하다.. 이걸 막기 위해 const 배열이라면 포인터도 const 로 선언할 필요가 있다. 하지만 이 경우에도 포인터의 증감연산은 된다. 이 때문에 const 를 두 개 써서 아예 변화를 차단하는 방법도 쓴다.

const double arr[] = {1.0,2.0,3.0,4.0};
const double* const ptr = arr;
// 앞의 const 는 포인터가 가리키는 원소의 값을 바꾸지 못한다는 const, 뒤의 const 는 포인터 자체의 주소를 바꾸지 못한다는 뜻 (포인터 증감연산 불가능)

"가진 게 하나도 없다면 모든 것을 다 가진 것처럼 행동해야 해" _알라딘

10.12 포인터에 대한 포인터(2중 포인터) 의 작동원리

다중 포인터는 동적할당에 요긴하게 쓰인다.

int a = 7;

//일반 포인터
int *ptr = &a;
// 포인터의 간접접근indirection(역참조)(de-referencing)
*ptr = 8;

// 이중포인터
// int (*(*pptr)) = &ptr;
int **pptr = &ptr; // 축약해서 씀
// 이중포인터의 de-referencing
**pptr = 9; // *(*pptr) = 9; 

10.13 포인터의 배열과 2차원 배열

포인터의 배열을 이용하면 다차원 배열을 쓰는 것과 유사한 효과를 낼 수 있다.

int arr[2][3] = {{1,2,3},{4,5,6}};
int* ptr_arr[2] = {arr[0], arr[1]};
// 각 row 를 포인터 배열의 각 원소가 담당한 것처럼 작동한다.

이해를 돕기 위한 1차원 배열 여러 개를 하나의 포인터 배열로 어떻게 관리할 수 있는지 등에 대한 내용은 코드를 참고하자.

배열은 그 자체가 포인터처럼 쓰이지만 포인터를 위한 주소공간이 따로 있는 것이 아니라 배열의 주소 자체를 이용하는 것이다. 하지만 포인터의 배열은 포인터 배열 내 각 포인터가 별개의 주소를 가진다는 점에서 차이가 있다.

printf("%p\n", &arr[0]); // A
printf("%p\n", arr[0]);  // A
printf("%p\n", arr); // A
printf("%p\n", &arr[0][0]); // A
printf("%p\n", &parr[0]);// B 얘만 별도의 포인터 주소
printf("%p\n", parr[0]); // A

 

이런 이중 배열을 위한 포인터 배열은 "문자열" 의 배열을 다루는 데 많이 쓰인다. 문자열이 하나의 배열과 같기 때문에, 포인터를 이용하면 각 문자열을 다루기 편리해진다.

char* name[] = {"Aladdin", "Jasmine", "Genie"};
const int n = sizeof(name) / sizeof(char*);
for ( int i = 0; i < n; ++i)
{
  printf("%s at %u\n", name[i], (unsigned)name[i]); // x64 면 %u 대신 %ull 써야함 (long long int)
  // 포인터 배열은 각 문자열의 시작 부분을 가리킬 뿐이므로, 이렇게 주소를 출력해보면 앞선 문자열의 길이가 몇이었냐에 따라 뒤 문자열의 시작 메모리주소의 증가값이 달라짐.
}
printf("\n");
// 포인터 배열을 쓰지 않는다면 아래와 같이 해야함.
char aname[][15] = {"Aladdin", "Jasmine", "Genie"};
const int an = sizeof(aname) / sizeof(char[15]); // 포인터 크기처럼 일정하지 않으니 미리 정해놓은 15 크기로 나눠야 함
for (int i=0; i<an; ++i)
{
  printf("%s at %u\n", aname[i], (unsigned)& aname[i]);
  // 포인터 배열을 썼을 때와 달리 메모리 주소 출력이 딱 15 단위로 증가함. 미리 크기를 정해놨으니 당연한 것.
}
printf("\n");

10.14 2차원 배열과 포인터

다차원 배열이라고 해도 결국엔 1차원 배열을 개발자 임의로 원하는 차원의 개수로 접어서 쓰는 것과 동일하다. 그래서 다차원 배열을 초기화할 때, 차원의 개수에 맞춰서 원소를 나눠서 초기화해줄 필요가 없다.
int arr[2][2] = {1,2,3,4}; 왼쪽과 같이 그냥 1차원적으로 나열해도, 알아서 분배된다. 특히 맨 앞의 숫자는 뒤의 숫자들이 어떻게 배정됐냐에 따라 자동으로 정해지게 되므로, 생략하는 경우가 많다. 아주 고차원 배열인 경우에도.
int arr[][2] = {1,2,3,4}; int arr[][3][4][5] = {...};

10.17 변수로 길이를 정할 수 있는 배열

가변길이배열이라고도 하는데, 비쥬얼 스튜디오에서는 컴파일되지 않는다. 그냥 동적할당을 제대로 익혀서 쓰자.
int n = 3; float arr[n]; 과 같이 쓴다.