C/C++ 기본 정리(1)

Six Root·2023년 4월 24일

C++ 공부

목록 보기
5/10

1. 함수

1-1. 스택(Stack) 메모리 영역

함수가 사용하는 메모리 공간을 스택(Stack) 메모리 영역이라고 한다.
후입선출 동작방식

🔖예문
int Add(int a, int b)
{
  return a + b; 
}

int main() // main함수가 호출되면 iData변수에 대한 메모리가 잡힌다.
{
  int iData = Add(100, 200); // iData 변수를 선언할 때 메모리가 잡히는게 아님.
  						 // Add함수가 호출되면 a와 b에 대한 메모리가 잡히고, 종료되면 날라감.
  iData = Add(200, 300); // Add함수가 호출되면 a와 b에 대한 메모리가 잡히고, 종료되면 날라감.
  iData = Add(300, 400); // Add함수가 호출되면 a와 b에 대한 메모리가 잡히고, 종료되면 날라감.
  
  return 0;
}

📃 설명
작성한 코드는 명령어의 집합이고, 명령을 수행했을 때에 그 수행에 맞춰서 동작하고 있는 곳이 메모리 영역이다.


1-2. 모듈화

🔖예문
// 팩토리얼 기능을 만들어 보자.
int main()
{
  int i = 10; // i 값은 팩토리얼을 구하고 싶은 숫자.
  int iValue = 1;
  
  for(int j = 0; j < i-1; ++j)
  {
    iValue *= j + 2;
  }
  // i의 값만 정해주면 그 숫자의 팩토리얼 값을 구해준다.
   
  return 0;
}

하지만 이 기능을 다른 곳에서도 또 사용하고 싶다면 이 코드를 다시 복사해서 붙여넣기하는 방식은 너무 번거롭다. 따라서 이 기능을 함수로 만들어서 모듈화시킨다.

  • 함수를 사용하는 이유 - 코드를 재사용 하기 쉽게 하기 위해서.
🔖예문
int Factorial(int iCount) // 팩토리얼을 구하는 함수를 만든다.
{
  int iValue = 1;
  
  for(int j = 0; j < iCount - 1; ++j)
  {
    iValue *= j + 2;
  }
  
  return iValue; // Factorial 함수가 호출되면 return값을 CPU의 레지스터 메모리에 저장해놓고 스택메모리에서 사라진다.
}

int main()
{
  int iValue = Factorial(4); // 레지스터 메모리에 저장되있는 Factorial 함수의 return값을 불러와 대입한다.
  
  return 0;
}

🔍참고
레지스터 메모리 : CPU가 연산할 때 임시로 빠르게 접근할 수 있는 메모리 영역이다.
일반적으로 함수의 return값을 레지스터 메모리에 잠시 담아둔 후 함수의 메모리 영역은 사라지고, 호출된 함수에서 레지스터 메모리에 담아둔 값을 꺼내와서 사용한다.


1-3. 재귀함수

함수 안에서 자기 자신을 호출하는 함수

🔖예문
int Factorial(int iCount)
{
  int iValue = 1;
  
  for(int j = 0; j < iCount - 1; ++j)
  {
    iValue *= j + 2;
  }
  
  Factorial(10); // 자기 자신을 호출했다.
  
  return iValue;
}

위 예문에서 스택 메모리에 한계에 다다르면 Stack Overflow 에러가 발생.
그렇기 때문에 재귀함수는 반드시 탈출 조건이 있어야 한다.

  • 1-2에서 for문으로 만들었던 팩토리얼을 재귀함수를 사용해서 만들어보면 아래와 같다.
🔖예문
int Factorial_Re(int iCount) // 팩토리얼을 구하는 함수를 만든다.
{
  if (1 == iCount)
  {
    return 1; // 1! 에서는 1을 return하고 탈출.
  }
  
  return iCount * Factorial_Re(iCount - 1); // 예) 5! = 5 * 4!
}

int main()
{
  int iValue = Factorial_Re(5);
  
  return 0;
}
  • 피보나치 수열을 만들어 보자.
    1, 1, 2, 3, 5, 8, 13, 21, 34, 55.....
    먼저 반복문을 사용하면 아래와 같다.
