열혈 C - Chapter 19 함수 포인터와 void 포인터

2024. 10. 19. 18:07Programming Language/C

19-1 함수 포인터와 void 포인터

함수 포인터는 독특한 포인터이다.

그러나 기존에 Chapter 18을 잘 이해했다면 어렵진 않을 것이다

그런데 void포인터는 활용이 잘 되는 편인데, 함수 포인터는 기본서를 통해서는 접하기 힘들것이다.

추후에 다양하게 공부하면서 접하게 될것이나 우선 이해에 대해서만 설명해보겠다.

 

우선 이전에 포인터 변수의 접근에 대해서 생각해보자.

먼저

int num = 10;
int * ptr = #
*ptr = 20;

이라는 코드를 작성했을때 컴파일러는 해당 코드를 어떤 기준으로 컴파일을 할까

이전에 말했던 것 처럼 ptr이라는 것에는 주소값이 담겨 있고 그 외에 가리키고 있는 값의 타입에 대한 정보를 담고 있고 그걸 가지고 컴파일을 하게 된다.

 

더 세부적으로는 0x1234라는 숫자는 ptr에 담겨 있을때 우리가 주소값이라는 말을 하지만 사실 이건 컴파일러 입장에서 딱 이 숫자만 봤을때 이 값이 주소값인지, 그냥 정수인지에 대한 정보를 알 수 가 없다.

그렇기 때문에 *로 연산을 하지 못하기에 값에 접근할 수가 없는 것이고, 그렇기 때문에 포인터를 사용해서 해당 값이 주소값임을 인지시켜주고 그 값의 타입도 전달을 해주는 것이다.

 

내용을 전환해서 

int fct(int num){...};

이라는 함수를 선언 했다면 이 함수는 컴파일 시에 메인 메모리에 올라가게 되고 함수의 이름인 fct는 메인 메모리에 올라가 있는 함수의 몸체에 대한 정보를 갖고 있는 상수가 된다.

예를 들어 fct 함수가 메인 메모리의 0x3005라는 메모리 공간에 저장되어 있다고 생각해보자.

그리고 이걸 메인 함수에서 

int main(void){
	fct(3);
}

으로 함수를 호출 했을때 fct는 0x3005라는 주소값임을 알리고 그 장소에 있는 함수에 3을 인자값으로 전달하라고 컴파일러 한테 알려준다.

 

그러면 위에서 말했다 싶이 함수의 이름이 주소값을 저장해서 접근이 가능한 형태라면 fct가 갖고 있는 0x3005라는 값이 정수인지 주소값인지 컴파일러가 확인할텐데 이게 주소값으로 컴파일러가 인식하고 그 주소값에 있는 장소로 접근한다는 의미는 이게 포인터의 형태를 갖고 있고 타입을 갖고 있음을 시사하는게 아닐까?

 

맞다.

사실 함수의 이름인 fct은 단순이 주소값만 가진게 아닌 타입의 정보를 갖고 있는 상수 형태의 포인터이다

 

그럼 fct라는 함수이름은 어떤 타입 정보를 갖고 있을까

우선 매개변수의 정보를 필요로 한다.

그래야 인자로 전달되는 값이 맞는지 틀린지를 판단해줄 것이다.

그리고 반환형 정보에 대해서도 필요로 한다

int num = fct(3);

과 같은 형태로 함수의 이름이 사용된다면 컴파일러는 fct가 반환하는 값이 있는지도 확인을 해서 반환형이 존재하는지 아닌지, 또한 타입이 틀린지 맞은지에 대해서 알 수 있을 것이다.

 

그래서 fct의 타입 정보가 무엇이냐.

그러면 그냥 무슨 포인터형! 이럴 필요 없이 그냥 반환형은 int이고 매개변수로 int 값을 하나 전달받는다 라고만 이야기 하면된다.

이게 그냥 타입정보가 되는 것이다.

 

