CPP Module 01 (1)
“CPP Module 01에 대하여”
Memory allocation, pointers to members, references, switch statement
C++의 메모리 할당, 포인터와 멤버들, 참조들, switch문 … C++에서도 메모리 관리는 계속된다.
ex00 BraiiiiiiinnnzzzZ
- Zombie 클래스를 만들어야 한다. string 멤버 변수 name을 가지고,
void announce(void)
라는 멤버 함수를 가진다. 좀비는 announce 하면서
<name>: BraiiiiiiinnnzzzZ...
라고 한다. name을 출력할 때, “<”, “>”(brackets)는 제거해야 하는데 이는 42의 유구한 전통이다. 고사기에도 그렇게 적혀있다.
그리고 함수를 2개 만들어야 한다.
Zombie* newZombie(std::string name)
과void randomChump(std::string name)
이라는 녀석들이다.(1) 첫 번째
newZombie(string name)
는 좀비를 만들어서 이름을 붙이고 함수의 스코프 바깥에서 사용할 수 있도록 반환하는 것이다. 매우 매우 중요한 포인트(2) 두 번째
randomChump(string name)
는 좀비를 만들어서 이름을 붙이고 좀비가 annouce하면 된다. 이상 무.
함수 스코프 바깥에서 사용할 수 있도록 반환
- 중요해서 한번 더 적었다. 이게 무슨 소리일까? 아래 코드를 한번 보자. 출력값은 굳이 안적었다. 출력값이 중요한 코드가 아니다.
#include <stdio.h>
int *getStackPtr() {
int var = 42;
int *ptr = &var; // 스택에서 할당된 포인터를 함수 스코프 밖으로 빼려고 함
return ptr;
}
int main()
{
int *ptr = getStackPtr();
printf("%d\n", *ptr);
return 0;
}
이런 식으로 쓰는 코드는 본 적이 없을 것이다. 당연하다. 이런 코드를 적는 자들은 모두 닌자에게 살해당했기 때문이다…
getStackPtr()
를 보면 스택에 int 변수 하나를 할당하고, 포인터에 그 주소를 담아서 반환한다. 그런데 var은 지역 변수이기 때문에 스코프가 getStackPtr()를 벗어나는 순간 해제되고, ptr의 주소는 해제된 곳을 가리키게 된다. 즉 ptr은 댕글링 포인터가 된다. 댕글링 포인터(Dangling Pointer)는 이미 해제된 메모리를 가리키거나 더 이상 유효하지 않은 메모리 위치를 가리키는 포인터다. 이는 프로그램의 안정성과 예측 가능성을 저해하며, 예기치 않은 동작 또는 프로그램 충돌을 유발한다고 한다. 그러니까 만약 이런 식으로 스택 포인터Zombie*
를 반환하면, 그게 함수 스코프 바깥에서 사용할 수 없도록 반환하는 거다. 이러면 안 된다. 그러면 함수 스코프 바깥에서 사용할 수 있도록 반환한다는 건 뭘까? 우리는 이미 답을 알고 있다. 수백번도 넘게 해온 행위이다. 바로 메모리의 동적 할당(malloc, calloc …)이다.
** 스택과 힙에 대해 자세히 알고 싶다면 C++ 08.10 - 스택과 힙 (Stack and Heap) 참고
- 동적 할당과 스택에 포인터를 만드는 것의 차이에 대해 공부한 적이 있을 것이다. 메모리를 동적 할당하면 메모리 세그먼트(스택, 힙, 코드, 데이터) 중에서는 힙을 쓰게 되며, 스택에 비해 더 큰 메모리를 가져갈 수 있지만 속도가 느리고, 명시적으로 해제할 때까지 유지되고, 포인터를 역참조하여 value에 접근할 수 있다. 여기에 더해 중요한 포인트가 바로 메모리를 직접 제어할 수 있기 때문에 데이터를 함수 스코프 밖으로 끄집어 낼 수 있다는 점에 있다. 즉 함수 스코프 바깥에서 사용할 수 있도록 반환할 수 있다. 이 과제는 메모리를 동적으로 할당할 것을 요구한다.
newZombie(std::string name)
#include "Zombie.hpp"
Zombie *newZombie(std::string name)
{
Zombie *heap = new Zombie(name);
return (heap);
}
문자열 name을 매개변수로 받아 새로운 좀비 클래스 하나를 동적 할당하여 스코프 바깥으로 반환하는 코드이다. 간단명료 그 자체.
C++에서는 malloc 대신 new를 사용한다. 쪼금만 살펴보자면 대략 이렇게 생겼다.
void* operator new ( std::size_t count );
- 또한 세부적으로는 아래와 같다.
void* operator new(std::size_t sz)
{
std::printf("1) new(size_t), size = %zu\n", sz);
if (sz == 0)
++sz; // avoid std::malloc(0) which may return nullptr on success
if (void *ptr = std::malloc(sz))
return ptr;
throw std::bad_alloc{}; // required by [new.delete.single]/3
}
** 출처, CPP Reference, operator new, operator new[]
- 내부적으로 malloc을 사용하고, 에러 발생 시에는 exception을 던진다. exception을 처음 볼 수도 있는데, 일단 예외 처리의 일종이라고 생각하자. 이 과제에서는 사용하지 않는다.
생성자와 소멸자
- 생성자와 소멸자가 무엇인지에 대해서 굳이 나열하진 않겠다. 중요한 점은 결국 클래스를 생성할 때는 생성자가, 클래스가 없어질 땐 소멸자가 반드시 호출된다는 것이다. 뭐 당연한 소리를 하냐고 볼 수도 있겠지만 반드시가 가지는 힘이 상당히 강력하다. 우리가 변수를 선언하면 초기화가 필요하다는 사실을 알면서도, 또 메모리를 동적 할당하면 해제가 필요하다는 사실을 알면서도, C에서 그 많은 segmentation fault와 data leaks를 목격했던 이유가 바로 저 “반드시”의 부재에 있다. 내가 생각하는 생성자와 소멸자의 강력한 힘은 그 자체가 초기화와 해제에 관한 명시적인 course라는 점에 있다.
돌다리를 두들겨보고 건너는 것과 그냥 건너는 것의 차이
- 클래스의 존재와 그 특징들은 상속/다형성과 더불어 C++가 가지는 뚜렷한 특징인데, 여기서는 C의 구조체와 C++의 클래스에 대해 조금만 이야기해보도록 하겠다.
(1) 멤버 함수
- 구글링을 하면서 막 찾아보다가 C 구조체로 C++의 클래스를 흉내내는 코드를 본 적이 있다(시간나면 한번 해보는 것도 나쁘지 않을 것 같다). 역시 C 구조체와 C++ 클래스의 가장 눈에 띄는 차이는 멤버 함수일 것이다. C 구조체에는 함수 자체를 담을 수는 없으며, 비슷한 걸 흉내내려면 함수 포인터를 써야한다. 아래는 내가 C 구조체로 C++ 클래스를 흉내냈던 코드다.
struct c_Person
{
char* name;
int age;
int(*getAge)(c_Person *);
void(*setAge)(c_Person *, int);
string(*getName)(c_Person *);
void(*setName)(c_Person *, string);
};
class Person
{
private:
string name;
int age;
public:
Person();
Person(string name, int age);
string Getname() const;
int GetAge() const;
void SetName(string name);
void SetAge(int age);
~Person();
};
- 이렇게 보면 둘이 비슷해 보일지도 모르겠다. 어차피 함수 내부 구현하는건 똑같을텐데 C도 그냥
getAge()
,setAge()
,getName()
,setName()
구현해서 매칭해주면 되는거 아닐까하고 생각할수도 있다. 물론 구조체, 클래스를 하나만 쓸거면 크게 상관없을거다. 사람이 천 명이면 어떨까? 구조체 천 개에 일일히 함수 매칭해주고, 구조체 포인터 보내서 이름, 나이 일일히 초기화할 거 생각하면 벌써부터 군침이 싹돈다. 반면에 클래스는 그렇지 않다. 클래스 안의 멤버 함수들은 클래스가 생성되면서 단 한번만 생성된다. 이후 저 클래스로 만들어진 객체들은 동일한 함수 포인터들을 돌려쓴다. 함수를 일일히 매칭해줄 필요가 없다. 또 생성자들을 통해 멤버 변수들을 명시적으로 초기화할 수 있다. 실수가 발생할 확률이 적다는 거다. 적어도 char*로 된 이름 천 개 할당하면서 생기는 실수보다는 훨씬 적을 것 같다.
(2) this 포인터
- 이건 또 클래스가 지닌 특별한 기능이다. 기능에 대한 상세한 내용을 알고 싶다면 C++ 09.10 - this 포인터을 참고하자. this 포인터는 자기 자신에 대한 포인터인데, 컴파일러 상에서 항상 멤버 함수의 매개 변수에 추가된다. 예를 들어 Person의 age를 반환하는
int getAge()
에 대해 생각해보자.
int Person::getAge(const Person* this) const
{ // int Person::getAge() const
return (this->age);
}
- 매개 변수에 포함된
const Person* this
가 바로 this 포인터이다. 우리가 작성하는 코드 상에서는 등장하지 않지만, 컴파일러 단에서 추가되어 객체를 식별한다. 여러 객체들이 똑같이 getAge()를 호출하더라도, 호출하는 객체가 다르면 서로 다른 값을 반환할 수 있는 비결이다.
ex01: Moar brainz!
- 이번 exercise는 객체 배열의 동적 할당과 관련이 있다. 단도직입적으로 객체 배열을 생성하는 요령에 대해 알아보자.
객체 배열의 선언 및 초기화
Classname* ptr = new Classname[size];
- 이걸 Zombie에 대입시켜보면?
Zombie* arr = new Zombie[size];
- 이렇게 하면 size만큼의 Zombie 배열을 얻을 수 있다. 다만 c++98에서 이런 식으로 객체 배열을 선언할 때는 매개 변수가 있는 생성자를 호출할 수가 없다(c++11부터 제한적으로 가능하다고 한다). 그 말인즉슨, 저렇게 선언된 Zombie 클래스 각각의 이름은 빈 문자열로 초기화 되어있고, 우리가 수동으로 이름을 초기화해주어야 한다는 거다. 반복문을 돌면서 알맞은 처리를 해주면 되겠다.
객체 배열의 해제
- new로 선언했으니, delete로 해제하는 것도 동일하다. 그런데 해제하는 모양이 살짝 다르다. 아래와 같이 해제한다.
Zombie* arr = new Zombie[size];
...
delete[] arr;
- delete[]를 통해, 배열의 각 요소에 있는 소멸자들을 전부 호출할 수 있다.
delete arr
라고만 쓰면 배열의 첫 번째 원소의 소멸자만을 호출하게 될 것이다.
ex02: HI THIS IS BRAIN
참조자
- 레퍼런스(참조자)에 대해 알아야 한다. 사실 그냥 가져다쓰는 것 자체는 별로 안어려운데, 제대로 알려고 하면 어렵다. 참조자는 두고두고 써야하기 때문에 제대로 아는 것이 중요하다. 열심히 공부해보도록 하자. 참조자에 대한 설명이 자세한 사이트 두 곳을 소개한다.
1. 모두의 코드, 씹어먹는 C++ - <2. C++ 참조자(레퍼런스)의 도입>
2. 참조자에 대하여
- 간략하게나마 참조자들의 특징들을 살펴보고 가도록 하자.
(1) 선언할 때, 반드시 어떤 변수를 참조하는지 정해야 한다.
(2) 참조자가 특정 변수를 참조하게 되면, 대상을 다시 변경할 수 없다.
=> 잘 알려진 특성
참조자의 주소
#include <iostream>
int main()
{
int a = 10;
int &aRef = a;
int b = a;
std::cout << "a address : " << &a << '\n' \
<< "b address : " << &b << '\n' \
<< "a reference : " << aRef << '\n' \
<< "a reference address : " << &aRef << '\n';
}
a address : 0x7ffeea49f8ac
b address : 0x7ffeea49f89c
a reference : 10
a reference address : 0x7ffeea49f8ac
- a의 주소 &a와 &aRef는 똑같다. 똑같이 a를 대입한 b의 주소는 a의 주소와 다르다. a의 별칭을 만드는 데 메모리를 할당하지 않는다는 특징이 있다.
함수 인자로 사용되는 참조자
#include <iostream>
void mV1(int x1) {
x1 = 42; // x는 함수 내에서 복사된 값
std::cout << "x1 address : " << &x << '\n';
}
void mV2(int& x2) {
x2 = 42;
std::cout << "x2 address : " << &x << '\n';
}
int main() {
int value = 10;
std::cout << "value address : " << &value << '\n';
mV1(value);
// value = 10
mV2(value);
// value = 42
return 0;
}
value address : 0x7ffee25618a8
x1 address : 0x7ffee256188c
x2 address : 0x7ffee25618a8
- 잘 알고 있듯이, c에서 value를 수정하려면 주소를 보낸 후 역참조하여 수정했어야 한다. 값을 복사하여 보내기 때문이다. 주소를 찍어보면 알 수 있다. 새로운 변수를 만들어 대입한 것처럼 두 값의 주소가 다르다(value, x1). 참조자를 사용하면 그러지 않아도 된다. 주소가 그대로 전달된다.
레퍼런스를 리턴하는 함수
참조자에 대해 좋은 글들이 많아서 참조자를 아주 자세히 다루진 않으려 했는데, 이건 좀 재밌어서 굳이 글로 남긴다.
int &aRef = a;
처럼 참조자를 선언했던 것처럼, 함수의 반환값으로 참조 값을 사용할 수 있다.
case 1, 임시 값(지역 변수의 참조값)을 레퍼런스에 대입
int& function() // 반환값으로 참조
{
int a = 2;
return a;
}
int main()
{
int b = function(); //
b = 3;
return 0;
}
function()
은 a를 가리키는 어떤 참조값인데, a는function()
함수가 반환되어 스코프가 function 함수를 벗어나게 되면 메모리에서 사라지고,function()
은 졸지에 가리키는 대상을 잃어버리게 된다. 이렇게 가리키는 값을 잃어버린 참조자를 댕글링 레퍼런스라고 한다.사실상 아래와 같다.
int& ref = a;
// 근데 a 가 사라짐
int b = ref; // !!!
- 댕글링 레퍼런스를 변수 b에 대입하려고 하기 때문에 오류가 발생한다.
case 2, 임시 값이 아닌 값(외부 변수의 레퍼런스 값)을 레퍼런스에 대입
int& function(int& a) {
a = 5;
return a;
}
int main() {
int b = 2;
int c = function(b);
return 0;
}
- 이건 좀 다르다. a는 b의 참조를 받은 값이기 때문에,
function()
이 리턴되어도 main에 계속 남아있을 수 있다. 이 경우 오류가 발생하지 않는다.
case 3, 참조가 아닌 값을 반환하는 함수(임시 값)를 참조자에 대입
int function() {
int a = 5;
return a;
}
int main() {
int& c = function();
return 0;
}
- 이번엔
function()
은 값을 반환하지만, 이 값을 참조자에 넣는다. 이 경우도 a는function()
함수가 반환되면서 메모리에서 사라지기 때문에int& c
에 대입할 수 없다. 이 코드는 사실상 맨 위에int b = function();
,int& function() { int a = 5; return a; }
(case 1)코드랑 다를게 거의 없다. 라인이 끝나면 사라지는 메모리 임시값(r value)을 참조할 수는 없다.
case 4, 상수 참조자의 대입
#include <iostream>
int function() {
int a = 5;
return a;
}
int main() {
const int& c = function();
std::cout << "c : " << c << std::endl;
return 0;
}
- 상수 참조자는 c++의 중요한 예외 규칙이다. 바로 위에서 임시값을 참조자에 대입할 수 없다고 말했는데, 상수 참조자는 예외다. c++는 임시 값의 수명을 연장하기 위해 상수 참조를 사용할 수 있도록 허용한다. 이것은 임시 값을 함수 호출에서 반환하거나 다른 표현식에서 사용할 때 유용하다고 한다. 예를 들어 아래와 같이 사용할 수 있다.
const int &aRef = 10;
// aRef는 10에 대한 상수 참조로 연장된 임시값을 가리킴
int x = aRef; // aRef의 값, 즉 10을 x에 복사
레퍼런스를 리턴하는 함수 정리
- 사이트에 올라와 있는 잘 정리된 표다.
함수에서 값 리턴 | 함수에서 참조자 리턴 | |
---|---|---|
값 타입으로 받음 (int a = f()) | 값 복사됨 | 값 복사됨. 다만 지역 변수의 레퍼런스를 리턴하지 않도록 주의 |
참조자 타입으로 받음 (int& a = f()) | 컴파일 오류 | 가능. 다만 마찬가지로 지역 변수의 레퍼런스를 리턴하지 않도록 주의 |
상수 참조자 타입으로 받음 (const int& a = f()) | 가능 | 가능. 다만 마찬가지로 지역 변수의 레퍼런스를 리턴하지 않도록 주의 |
- 크게 신경써야 할 부분은 두 가지로 첫 번째로 참조자에 임시 값을 대입할 수 없다는 거고(“컴파일 오류”), 참조자를 반환할 때 임시 값을 반환하면 안된다는 것이다. 이거만 신경쓰면, 큰 어려움 없이 사용할 수 있을 것 같다.
이 외에 복수 참조자, 배열의 참조자 등에 대해서도 시간이 나면 알아보도록 하자.
ex03: Unnecessary violence
위에서 참조자에 대해 이야기 할때, 선언할 때 반드시 어떤 변수를 참조할 것인지 정해야 한다고 말한 바 있다. 이건 “반드시”다. 클래스가 생성될 때 반드시 생성자를 호출해야 한다는 것과 같은 맥락에서 이야기 할 수 있는데, 이러면 초기화에 대한 실수가 현저하게 적어지게 된다. 레퍼런스는 NULL을 허용하지 않는다. 반면 원시 포인터는 NULL을 가리킬 수 있다. 그 점을 알고 클래스를 만들면 되겠다.
Weapon 클래스 포인터를 갖는 HumanB
class HumanB
{
private:
Weapon *param;
std::string name;
public:
HumanB(std::string name);
// .. implementation
};
- Weapon 클래스 레퍼런스를 갖는 HumanA
class HumanA
{
private:
Weapon ¶m;
std::string name;
public:
HumanA(std::string name, Weapon &weapon);
// ... implementation
};
HumanB 생성 시 유의 사항
- 레퍼런스 열심히 잡다가 원시 포인터를 다루면 좀 숨막힐 때가 있다. HumanB를 생성할 때 아마
HumanB::HumanB(std::string name)
{
this->name = name;
this->param = NULL; // <-- 중요
}
이런 식으로 생성할 수 있을 것 같은데, 이때 param을 NULL로 설정하는 걸 잊으면 안된다! 생성자에서 손 안대면 Weapon* param은 NULL인거 아닌가요? 아니다. 이러면 C에서 하던 실수를 그대로 반복하는 거다. this->param = NULL
을 생략하게 되면 HumanB의 Weapon* param은 NULL이 아니라 컴파일 타임에 스택에서 생성된 어떤 빈 const Weapon 클래스(문자열 리터럴을 생각해보자, 거의 동일한 경우이다)를 가리키게 된다(포인터 값도 존재한다). 물론 이 빈 Weapon 클래스의 type은 ““이다.(getType()이 빈 문자열을 반환한다) 왜 const냐. 아래를 보자.
HumanB::HumanB(std::string name)
{
this->name = name;
// this->param = NULL;
}
void HumanB::attack()
{
this->param->setType("Gun"); // 절름발이가 범인
std::cout << this->name << " attacks with their " << \
isArmed(this->param) << std::endl;
// isArmed()는 Weapon*의 NULL 여부를 확인하고, NULL이 아니라면
// getType()을 반환함
}
int main()
{
{
Weapon club = Weapon("crude spiked club");
HumanB jim("Jim");
jim.attack();
club.setType("some other type of club");
jim.attack();
}
}
- 메인문은 서브젝트 것을 그대로 갖고 왔다. 이러면 attack에서 무엇이 출력될까? Gun으로 attack 했다고 할까? 아니다.
zsh: bus error ./Weapon
// 문자열 리터럴(char* s = apple)의 인덱스를 변경할 때 발생하는 버스 에러와 동일한 종류의 버스 에러로 보인다.
- 끔찍한 버스 에러가 발생한다. 범인은
this->param->setType("Gun");
이다. HumanB jim의 Weapon* param은 초기화되지 않아서 어떤 const Weapon을 가리키고 있는데, const Weapon을 수정하려고 하니 버스 에러가 발생하는 것이다. 사실 Weapon* param의 NULL 여부가 아니라 Weapon* param의getType()
이 빈 문자열이냐 아니냐를 여부로 HumanB의 무기 유무를 판단하면 Weapon* param을 NULL로 초기화하지 않아도 이 문제에서 벗어날 수 있기는 한데, 별로 유쾌한 상황은 아니라고 생각한다. 원시 포인터를 다룰 땐 초기화를 신경쓰도록 하자.