C

C / 배열과 포인터

발아현미 2025. 4. 16. 18:02

- 배열이란?

코드를 작성하며 배열을 아주 많이 사용하곤 한다. 하지만 배열이란 정확히 무슨 뜻일까?

 배열이란, 연속된 메모리동일한 변수 타입을 가진 원소들의 모음을 말한다. 이때 배열의 원소는 배열의 식별자와 인덱스를 통해 접근 가능하다.

 

배열의 선언 시 원소의 변수타입, 식별자, 원소의 개수가 포함되어야 한다.

#include <stdio.h>

int main() {
	
	int num[3] = { 1, 2, 3 };

	return 0;
}
  1. 원소의 변수 타입 설정(int)
  2. 배열의 식별자 설정(num)
  3. 원소의 개수 설정(3개)

- [ ] 연산자

배열의 인덱스를 넣어, 배열의 n 번째 원소에 접근할 때 쓰이는 연산자이다. 하지만 배열을 선언할 때를 제외한 모든 경우에서 [ ] 연산자는 특별한 기능을 갖고 있다. 

a[n] == *(a + n)  //a는 배열의 식별자, n은 인덱스

위 법칙을 알고 있다면 편리한 경우가 많다.

#include <stdio.h>

int main() {
	char* str[3] = {"apple", "banana", "cat"};

	printf("str배열의 포인터값:%p\n", &str);
	printf("str[0]의 포인터값:%p\n", str);
	printf("apple 문자열 리터럴의 포인터값:%p\n", &(str[0]));
	printf("apple 문자열 리터럴의 포인터값:%p\n", str);

	return 0;
}

 str배열은 ⓐ길이가 3이고각 원소가 character pointer type 인 배열이다. 이때 2차원 배열은 아니다. str배열의 각 원소는 캐릭터 포인터 타입으로, Read-Only data영역에 저장된 각 리터럴 문자열의 포인터값을 저장한 것이기 때문이다.

 [참고] 그래도 각 리터럴 문자열들의 값에는 2차원 배열처럼 접근할 수 있다. 
#include <stdio.h>

int main() {
	char* str[3] = {"apple", "banana", "cat"};

	char* hello = "hello";
	printf("%c\n", hello[1]);
	printf("%c\n", str[0][4]);

	return 0;
}

 hello값에 접근하는 게 당연하듯, str 배열의 0번째 요소에 접근하는 방법은 위같이 하면 된다. 마치 2차원 배열에 접근하는 모습과 비슷하지만, 2차원 배열이라고 헷갈리지 말자.

 다시 돌아와서, str배열 자체의 포인터값을 반환하기 위해 &str을 사용했다. 배열의 식별자가 &연산과 사용된다면, 그것은 배열 전체의 포인터값을 가리킨다.

 두 번째 printf인 str을 보자. 배열의 식별자가 &, sizeof와 사용되지 않았기 때문에 배열의 첫 번째 원소의 포인터로 쓰였다. 따라서 str배열의 첫 번째 원소의 주소가 출력된다. ("apple" 리터럴 문자열의 주소가 아니다)

 세 번째 printf인 &(str[0])을 보자. str[0]에 &연산이 결합되었다. str[0]인 "apple"의 리터럴문자열의 주소값을 반환하라는 이야기인 것 같다. 

네 번째 printf인 str을 보자. 두 번째 printf와 완벽히 동일한 값을 가질 것이다.

 

 &(str[0])와 str을 보자. 위 공식에 의해 &(str[0]) == &(*(str + 0)) == &*str == str이다.

따라서 둘은 값을 출력을 갖는다. 

 

- 이차원 배열

 이차원 배열은 행, 열로 이루어진 배열이라고 대개 배운다. 하지만 행, 열로 구분 짓는 것은 머리 아플뿐더러, 3차원, 잘 쓰이지는 않지만 4차원 배열까지 가게 된다면 행, 열로 구분 지을 수 없을뿐더러 헷갈린다. 따라서 작성자는 a[m][n]와 같은 형태를 이차원 배열이라고 읽기만 할 뿐, 해석은 행/열로 구분 짓지 않겠다. 더 쉬운 방법이 있다.