🔖예문
int Fibonacci(int iNum)
{
  if(iNum == 1 || iNum == 2)
  {
    return 1; // 첫번째랑 두번째 수는 1로 고정이기 때문에 1로 return시킨다.
  }
  
  int iPrev1 = 1; 
  int iPrev2 = 1; // 두 수를 더하는 연산의 연속이기 때문에 두 개의 메모리 공간이 필요
  int iValue = 0; 
  
  for(int i = 0; i < iNum - 2; ++i) // iNum번째 수는 (iNum - 2)번의 연산을 거친다.
  {
    iValue = iPrev1 + iPrev2;
    iPrev1 = iPrev2; // 메모리 공간에 대입
    iPrev2 = iValue; // 메모리 공간에 대입
  }
  
  return iValue;
}

int main()
{
  int iValue = Fibonacci(10);
  
  return 0;
}
  • 재귀함수를 사용하여 피보나치 수열을 다시 만들어 보면 아래와 같다.
🔖예문
int Fibonacci_Re(int iNum)
{
  if(iNum == 1 || iNum == 2)
  {
    return 1; // 첫번째랑 두번째 수는 1로 고정이기 때문에 1로 반환한다.
  }
 
  return Fibonacci_Re(iNum - 2) + Fibonacci_Re(iNum - 1); // 재귀함수를 사용해서 한 줄로 구현이 끝났다.
}

int main()
{
  int iValue = Fibonacci_Re(10);
  
  return 0;
}

// 정상적으로 동작하지만 사실 문제가 있는 코드이다. 
// 호출이 될 때마다 2의 제곱승수로 함수 호출 횟수가 늘어나기 때문에
// 만약 50~60번째 피보나치 수열을 구하려면 연산이 수십억번 반복될 수 있다.
// 해결하자면 '꼬리재귀'라는 방법을 이용해서 해결할 수 있다.

📃 설명
재귀함수를 쓰는 이유? 장단점?
장점 : 직관적이어서 가독성이 좋고, 구현이 용이하며, 계층구조(ex. tree)를 표현할 때 효율이 좋다.
단점 : Stack Overflow같은 실수가 생길 수 있고, 성능 저하를 일으킬 수 있다. 따라서 적절한 상황에서 써야한다.


2. 배열

2-1. 배열의 구조

🔖예문
int iArray[10] = { } // 초기값이 전부 0인 int형 10묶음 배열 iArray변수

// Index 접근
iArray[4] = 10; // 5번째 iArray에 10을 대입하라. Index가 4인 iArray.

iArray[10] = 10; // 11번째? Debug모드에서는 컴파일 오류로 잡아주지만, Release모드에선 못잡아 줄 수 있음. 

2-2. 배열의 특징

  • 메모리가 연속적
    10묶음 배열이라고 한다면 메모리가 순서대로 연속적으로 잡힌다.
    만약 위 예문에서iArray[10]의 경우처럼 배열을 초과해서 접근했을 경우에도 10번째 다음인 11번째가 될 자리에 메모리가 잡힌다. 예기치 못하게 다른 변수의 값에 덮어씌워버릴 수 있기 때문에 항상 주의해서 사용해야 함.

3. 구조체

구조체 : 사용자 정의 자료형. 내가 직접 자료형을 설계해서 사용할 수 있다.

🔖예문
typedef struct _tagMyST
{
  int 	a;
  float f;
}MYST; // MYST라고 하는 자료형은 int형과 float형을 사용할 수 있다.

typedef struct _tagBig
{
  MYST 	k;
  int 	i;
  char 	c;
}BIG; // BIG이라는 자료형은 아까 만든 MYST라는 자료형과 int, char형을 사용할 수 있다.

int main()
{
  MYST t;
  t.a = 10; // MYST 자료형 안에 있는 int자료형 a를 따로 사용하고 싶을 때, [.]을 붙여서 사용할 수 있다.
  t.f = 10.2135f;
  
  int iSize = sizeof(MYST); // MYST 자료형의 크기는 8바이트이다. iSize = 8.
  return 0;
}

typedef : 타입을 재정의 한다.

🔖예문
typedef int INT; // 앞으로 int자료형을 INT로 써도 컴파일러는 int라고 받아들인다.

