이전에 배웠던 구조체처럼 클래스 역시 내가 만든 자료형이라고 할 수 있다.
그러나 C가 C++로 넘어가면서, 클래스에서는 접근 제한 지정자라는 것을 사용할 수 있게 되었다.
- 접근 제한 지정자
private, protected, public으로 나뉜 접근 제한 지정자는 그 종류에 따라 해당 클래스에 접근할 수 있는 권한을 제한한다.
- 생성자와 소멸자
또한 기존의 구조체가 객체를 하나 만들어내면 그 객체를 초기화해 주는 함수를 개발자가 직접 호출해주어야 했던 것과 달리, 클래스는 생성자라는 초기화 함수를 객체 생성 시 자동으로 호출하여 사용자가 초기화 관련 실수를 하지 않도록 해준다.
그러나 이러한 생성자는 엄연히 말하자면 여러 단계를 거친다는 점에서 정석적인 초기화 방법이 아니다.
이를 보완하기 위해 사용하는 것이 이니셜라이져라는 초기화 문법이다.
아래의 코드를 통해 지금까지 설명한 내용을 확인해 보자.
// <main.cpp>
#include "Arr.h"
struct tMy
{
};
class CMy
{
// 접근 제한 지정자
// private, protected, public
private:
int m_i;
float m_f;
public:
void SetInt(int i)
{
m_i = i;
}
public:
// 생성자
CMy() // 객체가 만들어짐
: m_i(100) // 이니셜라이져 (:) -> 초기화 문법
, m_f(0.f) // 초기화 할 변수가 여러 개 일때는 (,)를 써서 이어간다.
{
m_i = 100; // 만들어진 객체 공간에 접근해서 변수를 할당한다.
// 이러한 생성자는 객체가 만들어질 때 자동으로 호출된다.
// 생성자는 엄연히 말해서 초기화라고 할 수 없다.
// 진짜 초기화라면 호출과 동시에 값이 생겨야하는데,
// 이 경우는 1. 객체 생성 2. 변수 할당 2단계로 나눠져 있기 때문이다.
// 이러한 문제를 해결하기 위해 사용하는 것이 바로
// 이니셜라이져라는 초기화 문법이다.
}
};
int maun()
{
CMy c;
c.SetInt(10);
return 0;
}
이전에 가변 배열에 대해서 학습할 때, 데이터를 해제하는 함수를 다룬 적이 있다.
이때 해제 함수는 호출 시 해당 호출 주소로 들어온 객체 안의 멤버를 free() 함수로 메모리 해제하는 역할을 했었다.
이와 같이 객체가 사라질 때 꼭 해야만 하는 어떤 일들이 있을 수도 있다.
그런 것들을 개발자가 호출하는 것을 잊어버리면 문제가 발생할 여지가 있다.
그래서 C++에서는 객체가 없어질 때 자동으로 이러한 기능들을 호출되게 해주는 소멸자(~)라는 문법이 추가되었다.
~CMY() // 소멸자 : ~ 기호 뒤에 해당 클래스 이름과 동일하게 적어주면 된다.
{
}
현재 앞서 살펴본 main.cpp의 main 함수에서는 CMy라는 클래스를 c라는 지역 변수로 선언하였다.
따라서 이 c라는 지역 변수는 main 함수가 종료될 때 메모리가 해제된다.
이는 디스어셈블리로 확인해 보면 더욱 명확하다.
컴파일러가 코드를 분석해서 자동으로 소멸자를 호출했다는 것을 알 수 있다.
- 디폴트 생성자와 디폴트 소멸자
혹시 생성자와 소멸자를 입력하는 것을 깜빡하고 별도로 선언해주지 않더라도 컴파일러는 자동으로 이것을 만들어서 호출해 준다. 즉 코드 상에선 보이지 않지만 컴파일 과정에서 이것들이 만들어진다. 문법 규칙상 객체를 만들어 노출시키기 위해서는 생성자와 소멸자가 반드시 존재해야 하기 때문이다.
대신 이렇게 자동으로 컴파일러가 만들어준 생성자와 소멸자는 구색상 존재 해야 하기 때문에 강제로 만들어진 것이므로 아무런 기능이 없기 때문에, 초기화되지 않으며 소멸자 역시 아무 일도 하지 않는다.
이러한 자동 생성 생성자와 소멸자를 디폴트 생성자, 디폴트 소멸자라고 한다.
- 멤버 함수
해당 클래스가 사용하는 전용 함수를 멤버 함수라고 한다.
이를 호출하기 위해서는 해당 클래스의 객체가 필요하다.
만약 멤버 함수가 아닌 전역 함수로 선언하면 그 함수가 작동하기 위해서는 객체를 알아야 하고, 객체가 지정한 값을 알아야 한다. 모든 객체가 사용 가능해야만 하기 때문이다.
반면 멤버 함수는 호출시킨 객체가 명확하기 때문에 눈에는 보이지 않지만 함수를 호출시킨 객체의 주소가 함께 존재하게 된다. 이를 멤버 함수에서는 this 키워드로 나타낸다.
즉, 멤버 함수를 객체를 통해서 호출하면 해당 객체의 주소가 this 포인터로 전달된다.
class CMy
{
public:
void SetInt(int i)
{
this->m_i = i;
}
}
this 키워드는 해당 멤버 변수를 호출한 객체의 주소 타입을 이야기한다.
이를 기존에 배웠던 C 스타일로 구현하려면 다음과 같았을 것이다.
public :
void SetInt(CMy* _this, int i)
{
_this->m_i = i;
}
하지만 이것을 일일이 구현하는 것이 불편하기 때문에, 이를 감춰두고 대신 this 키워드를 사용하는 것이다.
함수가 호출될 때 this라는 자리에 전달받기 때문에 위에서 살펴본 코드를 다시 보면, 기존의 자리를 this가 대신하고 있다.
class CMy
{
public:
void SetInt(int i)
{
this->m_i = i;
}
}
디버그 모드로 로컬(※)을 확인해 보면 아래와 같이 this가 인자로 받는 것이 CMy* 형식임을 알 수 있다.
※ 로컬은 현재 호출된 함수의 지역 변수들을 보여주는 곳이다.
하지만 멤버 함수에서는 지칭하는 멤버가 당연히 이것을 호출한 객체일 것이라는 것이 명확하기 때문에 아래 코드처럼 this 키워드마저도 생략할 수 있다.
public :
void SetInt(int i)
{
m_i = i;
}
- 클래스끼리의 대입
이번에는 클래스끼리의 대입에 대해서 알아보자. 아래와 같이 처리된 코드가 있다고 가정한다.
CMy c;
c.SetInt(10);
CMy c2;
c.SetInt(100);
CMy c3;
c.SetInt(1000);
c3 = c2;
마지막 코드인 c3 = c2; 를 실행하는 것은 일반 변수에서 다음과 같은 처리를 하는 것과 같다.
int i = 0;
int a = 10;
i = a; // i에 10이 할당된다
int의 경우 이미 데이터의 크기와 형식이 정해져 있기 때문에 위와 같은 코드를 실행했을 때 i에 10이 할당되는 것은 매우 자연스럽다. 하지만, 클래스 변수인 c3에 c2의 값을 할당하는 일은 어떻게 가능한 걸까?
바로 대입 연산자 때문이다. 이 대입 연산자는 생성자를 만들지 않아도 컴파일러에서 디폴트 생성자를 자동으로 만들어주었던 것처럼, 자동으로 만들어지는 것 중 하나이다.
- 대입 연산자
class CMy
{
private:
int m_i;
float m_f;
public:
void SetInt(int i)
{
m_i = i;
}
// 대입 연산자
const CMy& operator =(const CMy& _Other)
{
m_i = _Ohter.m_i;
m_f = _Ohter.m_f;
return *this;
}
public:
CMy()
: m_i(100)
, m_f(0.f)
{
m_i = 100;
}
};
상기 코드 중 대입 연산자 부분은 사용자가 입력하지 않아도 자동으로 생성되는 내부 코드를 적은 것이다.
이런 코드가 어떻게 자동으로 생성될 수 있는 걸까?
각 클래스의 객체끼리 멤버의 합산을 하려는 상황을 가정해 보자.
단순히 더하는 연산만 생각했을 때는 다음과 같은 코드를 생각해 볼 수 있을 것이다.
CMy c;
CMy c2;
CMy c3;
c3 + c2;
그렇다면 이 연산이 발생했을 때 호출될 함수가 있어야 하지 않을까?
(+)와 같은 연산자도 특정 기능을 하는 일종의 함수라고 생각한다면 클래스 멤버끼리의 합산을 하는 기능 역시
사용자가 원하는 별도의 기능을 가지고 있으므로 이러한 연산을 하는 함수를 직접 만들어야 한다는 뜻이다.
c3 = c2;
하지만 대입 연산자 같은 경우는 사용자가 만들지 않아도 클래스에 대해서 자동으로 만들어 주고 있었기 때문에 바로 적용이 가능하다. 대입 연산자 외의 연산자들은 사용자가 직접 만들어야 한다는 뜻이다. 그럼 대입 연산자를 사용했을 때 자동으로 해당 기능 함수가 호출된 것처럼 될 것이다.
이러한 기능을 클래스에서는 연산자 오버로딩이라고 부른다.
이어서 내용을 학습하기 전에 잠시 헷갈리기 쉬운 포인터 관련 연산과 이번에 새롭게 배울 레퍼런스에 대해 짚고 넘어가자.
자료형 * 변수명 : 자료형 포인터 타입 변수 선언
*포인터변수 : 포인터 변수 안의 주소를 역참조
&변수; : 본인의 주소 값을 반환
자료형 & 변수명; : 레퍼런스(참조) 변수 선언 -> 원본에 접근할 수 있는 방법 중 하나로 C++에서 새롭게 추가됨
- 레퍼런스
레퍼런스의 기능은 아래의 코드와 비슷하다고 볼 수 있다.
int a = 10; // a라는 변수가 있다.
int* p = &a; // 이 a 변수의 주소를 p라는 int형 포인터에 받아온다.
*p = 100; // p에 담긴 주소를 역참조하여 수정한다.
위의 코드를 레퍼런스를 사용해 비슷하게 나타내면 다음과 같다.
int& iRef = a; // iRef라는 변수가 a를 참조한다.
iRef = 100; // a가 참조된 상태인 iRef의 값을 바꾼다.
a라는 변수의 주소를 받아서 주소로 접근을 해서 거기를 수정한다는 뜻이기 때문에 이는 포인터와 다를 게 없다.
어떻게 이렇게 작동하는 것이 가능할까?
레퍼런스 사용 코드를 어셈블리 코드로 확인해 보면 포인터와 동일하게 작동함을 알 수 있다.
즉, 레퍼런스와 포인터는 컴파일러 입장에서는 똑같은 기능을 한다.
그렇다면 왜 포인터가 아닌 레퍼런스를 쓰는 걸까?
컴파일러 입장에서 컴파일된 코드는 결국 똑같아도 사용자는 이를 C++ 문법으로 사용한다.
그렇기 때문에 사용자 입장에서는 호출 방식 자체가 분명히 다르다.
레퍼런스는 포인터에 const가 붙은 경우와 비교하면 쉽게 이해할 수 있다.
int a = 10;
int* const p = &a;
*p = 100;
위의 코드처럼 const가 수식하는 부분이 변수명이면 그것은 이 포인터 변수 자체를 상수화 하겠다는 의미이다.
이렇게 되면 이 포인터 변수는 더 이상 a라는 변수의 주소를 제외한 다른 변수의 주소를 받아올 수 없게 된다.
레퍼런스도 이와 마찬가지로 선언과 동시에 이 레퍼런스 변수가 특정 변수를 참조하게 되면 그 뒤로는 다른 변수를 참조하려고 해도 그것은 곧 현재 참조된 변수 값을 수정하려는 것으로 받아들여질 뿐 다른 변수를 참조할 수는 없게 된다.
즉, int& iRet = a; 코드는 'iRet과 a는 지금부터 동일 취급이 된다'는 것이다.
그렇기 때문에 '나(iRef)를 100으로 수정한다는 것은 (iRef= = 100;) 너(a)를 수정하는 것과 같다'라고 하는 것이다.
이렇게 레퍼런스를 썼을 경우의 장점은 (포인터와 똑같기 때문에 장점이라고 하기에는 애매하지만) 다음과 같다.
1. 한번 참조를 받으면 다른 것으로 바꿀 수 없기 때문에 주소 연산의 실수를 할 이유가 없다
2. 레퍼런스는 원본을 수정할 때 역참조에 해당하는 포인터 연산을 할 필요가 없어 원본을 바로 바꿀 수 있다.
2번째 장점 때문에 레퍼런스 밖에 할 수 없는 부분들이 존재하기도 한다.
매번 포인터로 역참조 연산을 명시해 줘야 원본으로 접근할 수 있는 포인터 변수와 달리 본인이 참조받자마자 바로 자기가 그 변수를 즉시 수정할 수 있는 형태이기 때문에 나중에 원하는 형태의 함수를 설계하는 데 있어서 중요한 점이 있다.
그러므로 호출 방식 자체의 형태의 변화가 레퍼런스의 중요한 의미 중 하나다.
또한 주소값을 즉시 얻어와서 쓰는 경우와 달리, 레퍼런스는 주소값을 바꿀 순 없지만 이 안에 참조받은 특정 주소값이 들어 있다는 것이 명확하다. 이렇듯 확실히 값을 보유하고 있지만 주소 개념이 문법적으로 보이지 않는다는 특성 때문에
기존의 포인터가 가진 문제점 중 하나였던, 주소로 바로 접근을 해서 안에 내용을 변경시킬 수 있는 주소 관련 실수의 위험요소가 없다. 따라서 원래 의도했던 것보다 더 오버해서 잘못된 주소로 접근하는 등의 일은 없어지는 것이다.
- 레퍼런스를 통해 참조를 받았지만 원본은 수정할 수 없게 하고 싶은 경우
이번에는 특정 변수를 레퍼런스를 사용해 참조 했으나, 그 원본이 아래 코드의 const 포인터처럼 수정되지 않고 언제나 하나의 값을 유지하도록 하고 싶은 경우에는 어떻게 구현해야 하는지에 대해 알아보자.
// const 포인터를 사용한다면 아래처럼 할당된 주소 안의 데이터 값을 수정하는 코드가 성립되지 않는다.
const int* p2 = &a;
//*p2 = 10; // 값을 수정하는 것이 불가능하므로 잘못된 코드이다.
레퍼런스 변수도 const 레퍼런스를 하면 위와 동일하게 수정이 불가능하다.
const int& iRefConst = a;
//iRefConst = 10;
const 레퍼런스는 예시 코드로 작성한 포인터 식으로 치차면 const int* const p2 = &a; 와 같다.
레퍼런스 자체가 처음 한 번만 유일한 주소를 참조받을 수 있는 특성을 가지고 있기 때문에
거기에 앞쪽까지 const를 붙이면 '한 번 유일한 주소를 참조하고 나면 그 주소에 있는 값도 수정할 수 없는 포인터'와
거의 유사하게 작동하게 된다.
포인터의 경우는 const가 무엇을 수식하냐에 따라 작동하는 방식이 2가지 존재했지만 레퍼런스의 경우는 그런 방식을 나눌 필요 없이 오직 하나의 경우로만 작동한다는 것이다.
- const 레퍼런스를 사용하는 이유
const 포인터를 사용하는 이유와 동일하게, const 레퍼런스는 원본을 전달하는 비용을 적게 하고 싶은 경우에 사용하면 유용하다. 전달해야 할 원본의 크기가 굉장히 큰 경우, 복사 비용이 부담이 된다. 이 때 포인터 변수는 항상 주소를 전달해 줄 때 고정 사이즈를 알기 때문에 주소를 통해서 접근할 수 있다.
레퍼런스도 비슷하게 원본 자체에 대해서 참조를 주가 때문에 원본 전체를 복사하는 비용을 줄일 수 있다.
따라서 바닥 안쪽에서도 참조 변수로 받아온 것을 수정하면 곧 그게 원본을 수정하게 되는 것이다.
하지만 이때 상대쪽에서 값을 읽어볼 수 있게 참조로 주소 값을 전달하되, 원본이 수정되기는 원치 않은 경우 const 포인터를 써서 인자를 받는 것처럼 const 레퍼런스도 동일하게 사용할 수 있다. - 연산자 오버로딩의 규칙
이번에는 앞서 배웠던 대입 연산자를 통해 연산자 오버로딩의 규칙을 알아보겠다.
class CMy
{
private:
int m_i;
float m_f;
public:
void SetInt(int i)
{
m_i = i;
}
// 대입 연산자
const CMy& operator =(const CMy& _Other)
{
m_i = _Ohter.m_i;
m_f = _Ohter.m_f;
return *this;
}
대입 연산자 부분을 다시 살펴보니 넘겨받는 인자를 레퍼런스로 참조하는 것을 확인할 수 있다. (const CMy& _other)
이는 이 (=) 연산자 함수를 호출한 객체가 인자로 들어오는 것을 의미한다.
그러면 다시 아래의 대입 연산자 코드를 살펴보자.
c3 = c2;
c3가 대입 연산자를 호출한 객체이다.
멤버 함수들의 경우에는 c3.SetInt(1000); 처럼 (.)을 이용해 호출했지만 오퍼레이터 함수의 멤버 함수들은 연산자가 호출 됐을 때 함께 호출된다. 그렇기 때문에 여기서 this 안에는 c3의 객체 주소가 담겨져 전달이 되고, 대입 연산자에서 선언한 인자 안에는 c2가 들어가게 되는 것이다.
내부적인 작동 모습을 확인하기 위해 더 자세히 코드를 작성하면 다음과 같을 것이다.
// 대입 연산자
const CMy& operator =(const CMy& _Other)
{
this->m_i = _Ohter.m_i;
this->m_f = _Ohter.m_f;
return *this;
}
대입 연산자 호출과 함께 this에는 c3의 객체 주소가 담긴 채로 전달되고, _other 인자에는 c2 객체를 참조 받게 된다.
그런데 이 상황에서 c2의 원본이 수정될 일은 없으므로 참조를 할 때 const 레퍼런스를 사용해서 받아온 것이다.
이때 c2가 추후에 다시 함수를 호출시켜 c3가 인자로 들어오는 것과 같이 연쇄적인 상황이 있을 수 있으므로
반환 타입으로 *this를 넣어 그 원본(여기선 c2)을 참조로 리턴해주게끔 하였다.
그럼 지금까지의 내용을 정리해 보자.
<C++ 클래스 특징>
1. 접근제한 지정자
2. 클래스 내의 멤버변수 or 멤버함수의 접근 레벨
<생성자, 소멸자>
1. 객체 생성, 소멸 시 자동으로 호출
2. 직접 만들지 않으면 기본 생성자, 기본 소멸자가 만들어짐
<멤버함수>
1. 해당 클래스가 사용하는 전용 함수
2. 해당 클래스의 객체가 필요함
3. 멤버함수를 객체를 통해서 호출하면, 해당 객채의 주소가 this 포인터로 전달된다.
<포인터 연산과 레퍼런스>
자료형 * 변수명 : 자료형 포인터 타입 변수 선언
*포인터변수 : 포인터 변수 안의 주소를 역참조
&변수; : 본인의 주소 값을 반환
자료형& 변수명; : 레퍼런스(참조) 변수 선언
<레퍼런스>
원본에 접근할 수 있는 방법 중 하나로 C++에서 새롭게 추가된 기능이다.
포인터와 유사하지만 포인터 변수가 '원본의 주소를 가져온다'라고 표현된다면
레퍼런스는 '원본을 참조한다'라고 표현할 수 있다.
레퍼런스 변수를 선언하면 원본과 할당된 변수가 동일하게 취급된다.
이는 const 포인터와 비슷한 역할을 한다.
레퍼런스가 포인터와 다른 점은, 기존의 포인터가 역참조 연산을 필요로 한 것과 달리
레퍼런스는 바로 참조받은 변수를 통해서 직접적으로 원본을 수정할 수 있다.
따라서 주소를 잘못 다루면서 파생되는 문제의 여지가 없다.
<const 레퍼런스의 활용>
const 포인터처럼 const 레퍼런스는 원본을 전달하는 비용을 적게 하고 싶은 경우에 사용할 수 있다.
'C,C++' 카테고리의 다른 글
함수 템플릿, 클래스 템플릿, 클래스 템플릿 리스트 (0) | 2023.01.31 |
---|---|
클래스를 이용한 배열 (0) | 2023.01.31 |
함수 포인터 (0) | 2023.01.27 |
리스트 (0) | 2023.01.26 |
가변 배열 (0) | 2023.01.24 |