#include <stdio.h>

int main() {
	int a[2][3] = { 1, 2, 3, 4, 5, 6 };
	printf("%p %p %p\n", a, a + 1, a[0] + 1);
	printf("%p %p\n", &a[0][1], &a[1][0]);

	return 0;
}

 위 그림과 같은 느낌을 상상하며 코드를 분석하는 사람들이 대다수일 것이다. 이런 방법도 좋지만, 길지 않은 2차열 배열 같은 경우 1차원 배열로 생각하는 것도 나쁘지 않다. 

일단, a라는 배열은 1차원 배열이다. 바로 위 a가 1-6까지 나란히 배열처럼 저장된 것처럼 생각하는 것이다.

그런 후, 1,2,3 / 4,5,6 끼리 또 다른 배열이라고 생각하는 것이다! 이때 각각의 배열의 식별자는 a[0], a[1]이 되는 것이다.

이것이 전부다. 아래 예제를 통해 자세히 살펴보자.

#include <stdio.h>

int main() {
	int a[2][3] = { 1, 2, 3, 4, 5, 6 };

	printf("%d\n", sizeof(a));

	printf("%d\n", sizeof(*a));
	printf("%d\n", sizeof(a[0]));

	printf("%d\n", sizeof(**a));
	printf("%d\n", sizeof(a[0][0]));
	printf("%d\n", sizeof(*a[0]));



	return 0;
}

sizeof(a)의 크기는, int type 변수 6개의 크기와 같으므로 24 출력

 

sizeof(*a)에서, a배열의 첫 번째 원소의 크기를 의미. 하지만 배열 a의 입장에서 첫 번째 원소는 a[0]이므로 int type 변수 3개의 크기, 12 출력

동일하게, a[0]은 a의 첫 번째 원소를 나타내므로 곧 int type 변수 3개의 크기 출력

 

sizeof(**a)는 첫 번째 *a와 먼저 결합, a[0]을 참조한다. 이어서 두 번째 *연산자와 결합하여, *a[0]의 값, a[0][0]을 의미한다.

첫 번째 *연산자와 결합했을 때 a는 a배열의 식별자로 작용하였고, 두 번째 *연산자와 결합했을 때는, a[0]이 배열의 식별자로 작용했다. 따라서 int type 변수 1개의 크기 4가 출력된다.

나머지 두 경우도 마찬가지로 해석되어, 크기 4가 출력된다.

 

- 2차원 배열과 포인터

포인터와 배열과 결합은 쉽게 가능했다. 하지만 2차원 배열과 포인터의 결합은 생각해야 할 것이 하나 있다. 배열 전체 / 부분적 배열 / 원소 하나하나 의 변수 타입이 다르다는 것이다. 이 것을 유념하여 변수를 선언해야 한다.

#include <stdio.h>

