C (4) - 배열과 포인터 ①

배열

 

지금까지 메모리에 저장 공간을 확보할 때 변수를 선언했습니다. 예를 들어 5개의 int형 변수를 다루기 위해서는 변수를 개별적으로 선언하고 일일이 값을 초기화해줘야 합니다. 변수마다 이름이 별도로 있기 때문에 반복문 사용도 불가능합니다.

 

이렇게 같은 형태의 많은 데이터를 반복문으로 처리하기 위해서 메모리에 연속적으로 저장해 놓고 쪼개서 사용하는 방법을 사용하는데 이를 배열(array)이라고 합니다.

 

배열의 선언

 

배열 또한 다른 자료형처럼 선언을 통해서 저장 공간을 확보합니다. 다만, 기존처럼 변수를 하나씩 선언하지 않고, 하나의 이름으로 한꺼번에 확보합니다. 또 저장 공간의 개수와 관계없이 이름은 하나만 사용합니다.

 

int형의 요소가 5개인 배열을 선언하고 사용하는 방법은 다음과 같습니다.

int main(void)
{
    int arr[5];

    arr[0] = 10;
    arr[1] = 20;
    arr[2] = arr[0] + arr[1];

    return 0;
}

int형 변수는 크기가 4바이트이므로 5개를 연속으로 할당하면 총 20바이트가 할당됩니다. 배열의 나누어진 조각을 배열 요소(element)라고 하는데, 각각의 배열 요소는 int형 변수와 똑같이 사용합니다. 배열 요소는 배열명에 첨자(index)를 붙여 표현하며 첨자는 0부터 시작합니다.

 

배열의 사용

 

배열을 선언할 때와 배열 요소를 사용할 때 대괄호([ ]) 속 숫자의 의미는 다릅니다. 선언할 때는 배열 요소의 전체 개수를 표시하며, 사용할 때는 각 요소가 배열에서 몇 번째에 있는지를 의미합니다. 이 값이 첨자며, 배열의 첨자는 0부터 시작하므로 최대 '배열 요소 개수 -1'까지만 사용합니다.

배열 arr의 배열 요소 개수가 5개일 때 arr[5]를 사용하면 이는 첨자의 사용 범위를 벗어나므로 그 결과를 예측할 수 없습니다. 이 경우 컴파일러가 경고 메시지로 알려주 주기도 하지만 배열 요소에 포인터 연산을 통해 접근하므로 확실한 에러 메시지를 표시하지 않습니다. 이렇게 범위를 벗어난 영역에 접근하는 배열 코드는 실행 단계에서 문제를 일으킬 때나 발견할 수 있으므로 버그를 찾아내기가 쉽지 않습니다. 따라서 사용 범위를 벗어나지 않도록 주의해야 합니다.

 

배열 초기화

 

배열도 변수와 마찬가지로 최초 할당된 저장 공간에는 쓰레기 값이 저장되어 있습니다. 그러므로 배열도 원하는 값을 가지려면 선언과 동시에 초기화해야 합니다. 배열은 중괄호({ })로 묶어서 초기화합니다.

int arr[5] = {1, 2, 3, 4, 5};		// 1, 2, 3, 4, 5

int arr[5] = {1, 2, 3};		// 1, 2, 3, 0, 0

int arr[1000] = {0};			// 0, 0, 0 ....

int arr[] = {1, 2, 3};			// 1, 2, 3
  • 초깃값이 배열 요소의 개수보다 적은 경우에는 왼쪽부터 차례로 초기화하고 남은 배열 요소는 모두 0으로 채웁니다.
  • 배열의 초기화는 선언 시 최초 한 번만 가능하며, 그 이후에는 배열 요소에 일일이 값을 대입해야 합니다.
  • 초기화를 제외하고 중괄호({ })를 사용한 대입 연산으로 한 번에 값을 변경하는 것은 불가능합니다.

 

배열과 반복문

 

반복문을 사용하면 배열 요소에 쉽게 접근할 수 있습니다.

#include <stdio.h>

