열혈C - Chapter 14 포인터와 함수에 대한 이해

2024. 10. 9. 17:50Programming Language/C

14-1 함수의 인자로 배열 전달하기

인자의 전달에 대해서 생각해보자

예를 들어 

int num = 10;
fct(num); //임의의 함수 fct

라는 코드를 작성했다고 보자.

fct에 num을 전달했다고 보통 말하는데 사실 fct에 num이란 변수를 전달할 방법은 없다.

다만 인자로 전달할때 매개체가 되는 변수(매개변수)를 선언해서 num이 갖고 있는 값을 복사해서 넣어 전달하는 방법이지 num자체를 전달하는 것은 아니다.

그래서 사실 정확하게 표현하려면 fct함수를 호출 하면서 num이 갖고 있는 값을 전달한다 라고 말하는게 정확하다.

 

그래서 이렇게 우리는 변수에 저장된 값을 전달할 수 있다.

그러면 함수의 인자로 배열을 통째로 전달할 수 있을까?

위에서 말한 fct에 num을 넣으면 num에 있는 값이 fct의 매개변수에 복사되어 값이 전달 된것인데, 그러면 배열의 경우도 통째로 복사해서 매개변수로 넣어 값을 전달해주는 것이 아닐까 생각할 수 있을 것이다.

int arr[3];
fct(???);

 

그렇기에 arr이란 배열을 fct에 통째로 복사해서 넣기 위한 매개변수가 필요하게 될것이다.

 

그런데 배열을 가져다 통로 복사하는 방법은 사실 존재하지 않는다....

왜냐면 

int arr[3];
fct(arr);

으로 선언했다면, 

void fct (int ar[3]){. . . .}

처럼 받을 수 있을 것이라고 생각하지만 C언어에서는 매개변수에 배열을 선언하는 것을 허용하지 않는다.

 

매개변수로 배열을 선언할 수 없다면 배열을 통로 복사해서 값을 전달할 방법은 없다는 말이된다.

그러면 어떻게 fct에서 배열이 갖고 있는 값을 가져갈 수 있을까에 대해서 의문이 생긴다.

 

또 다시 하나의 주제에 대해서 생각해보자면, 먼저 하나의 코드를 보자.

int age = 10;
fct(age);
printf("%d", age);

와 같은 코드를 만들었다.

코드를 작성한 사람이 fct라는 함수가 끝나면 age라는 값이 1이 늘어나서 printf문에서 출력되길 바라면서 코드를 작성했다.

그런데 이렇게 fct함수에 age를 전달해서 age의 값을 1증가시키는게 가능할까?

 

앞에서 말했다 싶이 fct함수에 age를 전달하는건 age자체를 전달하는게 아니라 age의 값을 fct의 매개변수에 값을 복사해 전달하는 것이기 때문에 fct함수를 사용해 age의 값을 직접적으로 변경할 수는 없다.

 

그러면 어떤 방식을 사용해야 이게 가능할까?

이 방법은 사실 앞에서 우리는 사용했던 방식으로 해결을 할 수 있다.

int age;
scanf("%d", &age);

scanf에 age를 전달하고 사용자의 입력을 받으면 age의 값은 바뀌었었다.

그 이유는 age의 주소값을 전달하기 때문이다.

 

그러면 우리가 위에 사용한 코드를 한번 수정해보자.

우리는 저 코드에서 age를 전달하는게 아니라 age의 주소값을 전달하는 방법을 사용해보자.

int age = 10;
fct(&age);

그러면 이때 fct는 어떻게 선언이 되어야할까?

fct에 매개변수로 전달되는 값은 int형 변수의 주소값이 된다.

그렇다면 그 값을 저장하기 위한 매개변수는

void fct(int * ptr){. . . .}

int형 포인터 변수가 되어야할 것이다.

 

그러면 fct라는 함수에서 age라는 주소값을 갖고 있기에 ptr이라는 것을 사용해서 age의 값을 변경할 수 있게 된다.