함수 포인터의 이해

1. 함수 포인터 (상수)

  • 함수의 이름은 함수가 저장된 메모리 공간을 가리키는 포인터이다(함수 포인터)
  • 함수의 이름이 의미하는 주소값은 함수 포인터 변수를 선언해서 저장할 수 있어야 한다
  • 함수 포인터 변수를 선언하려면 함수 포인터의 형(type)을 알아야 한다.

2. 함수 포인터의 형(type)

  • 함수 포인터의 형 정보에는 반환형과 매개변수 선언(갯수와 자료형)에 대한 정보를 담기로 약속
  • 즉, 함수의 반환형과 매개변수 선언이 동일한 두 함수의 함수 포인터 형은 일치한다.

3. 함수 포인터 형 결정

int SimpleFunc(int num) => 반환형 int, 매개변수가 int형 1개

double ComplexFunc(double num1, double num2) => 반환형 double, 매개변수가 double형 2개

 

적절한 함수 포인터 변수의 선언 

int fct (int num){....}

이라는 함수를 선언했을 때 이 함수의 포인터 변수를 선언하는 방법은 아래와 같다.

int (*fptr) (int)  // 함수 포인터 변수를 선언하는 방법

(*fptr) => fptr은 포인터라는 정보
int => 반환형이 int인 함수 포인터라는 정보
(int) => 매개변수 선언이 int 하나인 함수 포인터 라는 정보

 

이렇게 fct에 타입에 맞는 함수 포인터 변수를 선언하면 fptr에 fct를 저장할 수 있게 된다.

 

int SoSimple(int num1, int num2) {. . . .};
int (*fptr) (int, int); // SoSimple 함수 이름과 동일한 형의 변수를 선언
fptr = SoSimple; // 상수의 값을 변수에 저장
fptr(3, 4); //SoSimple(3, 4)와 동일한 결과를 보여줌
// 함수 포인터 변수에 저장된 값을 통해서 함수 호출이 가능하다는 것을 알 수 있음..!

위와 같이 함수의 타입과 동일한 형태로 함수 포인터 변수를 생성하고 그 안에 함수의 이름의 데이터를 담는다면 포인터를 사용해서 함수를 호출하는 것이 가능해진다.

fptr과 SoSimple은 변수이냐 상수이냐의 차이점 밖에 없다는 것이다...!

 

함수 포인터 변수 관련 예제

해당 코드를 보면 함수 포인터 변수를 어떻게 만드는지, 또 어떻게 사용하는지에 대한 내용이 나온다.

 

형(Type)이 존재하지 않는 void 포인터

어떤 주소값도 저장이 가능한 void형 포인터

int num = 20;
void * ptr = # // 어떤 타입이던지 주소값 저장이 가능
*ptr = 20;         // 컴파일에러, 타입정보가 없어 *로 값에 접근 불가능
ptr++              // 컴파일에러, 타입정보가 없어 어떤 연산도 불가능

형 정보가 존재하지 않는 포인터 변수이기 때문에 어떠한 주소 값도 저장이 가능하다.

형 정보가 존재하지 않기 때문에 메모리 접근을 위한 * 연산은 불가능하다.

 

** 그럼 왜..? 쓰는거지 여태까지 포인터는 접근하려고 하는 값에 대한 타입정보가 존재하기 때문에 사용한다고 했었는데 이제는 void포인터이고 어떤 타입이던 담고 타입이 없으니까 * 연산으로 접근이 불가능하다고..? 그럼 그냥 int 변수 사용하면 되는거 아닌가..? 

 

이걸 포인터라고 칭할지 말지가 중요한게 아니라 그냥 포인터라고 약속을 했기 때문에 그냥 포인터이라고 한다.

그리고 사실 void는 타입에 대한 정보가 void가 아니라 그냥 타입에 대한 정보가 없습니다라는 선언이다.

 