int main(void)
{
    int score[5];
    int i;
    int total = 0;
    double avg;

    for(i = 0; i < 5; i++)
    {
        scanf("%d", &score[i]);
    }

    for (i = 0; i < 5; i++)
    {
        total += score[i];
    }
    avg = total / 5.0;

    return 0;
}

 

 

sizeof 연산자를 활용한 배열 처리

 

보통 많은 양의 데이터를 처리하므로 배열을 다룰 때는 반복문 사용이 필수입니다. 따라서 배열 요소의 개수가 바뀌면 배열을 처리하는 반복문을 모두 수정해야 하는 부담이 있습니다. 이 문제의 해결책으로 sizeof 연산자로 배열 요소의 개수를 직접 계산해 반복문에 사용하는 방법을 사용할 수 있습니다.

#include <stdio.h>

int main(void)
{
    int score[5];
    int i;
    int total = 0;
    double avg;
    int count;
    
    count = sizeof(score) / sizeof(score[0]);

    for(i = 0; i < count; i++)
    {
        scanf("%d", &score[i]);
    }

    for (i = 0; i < count; i++)
    {
        total += score[i];
    }
    avg = total / (double)count;

    return 0;
}

 

문자를 저장하는 배열

 

우리는 평소 의미를 전달하기 위해 단어를 사용합니다. 단어는 알파벳의 나열이며, 그 순서에 따라 단어의 뜻이 달라집니다. 이런 단어를 컴퓨터에서 데이터로 처리하려면 메모리에 문자를 순서를 맞게 연속으로 저장해야 합니다. 결국 배열이 단어를 저장하기 위한 가장 좋은 방법입니다. 그리고 모든 알파벳 문자는 한 바이트로 충분히 표현할 수 있으니 char형 배열을 사용하는 것이 좋습니다.

 

char형 배열의 선언과 초기화

 

char형 배열을 초기화 하는 방법은 다음과 같습니다.

char str[15] = "applejam";

char str[15] = {'a', 'p', 'p', 'l', 'e', 'j', 'a', 'm'};

 

char형 배열을 선언할 때 꼭 기억해야 할 점은 저장할 문자열의 길이보다 최소 하나 이상 크게 배열을 선언해야 한다는 것입니다.

예를 들어 문자열 "apple"을 저장할 배열은 배열 요소 개수가 최소한 6개 이상이어야 합니다.

이렇게 여분의 공간이 필요한 이유는 널 문자(\0)를 저장하기 위해서입니다.

 

널 문자의 용도

 

초기화한 문자는 배열의 처음부터 차례로 저장되어 문자열을 만듭니다. 이때 남는 배열 요소에는 자동으로 0이 채워집니다. 이렇게 char형 배열에 저장된 0을 특별히 널 문자(null character)라고 부릅니다.

 

모든 문자는 아스키 코드 값으로 저장되므로 결국 널 문자는 아스키 코드 값이 0인 문자를 말하며 문자 상수로는 \0으로 표현됩니다.

아스키 코드 값이 0인 문자를 널 문자라는 이름으로 특별히 대접하는 이유는, 널 문자가 문자열의 끝을 표시하는 용도로 쓰이기 때문입니다. 

 

scanf 함수로 문자열을 입력받을 때도 입력 후 남은 요소는 널 문자로 채워지며, printf 함수는 널 문자가 나올 때까지만 출력하도록 만들어 졌습니다.

char형 배열 선언 시 초기화를 하면 남는 배열 요소가 0으로 채워지므로 자동으로 문자열의 끝에 널 문자가 저장되나, 초기화하지 않은 상태에서 배열 요소에 직접 문자를 대입한다면 반드시 마지막 문자 다음에는 널 문자(\0)를 직접 대입해야 합니다.

 

문자열 대입

 

char형 배열이 문자열을 저장하는 변수의 역할을 하므로 초기화된 이후에도 얼마든지 새로운 문자열을 저장할 수 있습니다. 단, 문자열의 길이가 다를 수 있으므로 일반 변수처럼 대입 연산자를 사용하는 것은 불가능합니다. 이때는 strcpy 함수를 사용합니다.

 