void fct( int * ptr) {
	*ptr += 1;
}

이렇게 하면 

int age = 10;
fct(&age);
printf("%d", age);

의 결과는 11이 되게 될것이다.

 

전에 지역변수는 지역변수가 선언된 영역 내에서만 접근이 가능하다고 했는데 그 예외가 주소값을 이용하는 방법이다.

주소값을 이용한다면 그 지역 외에서도 접근이 가능하다.

 

그렇다면 위에서 말했던 의문인 어떻게 fct에서 배열이 갖고 있는 값을 가져갈 수 있을까에 대해서 의문에 대해서 생각해보자.

우리는 배열을 통로 전달할 수는 없지만 배열의 주소값을 전달할 수는 있고 그 주소값을 사용해서 배열을 직접 찾아가서 접근할 수 는 있다는 것이다.

 

이제 이 내용에 대해서 정리해보면서 복습해보자.

 

인자전달의 기본방식은 값의 복사이다

int SimpleFunc(int num) { . . . .}
int main(void){
    int age =17;
    SimpleFunc(age);
}

와 같이 코드를 작성 했다.

 

여기서 SimpleFunc(age);는 age를 전달하는게 아니라 age에 저장된 값을 전달하는 것이다.

그러면 age에 저장된 값이 매개변수의 num에 복사가 되어 전달된다.

 

배열을 함수의 매개변수에 전달하는 이유는 함수 내에서 배열에 저장된 값을 참조하기 위함이다.

그런데 배열을 통째로 전달하지 않아도 이런 일이 가능하다.

 

위 코드에서 보이는 바와 같이 배열을 함수의 인자로 전달하려면 배열을 통째로 복사할 수 있도록 배열이 매개변수로 선언되어야 한다. 

그러나 C언어는 매개변수로 배열의 선언을 허용하지 않는다.

결론적으로 배열을 통째로 복사하는 방법은 C언어에서 존재하지 않기에 불가능하다.

 

따라서 배열을 통째로 복사해서 전달하는 방식이 아닌 배열의 주소값을 전달하는 방법을 사용해야만 한다.

 

배열을 함수의 인자로 전달하는 방식

int arr[3] = {1, 2, 3};
int * ptr = arr;

배열의 이름은 int형 포인터이기에 int형 포인터 변수에 배열의 이름이 지니는 주소값을 저장할 수 있다.

 

위 예제를 통해서 코드의 구성이 

//배열의 이름 arr은 int형 포인터이므로 매개변수는 int형 포인터 변수이다.
void SimpleFunc(int * param){
    // 포인터 변수를 사용해서도 배열의 형태로 접근이 가능하다.
    printf("%d %d", param[0], param[1])
}

int main(void){
    int arr[3] = {1, 2, 3};
    SimpleFunc(arr); // 배열 이름 arr은 배열 arr[0]의 주소값을 갖고 있고 이 값을 전달
    . . . .
}

이렇게 될 것임을 유추할 수 있다.

 

배열을 함수의 인자로 전달하는 예제

의 실행결과는 

이 나온다.

그리고 

이 코드를 실행해보면 

이런 결과를 확인할 수 있다.

 

이 코드의 내용은 그냥 보고 다양한 사용 방법이 있음을 이해하면 될듯하다.

 

배열을 함수의 인자로 전달받는 함수의 또 다른 선언

여기서 예외적인 것에 대해서 말해보자면

void ShowArayElem(int * param, int len) {. . . .}
void AddArayElem(int * param, int len, int add) {. . . .}

과 같이 매개변수를 선언했는데 여기서 int * param의 경우는 int num의 주소값을 전달받을수도, int arr[3]의 주소값(첫번째 인자의 주소값)을 전달받을 수도 있기에 int * param을 매개변수로 넣어준다면 이 함수는 어떤것을 전달받을지 예측하기 어렵다는 것이다.