아래 예문에서 typedef에 대해 더 자세히 살펴보자.

🔖예문
typedef struct _tagMyST
{
  int 	a;
  float f;
}MYST;

struct NewStruct // struct를 NewStruct라는 자료형을 새롭게 만들어주었다.
{
  int a;
  short s;
}

int main()
{
  _tagMyST s; // C언어 컴파일러에서는 오류로 인식한다.
  
  struct NewStruct a; // NewStruct 자료형을 사용하기 위해서 앞에 struct라고 명시해줘야 한다. tag명.
}

매번 struct를 앞에 붙여주면 귀찮기도 하고 자료형 이름이 너무 길어지기 때문에 재정의 해준다.

🔖예문
typedef struct NewStruct
{
  int a;
  short s;
}NEWST; // NewStruct의 새로운 이름을 부여함.

int main()
{
  NEWST a;
}

C언어 컴파일러에서는 이렇게 따로 재정의 해줘야 했다.
하지만 C++로 넘어오면서 struct 키워드를 붙여주지 않아도 되도록 바꼈다.

🔖예문
typedef struct _tagMyST
{
  int 	a;
  float f;
}MYST; // C와 C++ 모두 범용적으로 호환되게 하기 위해 대부분 이런방식으로 사용한다.

struct NewStruct
{
  int a;
  short s;
}

int main()
{
  _tagMyST s; // C++ 컴파일러에서는 문제가 아니다.
  
  NewStruct a; // struct 키워드를 붙여주지 않아도 된다.
}

그리고 구조체 자료형의 값을 "초기화" 하는 방법은 배열이랑 비슷한다.

🔖예문
typedef struct _tagMyST
{
  int 	a;
  float f;
}MYST;

int main()
{
  // 배열에서 초기화 하는 방법
  int arr[10] = {1, 2, 3, 4, 5, 6}; // 나머지 값들은 모두 0으로 초기화 됨.
  
  MYST t = { }; // MYST 자료형의 값을 모두 0으로 초기화 한다. 배열과 비슷.
  MYST t = {100, 3.14f}; // 100(int)과, 3.14(float)이라는 값으로 초기화 됨.
  t.a = 10; // int a에 10이라는 값으로 초기화 시킨다.
  t.f = 10.2346f; // float f에 10.2346이라는 값으로 초기화 시킨다.
}

4. 변수(2)

4-1. 변수의 종류와 메모리 영역

변수의 종류

  • 지역변수
  • 전역변수
  • 정적변수
  • 외부변수

메모리 영역의 종류

  • 스택(Stack) 영역
  • 데이터(Data) 영역
  • 읽기전용(코드, ROM(Read Only Memory))
  • 힙 영역

4-2. 데이터(Data) 메모리 영역

  • 데이터 영역의 특징(1)
    프로그램 시작 시 생성
    프로그램 종료 시 해제
🔖예문
void Test()
{
  int i = 0;
  ++i;
}

int main()
{
  Test();
  Test();
  Test();
  
  return 0;
}

위 예문에서 Test 함수가 반복되도 지역변수 i의 값이 늘어나지 않는다.
스택메모리에서 생성됐다 지워졌다를 반복하기 때문이다.
반면에, 전역변수로 선언된 변수는

🔖예문
int g_i = 0;

void Test()
{
  ++g_i; 
}

int main()
{
  Test();
  Test();
  Test();
  
  g_i = 0; // 데이터 영역에 존재하기 때문에 어떤 함수에서든 사용할 수 있다.
  
  return 0;
}

Test함수를 3번 호출한 후에 전역변수 g_i의 값은 3이 될 것이다.
함수의 호출과 종료에 상관 없이 데이터 영역에서는 계속해서 값을 유지시킨다.

🔍참고
정적변수(static)와 외부변수(extern) 모두 데이터 영역을 사용한다.


5. 분할 구현

헤더(.h)와 파일(.cpp)를 분리해서 구현.

🔖예문
func.h

int Add(int a, int b); // 헤더에서 이런 함수가 있다고 선언만 함.

/*--------------*/

func.cpp

#include "func.h" // func.h에 선언한 내용을 복붙하는것과 같다.

int Add(int a, int b)
{
  return a + b;
}