strcpy 함수는 string copy라는 의미로 char형 배열에 새로운 문자열을 저장하는 함수입니다. 이 함수는 저장할 문자열의 길이를 파악해 딱 그 길이만큼만 char형 배열에 문자열을 복사합니다. 물론 문자열 끝에 널 문자도 자동으로 붙여 줍니다.

 

strcpy 함수의 첫 번째 인수로는 저장될 곳의 배열명을 주고, 두 번째 인수는 저장할 문자열을 사용합니다.

이때, 두 번째 인수로 문자열 상수뿐 아니라 char형 배열의 배열명도 사용할 수 있습니다.

#include <stdio.h>
#include <string.h>

int main(void)
{
    char str1[80] = "cat";
    char str2[80];

    strcpy(str1, "tiger");
    strcpy(str2, str1);

    return 0;
}

 

문자열 전용 입출력 함수

 

키보드로 char형 배열에 문자열을 입력할 때 scanf 함수를 사용할 수 있으나, scanf 함수는 중간에 빈칸이 있는 경우 빈칸 전까지만 입력합니다. 따라서 빈칸을 포함해서 문자열을 입력할 때는 다른 함수가 필요합니다.

 

gets 함수는 빈칸을 포함해 한 줄 전체를 문자열로 입력합니다. 또한 이 함수와 짝을 이루는 문자열 출력 함수 puts도 있습니다.

#include <stdio.h>

int main(void)
{
    char str[80];
    printf("문자열 입력 : ");
    gets(str);
    puts("입력된 문자열 : ");
    puts(str);

    return 0;
}

 

gets 함수

 

gets 함수는 인수로 char형 배열의 배열명을 줍니다.

gets 함수는 문자열 입력 중간에 빈 칸이나 탭 문자를 사용할 수 있으며, enter를 누르기 전까지 전체를 하나의 문자열로 배열에 저장합니다. 물론 마지막에 널 문자를 붙여서 문자열의 끝을 표시합니다.

 

gets 함수를 사용할 때 주의할 점은 입력할 배열의 크기를 검사하지 않으므로 배열의 크기보다 긴 문자열을 입력하면 배열을 벗어난 메모리 영역을 침범할 가능성이 있습니다. 따라서 입력할 때 항상 배열의 크기를 고려해야 합니다. 이 문제는 scanf 함수나 strcpy 함수에도 해당되며 컴파일러에 따라 시스템 안전성 문제 때문에 컴파일을 제한하기도 합니다.

 

puts 함수

 

puts 함수는 문자열 상수나 char형 배열의 배열명을 주면 문자열을 화면에 출력합니다. printf 함수의 문자열 출력 기능과 같습니다. 단, 문자열을 출력한 후에 자동으로 줄을 바꾸는 차이가 있습니다.

 

+) 더 알아보기

 

배열과 대입 연산자

 

C 언어에서 배열을 배울 때 한 번쯤은 다음과 같은 코드를 작성하게 됩니다.

char str[80];
str = "apple";

이 대입 연산식은 당연히 수행될 듯하지만, 컴파일 단계에서 에러가 발생합니다. 대입 연산자 왼쪽에 사용한 배열명이 컴파일 과정에서 다음과 같이 배열이 할당된 메모리의 주소 값으로 바뀌기 때문입니다. 

반면, strcpy 함수는 문자열을 복사할 때 대상 메모리 위치로 이동하면서 해당 공간을 차례로 사용하도록 구현되어 있습니다. 이 문제를 정확히 이해하기 위해서는 배열과 포인터의 관계를 배워야 하기 때문에 뒤에서 다시 알아보겠습니다.

 

문자열 끝에 널 문자가 없다면?

 

printf 함수를 비롯한 많은 문자열 처리 함수가 널 문자로 문자열의 끝을 확인합니다. 따라서 char형 배열에 무엇을 저장하든 널 문자가 나올 때까지가 하나의 문자열입니다. 만약 필요한 곳에 널 문자가 없다면 문자열을 다루는 함수의 결과는 예상할 수 없습니다.