그렇기에 이 코드를 다른 방식으로 선언을 할 수 있는데 그 방법은 

void ShowArayElem(int param[], int len) {. . . .}
void AddArayElem(int param[], int len, int add) {. . . .}

과 같이 선언하는 방법이다.

 

이 선언을 보면 아니 전엔 배열을 매개변수에 선언이 불가능하다고 하지 않았는가?

 

근데 사실 저기 int param[]은 int * param과 완전히 동일한 선언이다.

그러면 이런 선언을 왜 정의해 뒀을까?

함수를 정의한 사람이 int param[]을 매개변수로 쓰는 부분에서는 배열의 주소값을 전달하라는 강한 의도를 표현하기 위함이다.

따라서 이걸 그냥 int param[]으로만 인식하면 지금 포인터와 배열과 매개변수의 선언에서 많은 혼란을 발생시킬수도 있다.

그렇기에 지금은 매개변수에 int param[]이라고 선언되어 있다면 이건 그냥 int * param이라고 인식하기를 추천한다.

 

결론적으로 배열을 인자로 전달받는 경우엔 int param[]이 더 의미있어 보이므로 주로 사용된다.

 

그러면

int arr[3] = {1, 2, 3};
int * ptr = arr;

이란 코드를 봤을때 int * ptr을 int ptr[]으로 대체할 수 있을것 같다는 생각을 할 수 있는데 이건 불가능하다.

 

매개변수에서만 int param[]이 int * param과 대체가 가능한 것이지 그 외의 범위에서는 절대 대체할 수 없다.

 

14-2 Call-by-value vs. Call-by-reference

간단하게 압축해서 설명하자면 Call-by-value는 함수에 값을 전달하는것 (ex. fct(10)), Call-by-reference는 함수에 주소값을 전달하는것(ex.fct(&num))을 말한다.

 

 값을 전달하는 형태의 함수 호출: Call-by-value

함수를 호출할 때 단순히 값을 전달하는 형태의 함수 호출을 Call-by-value라 하고, 메모리의 접근에 사용되는 주소값을 전달하는 형태의 함수 호출을 Call-by-reference라고 한다.

즉, 두 개의 구분은 함수의 인자로 전달되는 대상의 차이다.

void NoRetrunType (int num){. . . .}

Call-by-value함수의 경우는 함수 외부에 선언된 변수에 접근이 불가능하다.

 

void ShowArayElem(int * param, int len){. . . .}

Call-by-reference 함수의 경우는 함수 외부에 선언된 변수에 접근이 가능하다.

잘못 적용된 Call-by-value


main함수에 존재하는 num1과 num2의 값이 서로 바뀌길 기대하나
num1과 num2에 주소값이 아닌 그냥 값을 넣어줌으로써(Call-by-value)
그 값이 복사되어 n1와 n2에 들어가는 것이지 num1과 num2를 직접 전달하는 것이 아니기 때문에
실제 num1과 num2의 값이 변경되지는 않는다.

 

주소값을 전달하는 형태의 함수호출: Call-by-reference

이렇게 주소값을 갖고 접근해야지만 Swap함수 외부인 main함수에 선언되어 있는 num1과 num2에 접근해서 그 값을 Swap함수 내부에서 변경을 할 수 있다.

그 이유는 계속 말했듯이 전달된 변수의 주소값을 가지고 포인터를 통해서 변수의 값에 직접 접근하기 때문이다

 

scanf 함수호출 시 &연산자를 붙이는 이유는 무엇일까

int main(void){
    int num;
    scanf("%d", &num);
}

scanf함수 내에서 외부에 선언된 변수에 값을 넣어주기 위해서는 변수의 주소값을 전달받아서 값을 scanf함수 내부에서 넣어주어야만 한다. 

 

그런데 scanf에게 전달하는 값이 

int main(void){
    char str[30];
    scanf("%s", str);
}