이렇게 구현한 것을 다른 파일에서도 사용하고 싶다면,

🔖예문
main.cpp

... 
#include "func.h" // 윗줄에 다른 코드가 작성되 있어도 중간에 포함시켜도 된다.

int main()
{
  int data = Add(10, 20); // 전체적인 컴파일 과정(링크)에서 func.cpp에 정의된 내용을 연결해준다.
  						  // 함수를 미리 선언만 미리 해놓고 구현은 나중에 해도 되는것과 같은 이치.
  
  return 0;
}

이렇게 헤더만 포함시켜주면 그 헤더에서 선언하고 cpp파일에서 구현한 함수를 모두 사용할 수가 있다.

📃 설명

  • 분할 구현을 하는 이유?
    모든 내용을 한 파일 안에 전부다 구현할 수도 있지만,
    직관적이지 못해 관리하기가 어렵고, 코드가 엄청 길어짐.
    코드를 체계적으로 구현하고, 유지보수를 용이하게 하는 목적이 있다.

  • 단점은?
    컴퓨터 입장에서는 하나의 파일에서 작업하는게 더 편할수도 있다.
    이렇게 분할 구현을 하게되면 컴파일 링크하는 과정에서 속도 저하가 일어난다.
    중복적으로 같은 코드를 여러번 확인하게 되기 때문에 컴파일 속도가 느려질 수 밖에 없다.
    그래서 헤더가 다른 헤더를 참조하는것을 방지하는 코드 규칙같은것들이 생김.

🤔❔ 만약
구현부분까지 헤더에다가 구현하면 되지 않을까?

🔖예문
void Name()
{

}
void Name()
{

}

int main()
{
  Name(); // 같은 이름의 함수가 두번 정의되었을 때 둘 중 어떤걸 호출하는건지 알 수가 없다.
}

이와같이 #include해서 헤더를 포함시킨다는 것은 복사/붙여넣기와 같은 의미이기 때문에 구현한 내용이 또 다른곳에서 복사되는것과 마찬가지이다.
그렇기 때문에 컴파일 링크 과정에서 같은 내용이 중복구현됐다고 오류가 발생한다.

5-2. 분할 구현의 문제점

전역변수의 장점이 사라진다.
데이터 영역에는 분명히 전역변수 내용이 존재하지만,
코드 개발하는 과정에서 다른 파일에서 선언된 전역변수의 존재를 알지 못한다.
따라서, 이런 문제를 해결하기 위한 방법이 있다. 방법을 알려면 정적변수외부변수를 알아야 한다.


6. 변수(3)

6-1. 정적변수와 외부변수

정적변수

🔖예문
// 전역변수
int g_i = 0; // 다른파일에서 사용할 시 컴파일 오류.

// 정적변수
static int g_iStatic = 0; // 다른파일에서 사용해도 오류가 아님.

📃 설명
전역변수와 정적변수 둘 다 데이터 메모리 영역을 사용하지만
전역변수는 다른 파일에서 정의됐을 때 컴파일(링크)단계에서 문제를 발생시키지만,
정적변수는 문제가 되지 않는다.

Dynamic : 동적
Static 	: 정적 // 생성된, 선언된 위치에서 틀어박혀서 움직이지 않는다.

정적변수는 그 변수가 선언된 파일 안에서만 존재하도록 한다. 그래서 같은 이름의 변수명을 사용하더라도 문제가 되지 않는 이유다.

🤔❔ 만약
함수 내에서 정적변수가 선언된다면?

🔖예문
int Test()
{
  static int i = 0; // 데이터 메모리 영역을 사용한다. 이 함수 안에서만 사용할 수 있다.
}
  • 위 예문에서 i는 스택 메모리를 사용하지 않기 때문에 Test 함수가 호출되거나 중단되거나 할 때 생성되거나 제거되지 않고 계속 유지된다.
  • 그리고 다른데서는 사용하지 못하고 해당 함수에서만 사용 가능하다.
    다른사람이 사용하지 못하게 하거나, 특정 상황에서만 접근해서 실수할 여지를 없애고 싶을 때 정적변수를 사용해서 제한을 걸어둔다.
  • 또한, 한번 선언된 정적변수의 초기화 구문은 최초 한번만 실행되고, 그 이후부터는 함수가 호출될 때마다 초기화 되지 않고 건너띄는 규칙이 있다.
