열혈 C++ - Chapter 02. C언어 기반의 C++ 2

2024. 11. 19. 00:39Programming Language/C++

02-1. Chapter 02의 시작에 앞서

C언어의 복습을 유도하는 확인학습 문제

#[문제 1] 키워드 const의 의미

키워드 const는 어떤 의미를 갖는가? 다음 문장들을 대상으로 이를 설명해보자.

  • const int num = 10;   ===> num을 상수화 / num은 10에서 변경할 수 없음
  • const int * ptr1 = &val1; ===> ptr이 가리키는 val1이 const int 타입임 / ptr1을 이용해서는 val1의 값을 변경할 수 없음, 그러나 ptr1은 다른 주소값으로 변경하는게 가능함
  • int * const ptr2 = &val2; ===> ptr2를 상수화 / ptr2의 값은 변경할 수 없이 val2를 항상보고있어야함, ptr2를 통해서 val2의 값을 변경하는건 문제 없음
  • const int * const ptr3 = &val3; ===> ptr3가 가리키는 걸 변경하는것도 val3라는 변수를 ptr3를 사용해서 변경하는것도 제한함

#[문제 2]실행중인 프로그램의 메모리 공간

실행중인 프로그램은 운영체제로 부터 메모리 공간을 할당 받는데, 이는 크게 데이터, 스텍, 힙 영역으로 나뉜다.

각각의 영역에는 어떤 형태의 변수가 할당되는지 설명해보자. 

특히 C언어의 malloc과 free함수와 관련해서도 설명해보자.

 

우선 사실 네개의 공간으로 나뉘고 첫번째 코드 공간에는 코드가 쌓인다.

그리고 이제 데이터 공간에는 전역 변수가 쌓인다. 이 전역변수는 프로그램이 종료될때 소멸된다.

스텍 공간에는 지역변수들이 쌓인다. 스택공간에 쌓인 지역 변수는 함수가 종료될때 소멸된다. 물론 프로그램이 종료될때도 소멸된다.

힙 영역에는 malloc으로 생성한 동적 메모리 공간을 할당한 변수들이 쌓인다. 이렇게 생성한 변수는 free함수를 사용해서 소멸이 가능하다. 물론 동일하게 프로그램이 종료될때도 소멸된다.

 

#[문제 3]Call-by-value vs Call-by-reference

함수의 호출형태는 크게 "값에 의한 호출(Call-by-value)"와 "참조에 의한 호출(Call-by-reference)"로 나뉜다.

이 둘을 나누는 기준이 뭔지, 두 int형 변수의 값을 교환하는 Swap 함수를 예로 들어가면서 설명해보자.

void SwapByVal(int num1, int num2){
	int temp;
    temp = num1;
    num1 = num2;
    num2 = temp;
    //Call-by-value -> 해당 값이 외부에서 전달된 num1과 num2에 직접적인 변경을 만들지 않음
    // 그렇기에 이렇게 로직을 구현해도 값이 변경된게 반영되지 않음
}

void SwapByRef(int* ptr1, int* ptr2){
	int temp;
    temp = *ptr1;
    *ptr1 = *ptr2;
    *ptr2 = temp;
    //Call-by-reference -> 변경된 값이 외부에서 전달된 ptr1과 ptr2가 가리키는 값에 반영됨
}

 

우선 Call-by-reference는 주소값을  전달하기에 매개변수가 포인터의 형태로 구성되고, 전달된 포인터의 값을 함수 내에서 변경하면 외부에 전달된 포인터가 가리키는 값이 변경되게 됨.

반면에 Call-by-value는 값만 전달(사실상 복사해서 그 값만 전달)하는 것이기 때문에 외부에 실제 그 변수의 값이 변경되지는 않음.

 

02-2. 새로운 자료형 bool

'참'을 의미하는 true와 '거짓'을 의미하는 false

true는 '참'을 의미하는 1바이트 데이터이고, false는 '거짓'을 의미하는 1바이트 데이터이다.

이 둘은 정수가 와야할 위치에 놓이면 각각 1과 0으로 변환되나 이 값이 1과 0은 아니다.

      • int num1 = true;                 =>        // num1에는 1이 저장된다
      • int num2 = false;               =>        // num2에는 0이 저장된다
      • int num3 = true + false;    =>        // num3에는 1 + 0의 결과인 1이 저장된다

이 결과를 보면 이걸 확인할 수 있는데 

보면 true와 false가 정수일때는 각각 1과 0으로 출력 되나 크기를 보면 1의 크기와 true의 크기가 동일하지 않고 fasle 또한 0과 동일하지 않음을 알 수 있다.

 