과 같이 배열인 경우에는 배열의 이름 자체가 포인터의 기능을 하기 때문에 &연산자 말고 str만 넘겨주면 된다.

 

14-3 포인터 대상의 const 선언

이전에 const 선언에 대해서 설명을 했었다.

const int num = 20;
num = 30; // 에러 발생

const는 변수를 선언할때 맨 앞에 붙여주면 이 변수는 상수로 변경되어 값을 변경할 수 없게 만드는 명령어이다.

 

동일하게 const명령어가 포인터 변수를 대상으로도 사용이 가능하다

 

포인터 변수의 참조 대상에 대한 const 선언

int num = 20;
const int * ptr = #
*ptr = 30;   // 컴파일 에러!
num = 40;    // 컴파일 성공!

 

const를 포인터 변수에 적용하면 포인터 변수 ptr을 이용해서 ptr이 가리키는 변수에 저장된 값을 변경하는 것을 허용하지 않기위한 용도로 사용된다.

 

위에 있는 

const int * ptr = #

이라고 한다면 여기서 상수가 되는 것은 num이다.

그러나 num 자체가 상수가 되는게 아니라, ptr이 바라보는 관점에서 num이 상수가 된다는 것이다.

그렇기에 ptr을 이용해서 num의 값을 변경하는 것을 허용하지 않게 된다.

그러나 변수 num에 저장된 값 자체의 변경이 불가능한건 아니고 ptr을 통한 변경만 허가하지 않는 것이다.

그리고 변경만 불가능하고 참조하는 것은 가능하다는점..!

 

포인터 변수의 상수화

int num1 = 20;
int num2 = 30;
int * const ptr = &num1;
ptr = &num2;    // 컴파일 에러!
*ptr = 40;       // 컴파일 성공!

포인터 변수 ptr에 저장된 값을 상수화 하겠다는 의미로 ptr에 저장된 값은 변경이 불가능하다.

ptr이 가리키는 대상의 변경을 허용하지 않는다는 의미이다.

 

그니

int * const ptr = #

여기서 상수가 되는 값은 ptr이 되는 것이다.

그렇기에 ptr에 들어 있는 값을 변경하지 못하고 가리키는 대상이 고정되게 된다.

그리고 ptr이 가리키는 대상 자체가 상수가 된게 아니라서 가리키는 대상의 값을 ptr을 통해서 변경하는 것 또한 문제가 없다.

 

const 선언은 앞 뒤 한번에 하는것 또한 가능하다.

const int * ptr = #
int * const ptr = #

          ↓
          
const int * const ptr = #

이 경우에 ptr은 가리키는 대상을 바꾸지도 못하고 기리키는 대상의 값도 바꾸지 못하는 포인터 변수가 된다.

 

const 선언이 갖는 의미

const는 기능적으로 새로운 기능은 아니기에 잘 사용하지 않는 경우가 많은데 안정성에 매우 많은 도움을 주는 코드이다.

const를 사용하는 이유는 프로그램이 실행된 이후로 절대 값이 바뀌지 않을 값에 넣어주는데, 이렇게 작성해주면 어떤 이유로든 그 값이 변경되는 상황을 에러로 인식해서 코드의 문제점을 파악하기 쉽게 해준다.

여기서 PI는 절대 변경되지 않을 값인데, 중간에 어떤 이유로 잘못된 코드가 삽입되어 있다고 생각해보자.

이 경우에는 프로그램이 시작되고 종료될때 까지 원인을 찾을 수 없다.

더 과장해보자면 코드가 1억줄이 있고 저 코드 하나가 어디엔가에 작성되어 있다면 찾을수 있겠는가?

 

그럴때 이렇게 const를 선언해주면 

컴파일 과정에서 에러를 출력하면서 오류를 확인할 수 있게 해준다.

 

그렇기에 const 선언을 필요하다면 가급적으로 많이 써주려고 하는것이 좋다.