나중에 메모리의 동적할당을 공부하면서 void형 포인터가 존재해야하는 이유에 대해서 그때 알게 된다.

그러니까 이 활용에 대해서는 일단 생각하지 말고 알아두도록 하자.

 

그냥 임시로 주소값을 저장하는 상자라고 생각하자.

 

어떠한 에러도 발생하지 않고 

문제 없이 실행된다.

 

나중에 malloc과 free라는 기능을 배울때 사용하게 될것이니까 꼭 기억해두자.

19-2 main 함수로의 인자전달

main함수로도 인자를 전달할 수 있다.

그런데 main함수는 직접 호출하는게 아니라 자동으로 호출 되는 함수이기 때문에 인자를 직접 전달하는게 아니라 간접적인 형태를 통해서 전달한다.

여기서 말하는 간접적인 형태의 전달이란, 인자를 형성할 수 있는 정보를 제공해주는 것이다.

이 정보를 전달하면 운영체제가 그 정보를 참고해서 main함수를 호출할 때 전달할 인자를 직접 구성해서 전달한다.

int main(int argc, char *argv[]){
	int i = 0;
    printf("전달된 문자열의 수: %d \n", argc);
    
    for(i= 0; i < argc; i++){
    	printf("%d번째 문자열: %s \n", i + 1, argv[i] );
    }
    
    return 0;
}

라고 코드가 작성된 코드를 보면 원래 main함수에는 void만 전달 했는데 이번엔 argc와 argv라는 변수를 매개변수로 선언했다.

사실 선언할 수 있는 매개변수는 void아니면 위에 있는 저 내용 밖에 없다.

 

이걸 좀 보면 int형 인자를 하나 받고 두번째는 char 형 포인터 변수를 하나 선언 했다.

 

이 메인 함수를 출력하는 방법은 메인 함수가 저장되어 있는 폴더 내부에서 해당 프로그램을 출력하면 된다.

더보기

Visual Studio에서 CMD를 통해서 직접 프로그램을 실행시키는 방법

 

C언어를 처음하다 보니까 어떻게 사용해야하는지 조금씩 찾아가면서 해야한다..

일단 전체적인 내용을 요약해보자면 C언어의 경우는 빌드를 통해서 exe파일을 생성하고 이걸 통해서 CMD로 프로그램을 실행시킬 수 있는것 같다.

 

Visual Studio에서 C프로그램 빌드하기

빌드를 하면 그 결과가 하단에 출력된다.

이렇게 빌드가 성공했다면 잘 보면 해당 프로그램이 어디에 생성되어 있는지 정보가 남아 있다.

 

프로젝트 > x64 > > Debug > 프로젝트명.exe로 생성되었을 것이다.

 

x64는 프로젝트명 내부의 프로젝트 명으로 된 폴더가 하나 더 있는데 그안에 동일한 경로가 있으니 헷갈리지 말자.

(왜냐면 내가 했갈렸거든...)

폴더 오른쪽 클릭해서 

터미널에서 열기 선택하고 

프로그램 명을 입력하면 

잘 실행된다.

 

** 여기서도 저렇게 터미널로 열먼 PowerShell로 실행돼서 걍 helloC로는 안먹고 ./helloC와 같이 경로를 붙여줘야한다...CMD에서는

이렇게 그냥 잘된다;

 

근데 출력 할때 그냥 출력하는게 아니라 파일 명과 함께 문자열을 전달해주면 

이런 식으로 출력된다.

그렇다는 말은 helloC I Love U라는 정보가 main함수로 전달되었다는 것을 알 수 있다

 

그럼 우리가 입력한 그 내용이 어떻게 main 함수의 인자로 전달되어었는지를 알아야하는데  그전에 argv의 포인터형, 타입이 뭔지를 알아보자.

 

char * argv[]

argv의 타입, 포인터형은 char형 더블 포인터이다.

 

void SimpleFunc(TYPE * arr) {....}

void SimpleFunc(TYPE arr[]) {....}