#include <stdio.h>

int main(void)
{
    char str[5];

    str[0] = 'O';
    str[1] = 'K';
    printf("%s\n", str);

    return 0;
}

위 그림과 같이, str 배열의 나머지 요소에는 어떤 값이 들어 있는지 알 수 없습니다. 운이 좋다면 널 문자(\0)가 포함되어 있을 수도 있지만, 그렇지 않을 수도 있습니다. 만약 배열 내에 널 문자가 없다면, printf 함수는 메모리의 다음 영역까지 계속 읽어 출력할 수 있어 예기치 않은 결과가 발생할 수 있습니다.


따라서 char형 배열에 문자를 하나씩 대입하여 문자열을 만들 때는, 문자열의 끝에 반드시 널 문자(\0)를 명시적으로 저장해야 한다는 점을 잊지 말아야 합니다.

 

 

포인터

 

지금까지 변수 선언으로 메모리에 공간을 확보하고, 그곳을, 데이터를 넣고 꺼내 쓰는 공간으로 사용했습니다. 그리고 변수명을 그런 메모리 공간을 식별할 수 있는 이름으로 사용했습니다.

 

그러나 선언된 블록({ }), 즉 함수 내부에서만 그 변수를 사용할 수 있었습니다. 같은 변수명을 사용했다 하더라도 블록이나 함수가 다르면 별도의 저장 공간을 확보하므로 전혀 다른 변수로 사용되는 것이었습니다. 이번엔 사용 범위를 벗어난 경우에도 데이터를 공유할 수 있게 도와주는 포인터에 관해 알아보겠습니다.

 

메모리의 주소

 

메모리라는 것은 우리가 데이터를 넣고 꺼내 쓰는 공간으로, 그 위치를 식별할 수 있어야 합니다. 다행히도 프로그램이 사용하는 메모리의 위치를 주소 값으로 식별할 수 있습니다. 메모리의 위치를 식별하는 주소 값은 바이트 단위로 구분됩니다. 이 값은 0부터 시작하고 바이트 단위로 1씩 증가하므로 2바이트 이상의 크기를 갖는 변수는 여러 개의 주소 값에 걸쳐 할당됩니다.

 

주소 연산자(&)

 

이제 변수를 이름이 아닌 주소로 사용하는 방법을 살펴보겠습니다. 여기서 주소라 하면 변수가 할당된 메모리 공간의 시작 주소를 의미합니다. 시작 주소를 알면 그 위치부터 변수의 크기만큼 메모리를 사용할 수 있습니다. 주소는 주소 연산자(&)를 사용해서 구합니다.

#include <stdio.h>

int main(void)
{
    int a;
    double b;
    char c;

    printf("int형 변수의 주소: %u\n", &a);
    printf("double형 변수의 주소: %u\n", &b);
    printf("char형 변수의 주소: %u\n", &c);

    return 0;
}
메모리 주소는 보통 16진수로 표기합니다. 따라서 주소를 출력할 때는 전용 변환 문자인 %p를 사용하는 것이 좋습니다. %p는 주소값의 데이터 크기에 따라 자릿수를 맞춰 16진수 대문자로 출력합니다. 여기서는 설명의 편의를 위해 주소 값을 10진수로 출력하며, 주소는 음수가 없으므로 %u 변환 문자를 사용했습니다.

 

위 코드를 실제로 실행해보면 아래와 같은 결과가 나옵니다.

int형 변수의 주소: 94304196
double형 변수의 주소: 94304200
char형 변수의 주소: 94304192

 

주소 연산자를 사용해 변수에 할당된 메모리의 시작 주소를 확인하고 변수의 크기를 알아내면 변수가 메모리의 어디서부터 어디까지 할당되었는지 확인할 수 있습니다.

 

 

포인터와 간접 참조 연산자(*)

 

