C언어 배열과 포인터

Minimal_user·2024년 5월 11일

c언어

목록 보기
11/17

1. 배열과 부분 배열

  • (보류)

2. 첨자 ([ ]) 연산

  • [ ] 괄호는 언뜻 보기에 구두점같아 보이지만 실제로 첨자 연산을 하는 포인터다.
    • ptr이 임의의 배열을 가리키고 있는 포인터이고 n이 정수일 때,
      ptr[n] = *(ptr+n)
      • 따라서, ptr이나 n은 각각 포인터, 정수이거나 정수, 포인터야한다.
      • 그렇다면 심지어 2[ptr] 이런 것이 가능하다. (int ptr[]={1,2,3,4,5}; 인 경우에도)
    • 사실 ptr[n] 표현식은 컴파일러에 의해 *(ptr+n)으로 바뀐 후 컴파일되며 생성되는 기계어 코드도 완전히 동일하다.
  • 이차원 배열ar[2][1]*(*(ar+2)+1)과 동일하다.
    • 따라서 이 경우 2[ar][1], 1[2[ar]], 1[ar[2]] 와 동일하다.

3. 포인터 배열

  • 요소가 포인터형인 배열이다.
  • int *arpi[5];
    • 이 선언문에 사용된 *[ ]는 둘다 구두점이다.
    • *가 앞쪽에 있으므로 arpi는 먼저 정수형 포인터가 되고,
    • 다음으로 [ ]에 의해 그런 포인터 변수 5개를 모아 배열을 선언하게 된다.
    • 특히 char *carpi[]={"고양이", "개", "오랑우탄", "돼지"}; 이러한 배열은
      각 문자열들은 메모리상에 흩어져 존재하지만 이 문자열들의 번지를 포인터 배열로 가지고 있다.
      • 이렇게 만들어진 배열을 Ragged배열이라고 하는데 개별 문자열의 길이가 각각 달라도 낭비되는 메모리가 없다.
  • 2차원 배열은 각 행의 길이가 모두 똑같지만, 포인터 배열은 각 포인터가 가리키는 배열의 길이가 각각 다를 수 있다.

  • 배열과 포인터의 차이점
    • 우선 포인터는 변수인데 비해 배열은 상수다.
      pi는 고유의 메모리를 차지하고 있어 언제든지 다른 대상을 가리킬 수 있지만 ar은 선언할 때 그 위치가 이미 고정되므로 다른 대상을 가리킬 수 없다.
      ar로는 오로지 배열의 선두 번지를 읽을 수 있을 뿐이며 이 선두 번지를 기준으로 하여 배열 요소를 읽는다.
    • pi가 가리키는 배열의크기는 동적으로 결정할 수 있지만, ar이 가리키는 배열의 크기는 선언할 때 정적으로 결정된다.
    • 배열은 그 자체가 크기 때문에 함수의 인수로 전달할 수 없지만, 포인터는 대상체가 무엇이든간에 4바이트의 크기밖에 차지하지 않으므로 함수로 전달할 수 있다. 그래서 배열을 함수로 전달할 때는 반드시 포인터를 사용해야 한다.
    • 배열로 요소를 읽는 것과 포인터로 대상체를 읽는 동작은 속도 차이가 있다.
      배열의 첨자 연산은 매번 배열 선두에서부터 출발하지만, 포인터는 대상체로 직접 이동해서 바로 읽으므로 액세스 속도가 빠르다.
      *pipi가 가리키는 곳을 바로 읽지만 ar[n]*(ar+n)으로 일단 번지를 더한 후 읽어야 하므로 조금 느리다.