자료형 bool

  • true와 false는 bool형 데이터이다.
  • true와 false 정보를 저장할 수 있는 변수는 bool형 변수이다.

bool형 자료형의 선언 방법은 다른 변수와 동일하지만 타입을 bool로 작성해주면 된다.

bool isTrue = true;
bool isFalse = false;

 

bool을 사용한 코드는 

와 같이 사용할 수 있다.

02-3. 참조자(Reference)의 이해

참조자(Reference)의 이해

참조자는 기존에 선언된 변수에 붙이는 "별칭"으로 참조자가 만들어지면 이는 변수의 이름과 동일하게 취급된다.

int num1 = 2010;
// 변수의 선언으로 인해 num1이라는 이름으로 메모리 공간이 할당됨

int &num2 = num1;
// 참조자의 선언으로 인해 num1이 메모리공간에 num2라는 이름이 추가도 붙게됨

 

이를 사용한 예제를 보자면 

로 보면 

참조자로 접근해서 변경한 300이 num1을 출력할때도 동일하게 확인할 수 있고

num1과 num2가 가진 주소값도 이예 동일함을 알 수 있다.

 

그리고 참조자의 수에는 제한이 없고 참조자를 대상으로 참조자를 선언하는 것도 가능하다.

int num1 = 300;
int &num2 = num1;
int &num3 = num2;
int &num4 = num3;

 

참조자의 선언 가능 범위

참조자는 선언과 동시에 어떤 무엇인가를 참조해야만 한다.

그 참조 대상은 기본적으로는 변수가 되고 참조자는 참조의 대상을 변경할 수 없다.

  • int & ref = 30;           (X)       ------>    상수를 대상으로 참조자 선언은 불가능하다.
  • int & ref;                   (X)       ------>    참조자는 생성과 동시에 누군가를 참조해야만 한다
  • int & ref = NULL;     (X)       ------>    참조자는 포인터 처럼 NULL로 초기화하는 것도 불가능하다

참조자는 변수의 성향을 지니는 대상이면 선언이 가능하기에 배열의 요소 또한 참조자 선언이 가능하다.

 

 

포인터 변수 대상의 참조자 선언

ptr과 dptr 역시 변수이다.

다만 주소값을 저장하는 포인터 변수일 뿐이다.

따라서 참조자의 선언이 가능하다.

더보기

참조자의 선언

 

데이터타입 & 참조자이름 = 원본변수이름;

 

#int 포인터 타입 변수에 대한 참조자 선언

int * &pref = val; // == int* (&pref) = val;

 

#int 더블 포인터 타입 변수에 대한 참조자 선언

int ** &pref = val; // == int** (&pref) = val;

 

이런 방식으로 참조자를 선언한다.

()를 사용하나 안하나 비슷하나 좀 더 명확하게 표현할 수 있다고 함.

 

02-4. 참조자(Reference)와 함수

Call-by-value & Call-by-reference

void SwapByValue (int num1, int num2){
    int temp = num1;
    num1 = num2;
    num2 = temp;
}

이렇게 값을 전달하면서 호출하게 되는 함수를 Call-by-value 함수라고 부르고 이 경우 함수는 전달된 전달인자의 값이 저장되어있는 번수에는 접근이 불가능하다

void SwapByRef(int * ptr1, int * ptr2) {
    int temp = *ptr1;
    *ptr1 = *ptr2;
    *ptr2 = temp;
}

이렇게 주소값을 전달하면서 호출하는 함수를 Call-by-reference 함수라고 부르고 이 경우는 인자로 전달된 주소의 메모리 공간에 접근이 가능하다.

 

Call-by-address? Call-by-reference!

포인터 ptr에 전달된 주소 값의 관점에서 보면 

int * SimpleFunc(int * ptr){
    return ptr + 1;
}

이건 Call-by-value 이다.

 

그러나 

int * SimpleFunc (int * ptr){
    if(ptr == NULL)
        return NULL;
    *ptr = 20;
    return ptr;
}

이렇게 주소값을 전달 받아서 외부에 있는 메모리 공간에 접근을 했을때는 Call-by-reference이다.

 

C++에서는 두 가지 형태의 Call-by-reference가 존재한다.

하나는 주소 값을 이용하는 형태이고, 다른 하나는 참조자를 이용하는 형태이다.

 

참조자를 이용한 Call-by-reference

매개변수는 함수가 호출될 때 선언이 되는 변수이기에 함수 호출 과정에서 선언과 동시에 전달되는 대상으로 초기화 된다.