🔖예문
int Test()
{
  static int i = 0;
  ++i;
  
  return i; // i값을 리턴.
}

int main()
{
  Test();
  Test();
  Test(); // 여기에서 Test는 3이라는 값을 리턴한다. 즉, 함수가 호출될 때마다 초기화 하지 않는다는 것.
}

외부변수

🔖예문
common.h // 헤더

static int g_iStatic = 0;
extern int g_iExtern; // 초기화를 하면 안된다. 컴파일 오류. 이런 변수가 있을것이라고 알려만 주는 것.

📃 설명
위 예문에서 g_iExtern 변수가 선언된게 아니다. 그냥 어딘가에 있을거라고 알려주기만 한 것.
그 어딘가가 아예 다른 파일이어도 상관 없다. (어디에도 없으면 컴파일 오류)
common.h에서 외부변수로 지정해 놓았는데 Test.h라는 새로운 헤더에서 선언되도 된다.

🔖예문
Test.h // 헤더

int g_iExtern = 0;

나중에 컴파일 시 하나로 합쳐지는 링크 과정에서 Test.h에 선언된 g_iExtern변수를 불러와서 호출된 곳에서 사용된다.


7. 포인터

7-1. 포인터의 기본 개념

  • 포인터(포인터변수) : 주소를 가리키는 기능. 그런 기능을 수행하는 변수.
  • C++의 강력함의 이유 = 포인터, 주소 개념을 언어차원에서 사용할 수 있기 때문.
  • 주소의 단위는 바이트(Byte)이다. 비트(Bit)단위로는 주소를 보유할 수 없다.
  • 포인터의 주소를 저장하는 방식은 정수 방식이다.
🔖예문
int* pInt = nullptr; // nullptr은 아무것도 가리키고 있지 않다는 의미. 실제 데이터는 0과 같다.

📃 설명
위 예문에서 int자료형인 변수의 주소를 저장하는 변수 pInt를 선언했다.

만들어 질 때부터, int변수 주소만 저장하겠다, 다시말해 주소에 접근하면 4Byte 정수형만 접근하겠다고 미리 정해놓는 것이다.

🔖예문
int i = 100;
int* pInt = &i; // 변수 i의 주소를 저장한다. 이제 포인터 변수를 사용해서 i의 값을 수정할 수 있다.

*pInt = 100; // 변수 i에 100이라는 값을 대입한다.
// 포인터 변수 앞에 *을 붙이는 것은 안에 저장되어있는 주소값으로 참조하겠다는 것. 주소로 접근을 해보겠다는 것.
// int 포인터라고 명시해 주는것은 주소로 접근할 때 4Byte만큼 접근하겠다는 의미이다.
// 어떤 자료형인지 명시해 주지 않으면 얼만큼 접근하겠다는건지 모름.

🤔❔ 만약
int형 포인터 변수인데 float형 변수의 주소를 저장하겠다고 한다면?

🔖예문
int i = 100;
float f = 3.f;

int* pInt = (int*)&f; // 변수 f의 주소를 (int*)을 붙여서 강제 캐스팅 하여 pInt에 저장. 안붙이면 컴파일 오류.

i = *pInt; // pInt에 저장된 주소로 접근하여 그 값을 i에 대입.

// i에 저장된 값 : 1077936128

위 예문에서 float 변수 f에는 실수 표현방식의 3.0이 들어가 있는데 int 포인터 pInt는 주소에 접근했을 때 그 값을 무조건 int형 정수 표현방식으로만 해석하기 때문에 이상한 값이 들어가게 된다.
어떻게 받아들이는지에 따라 실제로 얻게되는 값이 다르다.

자료형* 변수명 // 자료형: 해당 포인터에게 전달된 주소를 해석하는 단위

🔍참고

  • 포인터 변수의 크기는 자료형에 따라 달라지지 않는다. 자료형은 해석하는 단위일 뿐, 포인터는 주소를 저장하는 변수이기 때문에 크기는 일정하다.
  • 32bit로 컴파일 시 4Byte로 모두 같고, 64bit로 컴파일 시8Byte로 모두 같다.
  • 64bit 운영체제에서 32bit로 컴파일 하면 포인터 변수의 크기는 4Byte가 된다.