4. 배열 포인터

  • 배열 포인터는 상요 빈도가 그리 높지 않으며 실용성도 떨어진다.
    • 문법적 대칭성을 위해 존재하는데 포인터 배열이 있으니 배열 포인터도 당연히 있어야 하기 때문이다.
  • 포인터 배열 (Array of pointer) vs 배열 포인터(Pointer to array)
    • 포인터 배열 : 그 원소가 포인터인 배열이다.각각의 배열 요소인 포인터가 가리키는 대상은 원칙적으로 임의의 타입을 가질 수 있지만 주로 문자형을 가리키는 경우가 많다.
    • 배열 포인터 : 배열의 번지를 담는 포인터 변수다. 포인터가 가리키는 대상은 배열형으로 구성되어 있으며 포인터가 가리키는 배열의 요소는 임의의 타입을 가진다.
  • 포인터 배열은 결국은 배열이고, 배열 포인터는 결국 포인터다.
  • 배열 포인터는 배열이 여러 개 보여 있는 배열의 배열, 그러니까 2차원 이상의 배열에서만 의미가 있다.
    • 1차원 배열에 대한 배열 포인터라는 것은 없다.
  • 요소타입 (*포인터명)[2차 첨자 크기]
    • '( )'를 생략해 버리면 배열 포인터가 아닌 포인터 배열이 된다.
    • 배열 포인터 선언시 2차 첨자 크기는 반드시 밝혀야 하는데 이 크기를 알아야 기리킬 배열(곧 대상체)의 전체 크기를 구할 수 있기 때문이다.
    • 3차 이상은 2차 이후의 첨자만 적고 그 앞에 (*변수명)만 붙이면 된다.)
      • 예를 들어 int ar[2][3][4]; 배열을 가리키는 배열 포인터는 int (*par)[3][4]로 선언한다.
char arps[5][9]={"고양이", "개", "오랑우탄", "돼지", "지렁이"};
char (*ps)[9];
ps=arps;
for int(i=0;i<5;i++){
	printf("%s\n", *ps++);
}
  • 위 예에서 ps++은 대상체의 크기인 9바이트만큼 앞뒤로 이동하게 된다.

  • 배열 인수
    • 배열 포인터는 자신이 가리키는 대상체 배열의 크기를 정확하게 알고 있다.
      그래서 타입이 다른 배열은 이 변수에 대입할 수 없다.
    • 배열 포인터는 자신이 가리킬 수 있는 배열의 타입과 크기를 정확하게 기억하고 있기 때문에 사용자의 부주의한 대입을 막을 수 있다.
int ari [][7]={{1,2,3,4,5,6,7},{8,9,10,11,12,13,14}};
int (*pa)[7];
int (*pb)[8];

pa=ari; // 가능
pb=ari; // 에러 발생
//pb=(int (*)[8])ari;  // 로 강제 캐스팅이 가능하긴 하다.

// 실제 사용 예
int GetTotalForWeek(int (*pa)[7]){
// 파라미터 값으로 넘어온 배열은 1차원 배열이긴 하지만 1차 첨자가 크기가 1인 2차원 배열로도 볼 수 있다.
// pa는 2차원 배열 포인터이기 때문이다.
    int sum=0;
    for (int i=0; i<7; i++){
        sum+=pa[0][i];
    }
    return sum;
}

void main(){
    int ari[][7]={
        {1,2,3,4,5,6,7},
        {8,9,10,11,12,13,14},
        {15,16,17,18,19,20,21}
    };

    for (int i=0;i<3;i++){
        printf("%d\n", GetTotalForWeek(&ari[i])); // ari[i]와 &ari[i]의 주소값은 실제로 같다.
                                                  // 자세한 내용은 뒤 편의 &ar 참고할 것.
    }
}
  • c언어는 함수의 인수로 배열을 전달하는 방법은 제공하지 않으며 오로지 포인터로만 전달할 수 있다.
    • 아래 예제의 OutArray함수는 크기 5의 정수형 배열을 전달닫는 것처럼 보이지만 사실은 그렇지 않다.
void OutArray(int ar[5]){ // int ar[5], int *ar, int ar[] 모두 동일한 표현이다.
// int *ar이든 int ar[]로 표기하든 컴파일러는 둘 다 정수형 포인터로 해석한다.
// 따라서 [ ] 안에 배열의 크기는 생략할 수도 있고 상수값을 적을 수도 있지만 컴파일러는 여기에 표기된 상수값은 완전히 무시한다.
// 만약 함수 내에서 배열의 크기를 꼭 알아야 한다면 배열의 시작 번지와 함께 별도의 인수로 배열의 크기를 전달해야 한다.
// 이렇게 표기할 수도 있기 때문에 위 예제의 GetTotalForWeek 함수의 정의에서도 int (*pa)[7]를 int [][7]로 표기할 수 있다.
	for (int i=0; i<5; i++){
    	printf("%d\n",ar[i]);
    }
}