그렇기에 매개변수에 선언된 참조자는 선언과 동시에 초기화 된다고 볼 수 있다.

 

그렇기에 

void SwapByRef2 (int &ref1, int &ref2){
    int temp = ref1;
    ref1 = ref2;
    ref2 = temp;
}

와 같이 함수의 매개변수를 참조자로 설정해서 정의할 수가 있고 이게 바로 참조자를 이용한 Call-by-reference함수가 된다.

 

const 참조자

// 함수의 호출 형태
int num 24;
HappyFunc(num);

// 함수의 정의 형태
void HappyFunc(int &ref) {. . . .}

 

함수의 정의형태와 함수의 호출형태를 봐도 값의 변경 유무를 확인할 수 가 없다.

확인하기 위해서는 함수의 몸체부분을 확인해봐야만 한다.

 

그런데 함수를

void HappyFunc( const int *ref) {. . . .}

와 같이 정의한다면 함수의 내에서 전달 되는 인자의 값을 어용하지 않겠다는 것을 바로 알 수 있다.

 

이렇게 함수 내에서 참조자를 통한 값의 변경을 진행하지 않을 경우 참조자를 const로 선언해서 함수의 원형선언만 봐도 값의 변경이 일어나지 않는다는 것을 알 수 있게 하고 실수로라도 값의 변경이 일어나지 않도록 할 수 있다.

 

반환형이 참조이고 반환도 참조로 받는 경우

반환형이 참조이고 반환도 참조로 받는 경우를 보자면 

int& RefRetFuncOne(int& ref){
    ref++;
    return ref;
}

int main(void){
    int num1 = 1;
    int& num2 = RefRetFuncOne(num1);
    num1++;
    num2++;
    . . . .
}

 

num1이 바라보던 값인 1을 RefRetFuncOne을 선언하는 순간 ref가 같이 바라보게 되고 ref를 통해 값을 2로 변경한 후에 return 되면서 ref는 사라지고 num2가 이제 num1과 ref가 가리키던 값을 바라보게 된다.

 

반환형은 참조형이나 반환은 변수로 받는 경우

int& RefRetFuncOne(int& ref){
    ref++;
    return ref;
}

int main(void){
    int num1 = 1;
    int num2 = RefRetFuncOne(num1);
    num1+=5;
    num2+=100;
    . . . .
}

이때는 최종적으로 num1과 num2가 같은 값을 보는게 아니라 num1과 num2는 다른 값을(메모리 공간을) 바라보게 된다.

그래서 num1과 num2를 출력해보면 num1은 1+1+5의 값인 7이 출력되고 num2의 경우는 1+1+100의 102가 출력된다.

 

참조를 대상으로 값을 반환하는 경우

반환값이 참조형이 아니라면 참조자를 반환하던 변수를 반환하던 동일하게 값을 반환하게 된다.

//int & RefRetFuncOne(int& num)으로 반환형이 참조자인 경우
int num2 = RefRetFuncOne(num1);   // 가능
int& num3 = RefRetFuncOne(num1);  // 가능

반환형이 참조형인 경우에는 반환되는 대상을 참조자로 그리고 변수로도 받을 수 있다.

 

//int RefRetFuncOne(int &num1)으로 반환형이 값의 형태인 경우
int num2 = RefRetFuncOne(num1);   // 가능
int& num3 = RefRetFuncOne(num1);  // 불가능!

그러나 반환형이 값의 형태라면 참조자로는 그 값을 받을 수 없다.

 

잘못된 참조의 반환

int& ReturnRefFunc(int n){
    int num = 20;
    num += n;
    return num;
}

이렇게 지역변수를 참조의 형태로 반환하는 것은 문제가 될 수 있는게 

만약 함수를 호출할때 

int &ref = ReturnRefFunc(10);

이렇게 전달했을때 지역변수인 num의 참조를 반환하지만  return하면서 num이 소멸되기 때문에 ref가 바라볼 대상이 사라진다.

그렇기에 유효하지 않은 주소값을 참조하는 댕글링 참조를 생성하게 된다.

 

*댕글링 참조(Dangling Reference) - 더 이상 유효하지 않은 메모리 위치를 가리키는 참조를 말함

 

그렇기에 이런 형태로 함수를 정의하면 안된다.

 

const 참조자의 또 다른 특징

const를 통해서 선언된 변수는 참조자로 선언할때도 const를 선언해줘야만 한다.

const int num = 20;