7-2. 포인터의 연산

🔖예문
int i = 100;
int* pInt = &i;
pInt += 1;

📃 설명
pIntint* 변수이기 때문에, 가리키는 곳을 int로 해석한다.
따라서, 주소값을 1 증가시키는 의미는 다음 int 위치로 접근하기 위해서 sizeof(int) 단위로 증가하게 된다.

다시말해 pInt의 주소가 100이라고 했을 때 pInt + 1int자료형의 크기인 4만큼 증가한다.
정수형 4Byte를 접근하겠다고 선언한 포인터 변수이기 때문에 +1 즉, 주소 하나를 증가시키라고 하게 되면 다음 int주소인 104가 되는 것이다.
char 변수는 1, short변수는 2씩, 자료형 사이즈 단위로 증가하게 된다.


7-3. 포인터와 배열

배열의 특징을 다시 살펴보면
1. 메모리가 연속적인 구조이다.
2. 배열의 이름은 배열의 시작 주소이다.

🔖예문
int iArr[10] = { }; 

iArr; // 배열의 시작 주소.

*(iArr + 0) = 10; // 배열의 시작 즉, 배열의 첫 번째 칸에 10을 넣어라. iArr[0] = 10;
*(iArr + 1) = 10; // 배열의 시작으로부터 한 칸 띈 곳에다가 10을 넣어라. 두 번째 칸에다 대입. iArr[1] = 10;
// iArr[0]에 10이라는 값이 저장된다. iArr[0] = 10;
// iArr[1]에 10이라는 값이 저장된다.
// 배열의 Index 0이 첫번째 칸이 되는 이유.

📃 설명
위 예문에서 배열의 이름인 iArr가 배열의 시작 주소가 된다.

arr[n] 		 == *(arr + n)
배열이름[정수] == *(배열이름 + 정수)

애초에 배열은 주소 접근을 얘기하고 있다.

포인터와 배열 문제풀이

🔖예문
// 문제1)
short sArr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 각각 2Byte 간격으로 주소가 할당
int* pI = (int*)sArr; // pI에 int형 접근방식으로 변환해서 sArr 주소를 저장
int iData = *((short*)(pI + 2)); // pI에다가 2를 증가하는것은 결국 8Byte를 증가시키는 것이므로 5번째 값의 시작주소가 된다. 
								 // 여기까지는 5번째와 6번째 모두를 포함하지만 다시 short형으로 접근방식을 변환해줬기 때문에 iData에는 5만 저장되게 된다.
printf("1번 문제 정답 : %d\n", iData); // iData = 5

실행 결과
1번 문제 정답 : 5

/*----------------------------*/

// 문제2)
char cArr[2] = {1, 1}; // 각각 1Byte 간격으로 주소가 할당
short* pS = (short*)cArr; // pS에 short형 접근방식으로 변환해서 cArr 주소를 저장
iData = *pS; // 형변환 없이 그대로 주소에 접근한다.
			 // 1Byte는 8Bit이므로 0000000100000001이 저장된다.
             // 따라서 iData에는 256+1=257이 저장된다.
printf("2번 문제 정답 : %d\n", iData); // iData = 257

7-4. void 포인터

🔖예문
void* pVoid = nullptr;

주소로 접근했을 때의 원본의 형태를 어떤 형식으로 볼건지를 정하지 않은 포인터다.
따라서, 변수가 어떤 자료형을 가진 주소이든 다 저장할 수 있다.
하지만 대신 역참조 즉, 주소에 접근할 수가 없다. 당연히 주소 연산도 불가능.

🔖예문
void* pVoid = nullptr;
{
  int a = 0;
  float f = 0.f;
  double d = 0.;
  long long ll = 0;
  
  pVoid = &a;
  pVoid = &f;
  pVoid = &d;
  pVoid = &ll;
  
  *pVoid; // 오류. 주소에 접근할 수 없다. 역참조가 안됨. 어떤 형식으로도 해석할 수가 없다.
  pVoid + 1; // 오류. 몇 Byte씩 증가하는지 알 수가 없다.
}