이제 변수에 할당된 메모리 주소를 활용하는 방법을 살펴보겠습니다.

메모리의 주소는 필요할 때마다 계속 주소 연산을 수행하는 것보다 한 번 구한 주소를 저장해서 사용하면 편리한데, 포인터가 바로 변수의 메모리 주소를 저장하는 변수입니다. 따라서 주소를 저장할 포인터도 변수처럼 선언하고 사용합니다.

 

포인터를 선언할 때는 변수 앞에 *만 붙여 주면 됩니다.

 

예제를 통해 포인터의 선언과 사용법을 알아보겠습니다.

#include <stdio.h>

int main(void)
{
    int a;		// 일반 변수 선언
    int *pa;		// 포인터 선언

    pa = &a;		// 포인터에 a의 주소 대입
    *pa = 10;		// 포인터로 변수 a에 10 대입

    printf("포인터로 a 값 출력 : %d\n", *pa);	// 10
    printf("변수명으로 a 값 출력 : %d\n", a);	// 10

    return 0;
}

 

만약 변수 a가 메모리 100번지부터 103번지까지 할당되었다면 주소 값 100이 pa에 저장됩니다.

이제 포인터 pa는 변수 a가 메모리 어디에 할당되었는지 그 위치를 기억하고 있습니다. 이렇게 포인터가 어떤 변수의 주소를 저장한 경우 '가리킨다'고 말하며 둘의 관계를 pa → a 처럼 화살표로 간단히 표현합니다.

 

포인터가 어떤 변수를 가리키면 포인터로 가리키는 변수를 사용할 수 있습니다. 즉, 포인터 pa로 변수 a를 사용할 수 있습니다.

포인터가 가리키는 변수를 사용할 때는 포인터에 특별한 연산자를 사용하는데, 이를 간접 참조 연산자(*) 또는 포인터 연산자라고 합니다.

 

*pa == a

즉, *pa와 a는 동일하게 사용됩니다. *pa는 변수 a의 쓰임과 마찬가지로 대입 연산자(=)의 왼쪽에 올 때는 pa가 가리키는 변수의 저장 공간으로 사용되고, 오른쪽에 올 때는 pa가 가리키는 변수 값으로 사용됩니다.

 

예를 들어 scanf 함수의 경우 두 번째 인자로 입력할 변수의 주소를 줘야 하는데 이때, 간접 참조 연산자를 사용해도 됩니다.

scanf("%d", &a);

scanf("%d", pa);

 

👉  포인터 정리하기

* 기호를 변수명 앞에 붙인 것과 자료형 뒤에 붙인 두 가지 표현법 모두 int 자료형을 가리키는 포인터 pa를 뜻합니다.

int *pa;
int* pa;

 

두 방식 모두 동일한 의미를 가지지만, 표현 방식에 따라 가독성에 차이가 있습니다.

int *pa, pb;
int* pa, pb;

첫 번째 방식은 pa만 포인터임이 명확하게 드러나지만, 두 번째 방식은 마치 pa와 pb 모두 포인터로 선언된 것처럼 보이기 쉬워 혼동을 줄 수 있습니다. 이러한 이유로, * 기호는 변수명 앞에 붙여 각 변수마다 포인터 여부가 명확히 드러나도록 작성하는 것이 권장됩니다.

 

 

const를 사용한 포인터

 

const 예약어를 포인터에 사용하면 이는 가리키는 변수의 값을 바꿀 수 없다는 으미로, 변수에 사용하는 것과는 다른 의미를 가집니다.

#include <stdio.h>

int main(void)
{
    int a = 10, b = 20;
    const int *pa = &a;

    printf("변수 a 값 : %d\n", *pa);	// 10
    pa = &b;
    printf("변수 b 값 : %d\n", *pa);	// 20
    pa = &a;
    a = 20;
    printf("변수 a 값 : %d\n", *pa);	// 10

    return 0;
}