전에 파라미터로 배열을 받기 위해서는 매개변수의 타입을 포인터 형으로 선언해야한다고 했었고 그걸 매개변수 한정으로 배열의 형태로 수정해도 동일하다고 했었다.

그리고 이렇게 변경이 가능한 이유는 우리가 받는 매개변수가 배열임을 확실하게 알리기 위해서라고 했었다.

 

그렇다면 파라미터로 char * argv[]의 경우는 

int main(int argc, char * argv[]){....}

int main(int argc, char ** argv){....}

 

와 동일한것을 알 수 있고 그렇기 때문에 argv의 포인터형은 char형 더블 포인터 변수인것이다.

이것도 전과 동일하게 매개변수의 경우에서만 허용한다.

그럼 동일하게 여기서도 이렇게 변경이 가능한 이유는 뭔가를 강하게 어필하려고 함일 것인데 그게 과연 무엇일까 

 

더블 포인터 변수에 담을 수 있는 타입은 싱글 포인터 와 포인터 배열의 이름이다.

char ** dptr;
char * arr[4];
char *ptr;

// 더블 포인터에는 포인터의 주소값을 담을 수 있다
dptr = &ptr;

// 더블 포인터에는 배열의 이름을 담을 수 있다.
dptr = arr

 

그래서 결국 char * arr[]으로 받는 값은 1차원 포인터 배열을 전달받기 위함임을 강하게 어필하고 있다는 것이다.

 

char * argv[]관련 예제 

 

이걸 통해서 기억해야할 점은  

void Func(char * arr[]);

와 같은 형태로 함수가 선언된다면 이 함수의 매개변수에 있는 char * arr[]가 char ** arr로 변경할 수 있다는 것을 알아야하고 전달 받고자 하는게 char형 포인터 배열의 이름임을 알 수 있어야 한다.

 

인자의 형성 과정

c:\>helloC I Love U

와 같이 명령어를 입력해서 실행되면 이를 OS가 실행시켜주기 때문에 전 명령어를 전부 가져간다.

그리고 그 모든 명령어를 공백을 기준으로 다 문자열화 시켜버린다.

문자열 1 ------- "helloC"
문자열 2 ------- "I"
문자열 3 ------- "Love"
문자열 4 ------- "U"

그럼 이 문자열이 메모리 공간에 4개의 문자열을 저장하고 이걸 기억하기 위해 내부적으로 배열을 만들어서 주소값을 저장해둔다.

//가상의 배열을 설정(strArr => char * str[5])

strArr[0] ====> "helloC\0"
strArr[1] ====> "I\0"
strArr[2] ====> "Love\0"
strArr[3] ====> "U\0"
strArr[4] NULL

여기서 널문자를 마지막에 저장함으로써 배열의 끝임을 알려준다.

그러면서 

int main(int argc, char * argv[]){...}

에서 문자열 strArr이 argv에 전달이 되고 그 문자열의 숫자인 4가 argc에 전달되는 것이다.

 

그래서 argv를 통해서 저 문자열들에게 전달이 가능하고 그 문자열이 몆개인지를 argc를 통해서 알 수 있게 되는 것이다.

 

그래서 

int main(int argc, char * agrv[]){
	int i = 0;
    printf("전달된 문자열의 수 : %d \n", argc);
    
    while(argv[i] != NULL){
    	printf("%d번째 문자열 : %s \n", i+1, argv[i]);
        i++;
    }
    
    return 0;
}

를 통해서 알 수 있는 점은 문자열의 마지막에 NULL이 저장된다는 것을 알 수 있다.

 

그런데 마지막으로 공백으로 그 문자열을 나눴기 때문에 

c:\>helloCNULL "I Love U"

와 같이 전달 한다면 문자열의 구분을 

문자열 1 ------- "helloCNULL"
문자열 2 ------- ""I Love U""

로 인식하게 될 것이다.