다시 정리하자면,

  • void 포인터의 특징
  1. 원본의 자료형을 정하지 않음.
  2. 어떠한 타입의 변수의 주소든 다 저장 가능
  3. 역참조 불가능
  4. 주소 연산 불가능

8. const

8-1. const의 용도

const : 변동되지 않는 상수값을 표현

🔖예문
const int cInt = 100; // cInt는 상수화가 된다. 값이 바뀔 수 없는 상태가 되었다.

📃 설명
10이라는 수는 고정된 수이다. 상수 라고도 하고 r-value 라고도 한다.
반대로 변수l-value라고도 한다.
변수를 선언할 때 앞에 const를 붙이면 변수를 상수처럼 여기게 된다.
10=11이 성립되지 않는것처럼 상수화된 변수 역시도 cInt=11이 성립되지 않아 컴파일 오류다.
cInt는 상수 100처럼 여겨진다.

하지만 그렇다고 해서 그 값이 절대로 바뀌지 않는다는것은 아니다.
단지 문법적으로 막아놓은것일 뿐.
cInt는 엄연히 스택 메모리 영역 안에 한 공간을 차지하고 있고, 거기다 const라고 못 박아서 문법적으로 값을 바꾸지 못하게 막아놓은 것이다.
포인터 주소를 알면 주소로 접근해서 값을 바꿀 수 있다.

🔖예문
int main()
{
  cont int cInt = 100;
  
  int* pInt = (int*)(&cInt);
  *pInt = 300; // 주소에 접근해서 강제로 값을 300을 바꾼다.
  
  printf("cInt 출력 : %d\n", cInt); //
}

실행결과
cInt 출력 : 100; // 하지만 출력결과는 100이다. 왜?

📃 설명
포인터로 주소에 접근해서 강제로 값을 바꿨지만 출력 결과는 그대로 100으로 출력됐다.
그 이유는 컴파일러가 cInt는 변하지 않는 상수라고 인식해서 굳이 주소에 저장된 값까지 가지 않고 레지스터 메모리에 저장되 있던 100이라는 숫자를 빠르게 들고 와서 출력했기 때문이다.
원래 의도했던 대로 주소에 저장된 값을 출력하라고 하고 싶다면 volatile이라는 키워드를 앞에 붙여주면 된다. 휘발성이라는 의미로 레지스터 메모리를 사용해서 연산하는 방식을 쓰지 말라는 의미다.

volatile const int cInt = 100;

사실 애초에 상수화 시킨 변수를 강제로 바꾸는 행위 자체가 잘못된 것이다.


8-2. const와 포인터

🔖예문
int main ()
{
  int a = 0;
  int* pInt = &a; // 변수 a의 주소를 저장
  *pInt = 1; // 주소로 접근해서 1을 대입
  pInt = nullptr; // 포인터가 아무것도 가리키고 있지 않다.
}

이처럼 포인터는 어떤 변수의 주소를 저장할 수도(가리킬 수도) 있고,
그 주소에 접근해서 값을 수정할 수도 있다.
포인터 변수가 바뀐다는 것은 가리키는 곳이 바뀐다는 것을 의미한다.

포인터에 const 키워드가 붙었을 때 즉, 포인터의 상수화에는 2가지 경우가 생기는데
1. 가리키고 있는 곳(원본)을 수정할 것인지 말 것인지.
2. 본인 포인터 변수 자체가 상수화가 되서, 처음 어떤 주소를 가리키면 다른곳으로 변경하지 못하게 하는 것.
const 키워드가 붙는 위치에 따라 달라진다.

🔖예문
// const 포인터
int main ()
{
  int a = 0;
  const int* pConstInt = &a;
  *pConstInt = 1; // 오류가 나면서 "식이 수정할 수 있는 l-value여야 합니다" 라고 나온다.
  int b = 0;
  pConstInt = &b; // 다른곳을 가리킬 수는 있다. 즉, 다른곳의 주소를 저장할 수는 있다.
}

위 예문은 가리키는 곳(원본)이 상수화가 되어서 그 곳의 값을 수정할 수 없다.

🔖예문
// 포인터 const
int main ()
{
  int a = 0;
  int* const pIntConst = &a;
  *pIntConst = 100; // 원본을 바꿀 수 있다.
  
  int b = 0;
  pIntConst = &b; // 오류가 난다. 다른곳의 주소를 저장할 수 없다.
}