위 예제를 보면 6행에서 포인터 pa를 선언할 때 const로 상수화했습니다. 만약 const가 일반 변수처럼 포인터 값을 고정시킨다면 9행에서 pa는 다른 변수의 주소를 저장할 수 없습니다. 그러나 출력 결과에서 pa는 const의 사용과는 무관하게 변수 b의 주소를 저장하고 그 값을 간접 참조해 출력하고 있습니다.

 

그렇다면 포인터에 사용된 const의 의미는 무엇일까요? 바로 pa가 가리키는 변수 a는 pa를 간접 참조해 바꿀 수 없다는 의미입니다.

a = 20;

*pa = 20;

위의 두 수식은 모두 a의 값을 변경하는 동작을 하지만, 포인터가 const로 선언된 경우에는 두 번째 수식처럼 간접 참조를 통해 값을 변경하고자 하면 에러 메시지가 출력됩니다.

 

const 포인터 사용 이유

 

포인터에 const를 사용하는 대표적인 예는 문자열 상수를 인수로 받는 함수입니다. 문자열 상수는 값이 바뀌면 안 되므로 함수의 매개변수를 통해서 값을 바꿀 수 없도록, 매개변수로 선언된 포인터에 const를 사용합니다.

void printMessage(const char *msg) {
    printf("%s\n", msg);
}

 

주소와 포인터의 차이

 

주소는 변수에 할당된 메모리 저장 공간의 시작 주소 값 자체고, 포인터는 그 값을 저장하는 또 다른 메모리 공간입니다. 따라서 특정 변수의 주소 값은 바뀌지 않지만, 포인터는 다른 주소를 대입해 그 값을 바꿀 수 있습니다.

 

예를 들어 다음 코드가 실행될 때 변수 a, b가 메모리에 할당된 상태가 그 아래 있는 그림과 같다고 생각해 보겠습니다.

int a, b;
int *p;
p = &a;
p = &b;

이때 변수 a의 주소는 100이고, b의 주소는 200으로 프로그램 실행 중에는 그 값이 바뀌지 않습니다.

그러나 포인터 p는 a, b중 어떤 주소를 대입하느냐에 따라 가리키는 변수가 바뀝니다.

즉, 한마디로 주소는 '상수'이고, 포인터는 '변수'라는 뜻입니다.

 

따라서 두 포인터가 같은 주소를 저장하는 일, 즉 하나의 변수를 동시에 가리키는 일도 가능합니다.

int a;
int *pa, *pb;
pa = pb = &a;

이 경우 a 값을 바꾸거나 연산하는 데 pa와 pb를 모두 사용할 수 있습니다.

 

주소와 포인터의 크기

 

포인터도 저장 공간이므로 그 크기가 있습니다. 포인터의 크기는 저장할 주소의 크기에 따라 결정되는데 크기가 클수록 더 넓은 범위의 메모리를 사용할 수 있습니다. 포인터의 크기는 컴파일러에 따라 다를 수 있으나 모든 주소와 포인터는 가리키는 자료형과 상관없이 그 크기가 같다는 것에는 변함이 없습니다.

#include <stdio.h>

int main(void)
{
    char ch;
    int in;
    double db;

    char *pc = &ch;
    int *pi = &in;
    double *pd = &db;

    printf("char형 변수의 주소 크기 : %d\n", sizeof(&ch));		// 8
    printf("int형 변수의 주소 크기 : %d\n", sizeof(&in));		// 8
    printf("double형 변수의 주소 크기 : %d\n", sizeof(&db));		// 8
	
    printf("char * 포인터의 크기 : %d\n", sizeof(pc));		// 8
    printf("int * 포인터의 크기 : %d\n", sizeof(pi));		// 8
    printf("double * 포인터의 크기 : %d\n", sizeof(pd));		// 8

    printf("char * 포인터가 가리키는 변수의 크기 : %d\n", sizeof(*pc));		// 1
    printf("int * 포인터가 가리키는 변수의 크기 : %d\n", sizeof(*pi));		// 4
    printf("double * 포인터가 가리키는 변수의 크기 : %d\n", sizeof(*pd));		// 8

    return 0;
}