void main(){
	int ar[]={1,2,3,4,5};
    OutArray(ar);
}
  • 함수의 인수 목록에서 포인터형 인수에 대해서는 int *ar 형식과 int ar[]이라는 표기는 동일하게 해석되지만
    문법외적으로 가지는 의미는 다음과 같다.
    • int *ar : 이 인수가 포인터라는 것을 강조한다. 호출원에서 &ipi 등을 넘길 때는 이런 표기법을 쓰는 것이 좋으며, 함수를 쓰는 사람은 이 표기를 보고 이 인수가 정수형 변수의 번지를 전달받는다고 생각할 것이다.
    • int ar[] : 이 인수가 배열로부터 온 포인터라는 것을 강조한다. 호출원에서는 arScorearValue같은 배열명으로 평가된 포인터 상수를 넘길 때 이런 표기법을 쓰는 것이 좋으며 이렇게 표기된 인수는 배열이라는 것을 쉽게 할 수 있다.

  • 이차 이상의 배열을 함수로 넘길 때는 배열 포인터를 사용해야 한다. (하지만 1차 첨자 크기는 함께 함수에 전달해야 한다.)
    • 일차 배열을 함수의 인수로 전달할 때는 단순 포인터와 개수를 함께 넘겼다.
  • 이차원 배열을 인수로 전달하는 방법은 사실 거의 실용성이 없고 실제 프로젝트에서 사용할 일도 없다.