위 예문은 포인터 변수 자체가 상수화가 되어서 다른곳의 주소를 저장할 수 없다.

이 둘을 조합하면 아래와 같이 사용할 수 있다.

🔖예문
const int* const pConstIntConst = nullptr;

초기화 할 때 저장해놓은 주소만 가리킬 수 있고, 심지어 원본 값을 수정할 수도 없다.
간혹 이렇게 쓰여져 있는 경우도 있다.

int const* pInt = &a;

이 경우는 첫 번째에 해당한다. 즉, 가리키는 곳(원본)을 상수화 한 것.
const 키워드가 *보다 앞에 있냐 뒤에 있냐로 따지면 된다.

🤔❔ 만약

🔖예문
int a = 0;
const int* pInt = &a;

a = 100; // 이게 성립할 수 있을까?

당연히 성립된다. a가 상수화가 된 것이 아니다.
단지 포인터의 기능 즉, 원본에 접근해서 수정하는 행위를 제한한 것이다.
변수 a랑은 아무 관련이 없다.


8-3. const + 포인터의 용도

🔖예문
void Output(int a)
{
  
}

int main()
{
  int a = 0;
  Output(a); // main 스택에 있는 a가 Output 함수 스택에 복사가 됨.
  
  return 0;
}

main 스택에 있는 a를 복사해서 그 값을 가지고 Output 함수에서 사용하는 것이 번거롭고 비효율적이다.
메모리가 작은 데이터면 괜찮지만 매우 큰 메모리를 가진 데이터라면 문제가 있다. 심지어 자주 호출이 되는 데이터라면? 퍼포먼스가 매우 떨어질 수 있다.
따라서, 아래와 같이 포인터를 사용해서 직접 원본에 접근해 읽어오면 된다.
(변수 a가 매우 큰 데이터라는 가정하에 설명한다.)

🔖예문
void Output(int* pI) // 파라미터를 주소로 받는다.
{
  
}

int main()
{
  int a = 0;
  Output(&a); // 원본을 넘겨준다.
  
  return 0;
}

하지만 포인터를 사용할 때 우려될 수 있는 부분은 원본을 바꿔놓을 수 있다는 것이다.
읽어가기만 하면 되는데 원하지 않게 원본 값이 수정될 수 있는 여지를 없애기 위해 const 키워드를 붙여준다.

🔖예문
void Output(const int* pI) 
{
  int i = *pI; // 접근해서 대입하는것은 가능하지만
  *pI = 100; // 오류. 원본을 수정하는 것은 불가능 하다.
  
  // 하지만 강제로 바꿀 수도 있다.
  int* pInt = (int*)pI;
  *pI = 1000; // 이렇게 하면 a값이 바뀔 수도 있다. 
}

int main()
{
  int a = 0;
  Output(&a); // 원본을 넘겨준다.
  
  return 0;
}

🔍참고
원본을 넘겨주려 할 때, 만약 다른사람이 작성한 함수에 넘겨주는 경우라면,
파라미터에 const 키워드가 있는지 없는지 다른사람의 코드를 확인할 수 없는 경우가 있다.
그럴경우 호출된 함수의 파라미터를 입력하는 곳에서 단축키 Ctrl+Shift+Spacebar를 눌러보면
상대방이 어떻게 작성했는지 확인해 볼 수 있다.

Output(&a); // &a 부분에서 단축키 Ctrl+Shift+Spacebar

void Output(const int* pI) 라고 뜬다면,
협업에서 상대방이 원본을 읽어오기만 하고 수정하지는 않는다는 의도를 파악할 수 있다.
하지만, 위의 예문에서 처럼 강제로 바꿀 수 있는 여지는 있다. 협업에서 일어나선 안되는 상황.
상대적으로 안전하게 const_cast라는 키워드를 사용해서 const키워드가 붙은 포인터를 일반 포인터 형태로 바꾸는 키워드도 있다.
const키워드가 붙었다고 해서 절대 바꾸지 못한다는 것은 아니다.

profile
언리얼 전문가가 될 때까지 (중요한 건 꺾이지 않는 마음)

0개의 댓글