포인터가 가리키는 메모리 주소를 표현하기 위해 64비트 시스템에서는 8바이트(64비트)의 공간이 사용되고, 32비트 시스템에서는 4바이트(32비트)의 공간이 사용됩니다.

 

포인터의 대입 규칙

 

포인터는 크기가 모두 같으므로 대입 연산을 쉽게 생각할 수 있습니다. 그러나 다음 규칙에 따라 제한적으로 사용해야 합니다.

  1. 포인터는 가리키는 변수의 형태가 같은 때만 대입해야 합니다.
  2. 형 변환을 사용한 포인터의 대입은 언제나 가능합니다.

 

첫 번째 규칙부터 예제로 확인해 보겠습니다.

#include <stdio.h>

int main(void)
{
    int a  = 10;
    int *p = &a;
    double *pd;

    pd = p;
    printf("%lf\n", *pd);

    return 0;
}

위 코드의 printf 함수에서 pd를 통해 간접 참조 연산을 수행하면 메모리 100번지부터 107번지까지 8바이트를 하나의 double형 변수로 생각하고 그 안에 있는 값을 실수 값으로 해석하므로 알 수 없는 결과를 출력합니다. 특히 104번지부터 107번지까지가 다른 변수에 할당되어 이미 사용되고 있는 경우 *pd를 대입 연산자 왼쪽에 사용하면 그 변수의 값이 바뀌게 됩니다. 따라서 가리키는 자료형이 일치하지 않는 포인터의 대입을 시도하면 컴파일러는 경고 메시지로 알려 줍니다.

 

 

이어서 두 번째 규칙도 살펴보겠습니다.

 

포인터가 가리키는 자료형이 다른 경우라도 형 변환 연산자를 사용하면 경고 메시지 없이 대입할 수 있습니다. 물론 대입한 후에 포인터를 통한 사용에 문제가 없어야 합니다.

#include <stdio.h>

int main(void)
{
    double a = 3.4;
    double *pd = &a;
    int *pi;
    
    pi = (int *)pd;
    printf("%d\n", *pi);

    return 0;
}

여기서 pi에 간접 참조 연산을 수행하면 변수 a의 일부를 int형 변수처럼 사용할 수 있습니다. 이런 사용 방법은 포인터로 메모리를 직접 쪼개 쓰는 것이므로 데이터가 메모리에 저장되는 방식을 충분히 이해하고 있어야 합니다. 만약 *pi = 10; 과 같이 a의 일부분에 정수를 저장하면 정수와 실수의 저장 방식이 다르므로 a에 저장한 실수 값은 사용할 수 없습니다.

두 번째 규칙의 경우 문법적으로 컴파일에 문제가 없기 때문에 형식상 가능하다는 것이고, 실제로는 올바르지 않거나 예측 불가능한 실행 결과가 나오게 됩니다.

 

 

+) 포인터에 주소를 직접 대입할 수 있을까요?

 

일반적으로 포인터에 값을 대입할 때는 주소 연산자를 사용하여 주입합니다. 그러면 포인터 pa에는 변수 a의 주소, 예를 들어 100번지가 주입됩니다.

int a = 10;
int *pa;
pa = &a;
*pa = 20;

 

 

그러면 다음과 같이 포인터에 주소를 직접 대입하면 어떻게 될까요?

int *p;
p = (int *) 100;
*p = 20;

이 경우 100번지부터 103번지까지 4바이트의 메모리 공간을 포인터 변수 p에 할당하게 됩니다. 이 문장들은 컴파일 과정에서는 전혀 문제될 것이 없습니다. 그러나 메모리 100번지부터 103번지까지가 어떤 용도로 사용되는 영역인지 알 수 없으므로 프로그램을 실행할 때 문제를 일으킬 가능성이 큽니다. 따라서 주소 연산자를 사용한 정상적으로 할당받은 메모리 공간의 주소를 저장해서 포인터를 사용해야 합니다.

 

 