int main() {
	int a[2][3] = { 1, 2, 3, 4, 5, 6 };

	int* ptr1 = &a[0][0];
	int (*ptr2)[3] = &a[0];
	int(*ptr3)[2][3] = &a;

	printf("%d %d %d", sizeof(*ptr1), sizeof(*ptr2), sizeof(*ptr3));

	return 0;
}

 기본적인 상식으로, 변수를 초기화할 때 L-Value와 R-Value의 변수 type는 동일해야 한다. 하지만 컴파일러의 재량으로 가벼운 수준의 변수타입 오류는 넘어가곤 하지만, 포인터에 있어서는 정확한 변수 type가 요구된다.

 2차원 배열에서 제일 큰 스케일의 a[][] 배열의 포인터, 중간 스케일의 a [] 배열의 포인터, 마지막으로 제일 작은 크기의 a배열의 포인터를 초기화하려 한다.

  • ptr3; a배열 전체의 포인터
    A. 포인터 변수: 각 원소의 type가 int이고, 크기[2][3]을 갖는 배열을 받아와야 하기 때문에, int (*)[2][3]이라는 변수 타입을 가짐
    B. 배열명: a배열 전체의 포인터로 초기화해야 하기 때문에, a에 &연산자 사용. 배열의 연산자가 &와 사용되었기 때문에, 배열 전체의 포인터값을 의미한다.
  • ptr2; a배열의 0번째 원소, int type 3개짜리 배열
    A. 포인터 변수: 각 원소의 type가 int이고, 크기 [3]을 갖는 배열을 받아와야 하기 때문에, int(*)[3]이라는 변수 타입을 가짐
    B. 배열명: a배열 중에서 {1, 2, 3}만을 가져와야 하기 때문에, a[0]을 배열명으로 사용함과 동시에 &연산자 사용하여 초기화.
  • ptr3; a배열의 첫 번째 원소. int type 변수
    A. 포인터 변수: int type의 배열을 받아와야 하기 때문에, int* 이라는 변수 타입을 가짐
    B. 배열명: a배열 중 {1}을 가져와야 하기 때문에, a[0][[0]을 사용. 동시에 &연산자 사용해야 한다.

- Decay란?

 여기서 한 가지 의문이 들 수 있다. [ ] 연산자의 특징을 윗 페이지에서 알아보았다.

a[n] == *(a + n)  //a는 배열의 식별자, n은 인덱스

 

 하지만 위에서, 포인터 선언을 시행할 때도 위 규칙을 따를 수 있을까?

#include <stdio.h>

int main() {
	int a[2][3] = { 1, 2, 3, 4, 5, 6 };

	int* ptr1 = a[0];	//&a[0][0] -> a[0]
	int (*ptr2)[3] = a;	//&a[0] -> a
	int (*ptr3)[2][3] = &a;

	printf("%d %d %d", sizeof(*ptr1), sizeof(*ptr2), sizeof(*ptr3));

	return 0;
}

 

 가능하다!

전에도 이야기했지만, 배열의 식별자는 아래의 세 가지 규칙을 따른다.

1. 아래 두 경우가 아닐 때, 배열의 식별자는 첫 번째 원소의 포인터값을 반환한다.
2. sizeof함수와 사용될 때, 메모리에 할당된 배열의 크기를 반환한다.
3. &와 사용될 때, 배열 자체의 포인터값을 반환한다.

1번 규칙을 'decay'라는 표현으로 쓰겠다. (정식 명칭 Array-to-Pointer)

 decay란, 배열의 식별자가 sizeof연산자와 &연산자와 사용되지 않을 경우 n차원 배열이 n-1차원 배열의 포인터가 된다는 것이다.

바로 위 코드를 다시 보자!

 ptr2는 1차원 배열의 포인터값을 받아와야 한다. int (*)[3]이기 때문이다. 이때 R-Value는 a로, decay 되었기 때문에 2-1인 1차원 배열의 포인터값이 된다.

 같은  규칙을 적용하여, ptr1은 0차원 배열의 포인터값을 받아와야 한다. 이때 R-Value는  a[0]으로, decay 되었기에 1-1인 0차원 배열의 포인터값이 된다. 

 

- 정리

1. [ ] 연산자

[ ] 연산자는 a[b] == *(a + b) 법칙을 따른다. 이때 a는 배열의 식별자, b는 인덱스로 정수값을 갖는다.
추가적으로, &a[b] == &*(a + b) == a + b 연산은 참이다.

 

2. 2차원 배열의 의미

2차원 배열은 행/렬로 이루어진 배열보다, '배열 속 배열' 이라고 이해해야 한다.
이때 부분적 배열 또한 '배열의 식별자' 가 될 수 있다.

 

3. Decay

배열의 식별자가 &연산과 sizeof연산으로 사용되는 경우가 아니라면 '배열의 첫 번째 원소의 포인터값'을 반환할 수 있다. 
특정 배열이 n차원일 때, 배열의 식별자는 'n-1차원 배열 포인터' 변수타입을 갖는다. 이 method를 Decay (Array-to-Pointer)라고 일컫는다.