int &ref = num; 
//    ↑  이 부분이 에러의 원인이 될 수 있다.
// 이는 ref를 통한 num의 값의 변경을 허용한다는 의미기 때문에 
// num을 const로 선언한 이유를 잃게 만든다.

ref+=10;
cout << num << endl;

 

당연하게도 const를 사용한 변수는 변경하지 않을 것이기 때문에 참조자를 통해서도 변경되지 말아야 하기 때문이다.

그렇기에 

const int num = 20;
const int &ref = num;
const int &ref = 50;

과 같이 선언해줘야만 한다.

 

여기서 특이점은 const 참조자의 경우는 상수를 참조할 수 있다는 것이다.

 

아무튼 이렇게 한번 const 선언이 들어가기 시작하면 관련한 변수들도 const로 선언해야하는 경우가 있는데 이는 프로그램의 안정성을 높여주기에 const를 사용하는 것은 좋은 습관이 될 수 있다.


어떻게 참조자가 상수를 참조하냐고?

위에서 봤듯이 

const int &ref = 30;

 

const 참조자는 상수를 참조할 수 있다.

그 이유는 상수를 const 참조자로 참조할 경우 상수를 메모리 공간에 임시적으로 저장하기 때문이다.

그렇기에 행이 진행되더라도 상수를 바로 소멸시키지 않는다.

 

이렇게 const 참조자가 상수를 참조할 수 있게 만든 이유는

int Adder (const int &num1, const int &num2){
    return num1 + num2;
}

매개변수가 const 참조형인 경우에 상수를 전달할 수 있도록 하기 위함이다.

매개변수가 const 참조형이 아닌 경우에는 함수에 전달인자로 상수를 전달할 수가 없다.

02-5. malloc & free를 대신하는 new & delete

new & delete

  • int 형 변수의 할당                                        int * ptr1 = new int;
  • double형 변수의 할당                                 double * ptr2 = new double;
  • 길이가 3인 int형 배열의 할당                      int * arr1 = new int[3];
  • 길이가 7인 double형 배열의 할당               double * arr2 = new double[7];

malloc을 대한하는 메모리의 동적 할당 방법은 new 키워드 이다.

크기를 바이트 단위로 계산하는 일을 거치지 않아도 된다는 장점이 있다.

 

이렇게 new를 통해서 할당한 변수는 delete를 통해서 소멸시켜줘야만 한다.

 

  • int형 변수의 소멸                                       delete ptr1;
  • doube형 변수의 소멸                                 delete ptr2;
  • int형 배열의 소멸                                       delete []arr1;
  • double형 배열의 소멸                                delete []arr2;

free함수를 대신하는 메모리의 해제 방법이다.

 

new 연산자로 할당된 메모리 공간은 delete함수를 통해서 소멸해줘야한다. 

곧 보게 될 객체의 생성 및 소멸 과정에서 호출하는 new & delete 연산자의 연산의 연산 특정은 malloc & free와는 큰 차이가 있다.

 

malloc & free와 같은 이유로 서로 페어링이 되나, 특징은 다르다고 이해하자.

 

포인터를 사용하지 않고 힙 영역에 접근하기

C언어의 경우 힘 영역에 접근하기 위해서는 무조건 포인터를 사용해야 했으나 C++에서는 참조자를 이용해서 접근도 가능하다.

int *ptr = new int;
int &ref = *ptr;       // 힙 영역에 할당된 변수에 대한 참조자 선언
ref = 20;
cout << *ptr << endl;  // 출력 결과는 20이 출력됨

이렇게 변수 성향을 지니는(값의 변경이 가능한) 대상은 참조자의 선언이 가능하기에 포인터의 값(*연산을 통해 나온 결과)에 참조자의 선언이 가능하다.

그리고 이 참조자를 통해서 값의 변경도 가능하다.

 

02-6. C++에서 C언어의 표준함수 호출하기

#include <stdio.h>     →    #include <cstdio>
#include <stdlib.h>    →    #include <cstdlib>
#include <math.h>      →    #include <cmath>
#include <string.h>    →    #include <cstring>

이렇게 C언어에 대응하는 C++ 헤더의 이름의 정의에는 일정한 규칙이 적용되어 있다.

 

int abs(int num); // 표준 C의 abs 함수

↕

//대응 되는 C++의 표준 abs 함수
long abs(long num);
float abs(float num);
double abs(double num);
long double abs(long double num);

이렇게 표준 C에 대응하는 표준 C++함수는 C++문법을 기반으로 변경 및 확장되었기에 가급적 C++의 헤더파일을 포함시켜 C++의 표준함수를 호출해야 한다.