같은 이유로 다음과 같이 포인터를 초기화 하지 않는 건 더 위험합니다. 이 경우 포인터에 간접 참조 연산을 수행하면 알 수 없는 곳으로 찾아가 데이터를 바꿉니다.

int *p;
*p = 10;

 

 

포인터를 사용하는 이유

 

변수를 사용하는 가장 쉬운 방법은 이름을 쓰는 것입니다. 포인터를 사용하려면 추가적인 변수 선언이 필요하고 주소 연산, 간접 참조 연산 등 각종 연산을 수행해야 합니다. 그러니 포인터를 일부러 즐겨 사용할 필요는 없습니다. 그러나 임베디드 프로그래밍을 할 때 메모리에 직접 접근하는 경우나 동적 할당한 메모리를 사용하는 경우에는 포인터가 반드시 필요합니다.

임베디드 프로그래밍(Embedded programing)이란 내장형 시스템을 제어하기 위한 프로그램으로, 오늘날 만들어지는 거의 모든 생활 기기에서 특정 기능을 제어하기 위해 구현됩니다. 예를 들어 정수기, 자동차, 냉장고, 스마트워치 등에도 각 기능을 담당하는 하드웨어가 있는데, 이 하드웨어를 제어하는 소프트웨어를 만드는 일을 임베디드 프로그래밍이라고 합니다.

 

그외에도 포인터의 필요성을 이해하기 위해 두 가지 예제 코드를 비교해 보겠습니다.

 

두 변수의 값을 바꾸는 함수 (값을 인수로)

 

#include <stdio.h>

void swap(int x, int y);

int main(void)
{
    int a = 10, b = 20;

    swap(a, b);
    printf("a:%d, b:%d\n", a, b);	// a: 10, b: 20

    return 0;
}

void swap(int x, int y)
{
    int temp;

    temp = x;
    x = y;
    y = temp;
}

위 예제의 경우 9행에서 함수를 호출할 때 main 함수의 변수 a, b의 값이 복사되어 15행의 매개변수 x, y에 저장됩니다. 결국 swap 함수 안에서는 a, b의 복사본을 바꾸므로 main 함수의 두 변수 a, b의 값은 변함이 없게 됩니다.

이해를 돕기 위해서 swap 함수의 매개변수 x, y가 메모리 주소를 갖는 것 처럼 표현했지만, 실제로는 속도 최적화를 위해 주로 레지스터에 저장됩니다.

 

물론, swap 함수에서 바꾼 값을 main 함수로 return 하는 방법도 있지만, 함수는 오직 하나의 값만을 반환할 수 있으므로 한 번의 함수 호출을 통해 두 변수의 값을 바꾸는 것은 불가능합니다.

 

 

두 변수의 값을 바꾸는 함수 (포인터를 인수로)

 

#include <stdio.h>

void swap(int *pa, int *pb);

int main(void)
{
    int a = 10, b = 20;

    swap(&a, &b);
    printf("a:%d, b:%d\n", a, b);	// a: 20, b: 10

    return 0;
}

void swap(int *pa, int *pb)
{
    int temp;

    temp = *pa;
    *pa = *pb;
    *pb = temp;
}

반면, 이 예제의 경우 9행에서 함수를 호출할 때 main 함수의 변수 a, b의 주소를 인수로 줍니다. 따라서 15행의 매개변수는 포인터로 선언되고, 함수가 호출되면 포인터 pa, pb는 main 함수의 변수 a, b의 주소를 저장하므로 각각 a와 b를 가리키게 됩니다.

결국 swap 함수는 포인터를 통해 main 함수의 변수 a, b를 공유하므로 두 변수를 직접 바꾸는 일이 가능해집니다.

'Lang > C' 카테고리의 다른 글

C (6) - 문자와 문자열  (2) 2025.08.18
C (5) - 배열과 포인터 ②  (2) 2025.08.15
C (3) - 제어문과 함수  (4) 2025.08.07
C (2) - 연산자  (3) 2025.08.03
C (1) - 상수와 변수  (1) 2025.07.08