void func(int (*ar)[3], int size){ // int *ar[][3] 로 표현 가능.
  for (int i=0;i<size;i++){
  	for(int j=0;j<3;j++){
    	printf("%d\n", ar[i][j]);
  }
}

void main(){
	int ar1[2][3]={{1,2,3},{4,5,6}};
    int ar1[3][3]={{7,8,9},{10,11,12},{13,14,15}};
	
    func(ar1, 2);
    func(ar2, 3);

  • 이차배열 할당

    • 동적 할당 기능을 사용하여 2차원 배열도 동적 할당하는 것을 의미한다.
    • 결과만 이야기하자면 malloc을 이용할 때 malloc(3*4*size(char)를 이용할 수는 있다.
      그렇지만 이 malloc을 받는 변수의 타입을 설정할 때 배열포인터를 이용하면 첨자의 값을 반드시 상수로 주어야하기 때문에 불가능하다.
    • 다만 malloc을 이용하여 이차원 배열을 만들기 위해서 이중 포인터 변수에 malloc을 사용하고 그 하위에 다시 malloc을 이용하여 이차원 배열을 만들 수 있다.

  • &ar

    • ar이 포인터 상수인데상수는 번지를 가지지 않으므로 &연산자를 쓸 수 없다.
    • 하지만 ANSI C 이후부터 배열이 &의 피연산자가 될 때 포인터 상수가 아니라 배열 그 자체를 가리키는 것으로 변경되었다.
    • GetTotalForWeek 예제 코드에 적어놓았지만 int ar[5]={1,2,3,4,5};에서 ar과 &ar의 주소값은 같다.
    • 그렇지만 int *pi; 변수를 선언하고 pi=ar이라고 입력하면 정상적으로 실행되지만 pi=&ar을 입력하면 에러가 발생한다.
      • 정수형 포인터 pi는 정수형 포인터 상수인 ar을 대입받을 수 있지만 &ar을 대입받을 수는 없다.
        • 대입이 안된다는 이야기는 좌우변의 타입이 다르다는 뜻이다.
        • ar은 정수형 배열의 시작번지를 가리키는 포인터 상수이므로 정확한 타입은 int * const 이며 대상체는 int다.
        • 그러나 &ar의 타입은 대상체가 크기 5의 정수형 배열이며 타입은 int(*)[5] const가 된다.
          • 이 부분이 GetTotalForWeek 예제코드에서 참고하고 했던 부분이다.
          • &ar은 크기 5인 정수형 배열을 가리키는 배열 포인터 상수다.
        • 아래의 예시를 참고하면 각각의 타입이 다르다는 것을 확인할 수 있다.
void main(){
	int ar[5]={1,2,3,4,5};
    int *p1;
    int (*p2)[5];

    p1=ar;
    p2=&ar;

    printf("before : %p\n", p1); // before : 0x7fffdb557120
    printf("before : %p\n", p2); // before : 0x7fffdb557120
    p1++; // 4바이트를 이동시킨다.
    p2++; // 4*5 바이트를 이동시킨다.
    printf("after : %p\n", p1); // after : 0x7fffdb557124
    printf("after : %p\n", p2); // after : 0x7fffdb557134
    printf("===\n");

    p1++;
    printf("after : %p\n", p1); // after : 0x7fffdb557128
    p1++;
    printf("after : %p\n", p1); // after : 0x7fffdb55712c
    p1++;
    printf("after : %p\n", p1); // after : 0x7fffdb557130
    p1++;
    printf("after : %p\n", p1); // after : 0x7fffdb557134
}
  • 더 나아가서 char name[20]; scanf("%s", name);는 왜 잘 작동되는지 이해가 될 것이다.
    • 하지만 scanf("%s", &name); 도 잘 작동이 된다.
    • name&name은 타입이 다르지만 가리키는 주소는 우연히 같다.
    • 잘 작동되는 이유는 가변 인수 함수는 인수의 타입을 점검하지 않으며 서식과 일치하는 타입으로 간주한다.
      • name&name은 둘다 포인터형이므로 4바이트이고 %s 서식과 대응될 수 있으므로 동작에도 이상이 없는 것이다.
      • 하지만 결코 정상적인 문법은 아니므로 name앞에는 &를 붙이지 말아야 한다.
        • 차리리 &name[0]라고 쓰는 것이 정상적이다.
      • 참고 : 가변인수 내용은 포인터 고급에서 다룬다.

5. 배열과 문자열

  • 컴파일러는 문자열 상수를 다른 상수들과 달리 아주 특별하게 취급한다.
  • 문자열 상수는 실행 파일에 같이 기록되는데 정확하게 정적 데이터 영역에 기록된다.
    • 정확하게는 정적 데이터 영역에 기록된다.
    • 설사 함수 내에서 지역적으로 사용하고 있는 문자열 상수더라도 말이다.
  • 컴파일러가 문자열 상수를 실행 파일에 기록해두는 이유는
    • 정수나 문자형 같은 상수는 기본 타입이고 크기가 작기 때문에 그 값을 코드에 곧바로 기록할 수 있다.
    • 그러나 문자열 상수는 그 자체가 배열이며 길이가 굉장히 길 수 있기 때문에 코드로는 곧바로 표현하지 못한다.
  • 컴파일러는 문자열 상수를 정적 데이터 영역에 기록할 때 항상 Null종료 문자도 같이 포함한다.
    • 그래야 문자열 상수를 사용하는 곳에서 이 문자열의 길이를 알 수 있기 때문이다.
  • 코드에서 문자열 상수는 이 문자열의 시작 번지를 가리키는 문자형 포인터 상수로 평가된다.
    • 그래서 문자형 포인터를 인수로 요구하는 모든 함수에서 문자열 상수를 바로 사용할 수 있는 것이다.
  • 컴파일러는 문자열 상수를 두 번 이상 사용하면 이 문자열은 한 번만 기록된다.
  • 컴파일러는 공백이나 탭, 개행 코드 등으로만 구분된, 즉 중간에 콤마나 영문자 등이 없는 연속된 문자열 상수들을 하나로 합쳐서 기록한다.
    • printf("%s\n", "aa" "bb" " cc");의 출력 결과물은 aabb cc다.

  • 문자 배열 초기화
    • 문자 배열 초기화를 할 때 한글이나 한자는 우리가 생각하는 1음절이 2바이트를 차지하므로 두 배의 저장공간이 필요하다.
    • char str[]="2002 Korea/Japan Worldcup";
      * = 구두점 다음에 큰 따옴표로 싸여진 문자열 상수를 적으면 이 문자열을 구성하는 문자들을 str 배열 요소에 순서대로 복사한다.(뿐만 아니라 배열의 제일 끝에 널 종료 문자도 자동으로 붙이며 배열 크기를 생략할 경우 문자열 길이 +1로 알아서 길이를 계산하기도 한다.)

  • 문자형 포인터
    • 문자열이란 문자들의 집합이며 그 시작 번지를 곧 문자열이라고 할 수 있다
      • 문자열의 시작 번지를 저장할 수 있는 문자형 포인터로도 문자열을 다룰 수 있다.
        • char *pr="Korea";
      • ptr은 상수가 아닌 변수이므로 실행 중에 언제든지 다른 문자열을 가리킬 수 있다.
        • char *ptr; ptr="Korea"; ptr="China";
      • 그러나 문자 배열은 선언할 때 문자열로 초기화할 수만 있을 뿐 선언된 후에는 다른 문자열을 바꿔서 대입할 수 없다.
        • char str[10]; str="Korea";
        • 배열명 그 자체는 포인터 상수이기 때문에 한 번 정해지면 다른 번지를 가리킬 수 없다.
          • 문자 배열의 내용을 (통째로) 바꾸려면 strcpy 같은 함수를 사용해서 일일이 복사해야한다.
          • 다음 예제는 문자형 배열과 문자형 포인터의 차이점을 보여준다.
          char str[]="Korea";
          char *ptr="Korea";
          //-------------------
          ptr="China";
          //str="China";
          //-------------------
          ptr[0]='C';
          //ptr[0]='C';
        • char str[]="Korea"는 str 배열의 번지가 정적 데이터 영역에 있는 "Korea"로 바뀌는 것이 아니라 정적 데이터 영역의 "Korea" 문자열이 str배열로 복사되는 것이다. 즉 str 배열은 사본 문자열이다. 또한 str배열은 문자열의 사본을 가지고 있으므로 그 내용을 바꿀 수 있다.

  • 문자열 배열
    • 문자열 배열은 2차원 배열로 표현할 수 있다.
      • char arCon[][32]={"Korea", "America", "Iran", "Russia"};
    • 문자열 배열 대신 1차 포인터 배열을 쓸 수도 있다.
      • char *pCon[]={"Korea", "America", "Iran", "Russia"};
    • 위 두 예시의 차이점은, 첫 번째 예시는 메모리 상에 직사각형 배열로 할당되지만, 두 번째 예시는 Regged 배열로 만들어진다.
    • 첫 번째 예시의 경우 메모리가 조금 낭비되는 경향이 있다. 대신 사본을 가지고 있기 때문에 실행 중에 문자열의 내용을 자유롭게 변경할 수 있다.
    • 두 번째 예시의 경우 메모리 낭비가 거의 없다는 것이 장점이 있다. 메모리 용량면에서는 유리하지만 대신 실행 중에 이 문자열의 내용을 바꿀 수 없다는 것이 단점이다.
    • 실행 중에 편집 가능한 Ragged 문자열 배열을 만드는 것도 가능하다.
      • 필요한 최대 개수만큼 char* 배열을 선언하고 각 요소에 대해 원하는 길이만큼 동적 할당하면 된다.
        • 문자열 개수까지도 실행중에 결정하고 싶다면 char **변수를 선언하고 포인터 배열을 동적 할당하고 각 배열 요소인 포인터를 또 동적할당하면 된다.
        int len=10,num=5,i;
        char **name;
        //--
        name=(char **)malloc(num*sizeof(char *));
        for (i=0;i<num;i++) {
            name[i]=(char *)malloc(len*sizeof(char));
        }
        //--
        for (i=0;i<num;i++) {
            sprintf(name[i],"string %d",i);
            puts(name[i]);
        }
        //--
        for (i=0;i<num;i++) {
            free(name[i]);
        }
        free(name);

출처 : 혼자 연구하는 C/C++ 1 / 김상형 저 / 와우북스

profile
White book for everything I need.

0개의 댓글