- 생성자 재방문
1. 이전에 생성자를 구현할 때 멤버 변수를 초기화하는 방식으로 함수 내부에서 대입을 사용했었다. 하지만 생성자의 초기화에는 다른 방법도 존재한다. 바로 초기화 리스트(Initialization List)이다. 초기화 리스트는 콜론(:)으로 시작하고, 쉼표로 구분된 멤버 변수 목록이 뒤따르며, 각각의 멤버 변수 뒤에는 괄호 안에 초기값 또는 표현식이 온다.
2. 초기화 리스트에서는 각 멤버 변수가 한 번만 나타날 수 있으며, 문법적으로 초기화 리스트는 멤버 변수가 정의되고 초기화되는 위치로 간주할 수 있다.
이처럼 초기화 리스트는 괄호 내의 값들이 외부 변수를 초기화하는 방식이며, 함수 내에서 초기화하는 것과 유사하다:
함수 내부에서 초기화하는 방식은 위와 같다. 두 경우 모두 초기화 작업을 수행하므로, 이 두 방식을 함께 사용할 수 있는지 궁금할 수 있다. 답은 가능하다:
이처럼 두 방식을 섞어 쓸 수 있으므로, 함수 내 초기화와 초기화 리스트 사이에는 어떤 차이점이 있는가? 이러한 방식들이 동시에 존재하는 이유는 무엇인가? 다음 특성 설명에서 확인해보자:
3. 참조 멤버 변수, const 멤버 변수, 기본 생성자가 없는 클래스 타입의 변수는 반드시 초기화 리스트에서 초기화되어야 하며, 그렇지 않으면 컴파일 오류가 발생한다.
참조 멤버 변수와 const 멤버 변수를 살펴보면 공통점이 있는데, 바로 정의 시점에 반드시 초기화되어야 한다는 점이다. (이전에 참조와 const 멤버 변수에 대해 다룬 적이 있으므로 자세한 설명은 생략한다.)
만약 초기화 리스트에서 이러한 변수들을 초기화하지 않으면 컴파일 에러가 발생한다:
따라서 특성에 따라 다음과 같이 작성해야 한다:
이 코드는 문법적으로 문제가 없지만 큰 단점이 있는데, _ret이 참조형이므로 생성자 종료 후 소멸되며, 그 파라미터 year도 함께 소멸되어 '유령 참조' 문제가 발생한다. 따라서 올바른 방식은 다음과 같다:
생성자에 참조 형식의 매개변수를 추가하여 _ret이 x의 별칭이 되도록 하고, 생성 시 xx 값을 이용하여 초기화하면, xx가 소멸되지 않아 참조 문제를 해결할 수 있다.
기본 생성자가 없는 클래스 타입 변수 역시 초기화 리스트에서 초기화되어야 한다:
class Time
{
public:
Time(int hour)
:_hour(hour)
{
cout << "Time()" << endl;
}
private:
int _hour;
};
위 코드는 Time 클래스를 생성한 예시이며, Date 클래스 내부에 _t라는 멤버 변수가 선언되어 있다. 초기화 리스트에서 초기화하지 않으면 다음과 같은 오류가 발생한다.
4. C++11부터 멤버 변수 선언 시 기본값을 지정할 수 있으며, 이 값은 초기화 리스트에서 명시적으로 초기화되지 않은 멤버 변수에 사용된다.
"멤버 변수 선언 위치에서 기본값 제공"이라는 의미는 다음과 같다:
참조 멤버 변수나 const 멤버 변수에도 기본값을 설정할 수 있는가? 가능하다. 다만 기본값과 초기화 리스트에서 값을 지정했을 경우, 어떤 값이 사용될지는 초기화 리스트의 값이 우선한다. 이에 대한 논리 순서를 아래와 같이 정리할 수 있다:
이 논리에 따라 다음과 같은 두 가지를 알 수 있다:
5. 가능한 한 초기화 리스트를 사용하는 것이 좋다. 왜냐하면 초기화 리스트에 명시되지 않은 멤버도 초기화 리스트를 통해 초기화되기 때문이며, 모든 멤버는 초기화 리스트를 거친다. 만약 선언 시 기본값이 주어졌다면, 초기화 리스트는 해당 기본값으로 초기화한다. 기본값이 주어지지 않았다면, 내장 타입 멤버는 컴파일러에 따라 초기화 여부가 달라질 수 있고, 사용자 정의 타입 멤버는 해당 타입의 기본 생성자를 호출하게 되며, 기본 생성자가 없는 경우 컴파일 오류가 발생한다.
6. 초기화 리스트는 멤버 변수가 클래스 내 선언된 순서대로 초기화되며, 초기화 리스트에 나열된 순서는 무관하다. 선언 순서와 일치시키는 것을 권장한다. (중요한 포인트)
이처럼 모든 멤버가 초기화 리스트를 거치므로, 이후 모든 초기화는 초기화 리스트만으로 처리할 수 있을까? 예를 들어 배열을 초기화해야 하는 경우, 초기화 리스트만으로는 불가능하다. 따라서 복잡한 변수 초기화는 함수 내에서 처리해야 한다.
초기화 리스트에 대한 설명은 여기까지이며, 이제 혼란스러운 문제를 제시한다:
정답은 D이다. 댓글에서 논의해보자.
- 형변환
C 언어에서 형변환은 주로 정수형 간, 정수형과 실수형 간, 정수형과 포인터 간, 그리고 포인터 간에 이루어졌다. 구조체와 정수형 간에는 형변환이 불가능한데, 이 둘은 관련성이 없기 때문이다.
C++에서는 이러한 내용을 보완하여 내장 타입과 사용자 정의 타입 간 형변환을 지원한다:
1. C++은 내장 타입을 클래스 타입으로 암묵적으로 변환할 수 있으며, 이는 해당 내장 타입을 인자로 받는 생성자가 필요하다.
첫 번째 사례를 아래 코드로 확인하자:
동일한 클래스 타입의 객체에 대해 aa1=1을 할당할 때, 하나는 사용자 정의 타입이고 하나는 내장 타입 int이다. 생성자에 내장 타입 매개변수가 포함되어 있으면 코드가 정상적으로 컴파일되고 실행되며, 그렇지 않으면 오류가 발생한다.
이 형변환은 어떻게 작동하는가? 기본적으로는 1을 사용하여 A 타입의 임시 객체를 생성하고, 이 임시 객체를 사용하여 aa1을 복사 생성한다. 컴파일러는 연속된 생성 및 복사 생성을 만나면 직접 생성으로 최적화한다.
그런데 아래 코드는 왜 컴파일되지 않는가?
A 타입의 별칭이 int 값을 참조할 수 없는 이유일까? 아니다. 앞서 설명한 바와 같이 암묵적 형변환이 자동 적용된다. 이유는 무엇일까? 이는 참조 학습 중 자주 실수하는 부분과 관련이 있다: 여기서는 1의 임시 복사본을 참조하고 있으며, 임시 변수는 상수 특성을 가지므로 직접 참조하면 접근 권한 확장 문제가 발생한다.
따라서 올바른 방식은 다음과 같다:
형변환의 활용 목적은 무엇일까? 보기에는 불필요해 보이지만 실제로는 편리하다. 예를 들어 스택에 A 타입 변수를 넣고 싶을 때, aa2를 먼저 생성하고 값을 2로 설정한 후 Push 함수에 전달해야 했다. 그러나 C++에서는 형변환 덕분에 2만 전달하면 된다. 내장 타입 2가 자동으로 A 타입으로 변환되기 때문이다.
또한, 생성자에 하나의 인자만 전달하여 _a1을 초기화할 수 있었다면, 두 개의 값을 전달하여 _a1과 _a2 모두 초기화할 수 있을까? 가능하지만 문법이 다르다:
매개변수는 두 개여야 하며, 호출 시 {}를 사용해야 한다. (1,2)로 쓰면 컴파일러는 쉼표 표현식으로 해석하여 예상과 다른 결과를 초래한다. 스택의 Push 함수 호출 시에도 다음과 같이 작성해야 한다:
2. 생성자에 explicit 키워드를 붙이면 암묵적 형변환을 지원하지 않는다.
explicit는 키워드로, 암묵적 형변환을 원하지 않을 때 사용한다. 일반적으로 사용하지 않으며, 스마트 포인터에서 사용된다.
3. 클래스 타입 간에도 암묵적 형변환이 가능하며, 이는 해당 생성자가 필요하다.
이전에는 내장 타입에서 클래스 타입으로의 형변환을 다뤘으나, 클래스 간 형변환도 가능하다.
다음은 B 타입을 추가한 예시이다:
bb1이 aa1의 값으로 초기화되었으며, 컴파일 오류 없이 정상 작동한다.
- static 멤버
먼저 시나리오를 통해 static 멤버의 개념을 설명한다. 프로그램에서 생성된 클래스 객체 수를 계산하고 싶다고 가정하자. 어떻게 해야 할까?
전역 변수 _scount를 생성하고 생성자나 복사 생성자 호출 시마다 _scount 값을 증가시키면 된다. 생성자는 항상 호출되므로 최종적으로 객체 수를 얻을 수 있다.
그러나 이 방식에는 문제가 있다. _scount는 전역 변수이므로 누구나 수정할 수 있어 위험하다. 이를 방지하기 위해 _scount를 정적 전역 변수로 설정해야 한다:
이렇게 하면 _scount는 특정 객체의 변수가 아니라 클래스 전체의 멤버 변수가 되며, 모든 객체가 동일한 _scount를 공유한다.
이전에 언급한 바와 같이 "객체의 모든 멤버 변수는 초기화 리스트를 통해 초기화되며, 기본값이 있는 경우 기본값으로 초기화된다". _scount는 객체의 멤버가 아니므로 초기화 리스트를 통과하지 않으며, 기본값도 필요하지 않다. 따라서 컴파일러는 오류를 발생시킨다.
_scount는 클래스 밖에서 초기화해야 한다:
또 하나의 문제는 _scount 값을 출력하여 확인하고 싶지만 _scount가 private이므로 어떻게 접근할 수 있을까? 이럴 때 공용 멤버 함수를 만들어 _scount 값을 반환하도록 하면 된다. 어느 객체의 공용 함수를 호출하든 _scount는 동일한 변수이므로, 결과는 같다.
또한, C++에서 A 타입의 배열을 인스턴스화하면, 배열 내 각 요소는 A 타입 객체이므로 각각 초기화되며, 이때 각 요소에 대해 생성자가 호출된다:
결과는 13이며, aa1부터 aa3까지 3번, aa4 배열은 10개의 객체로 구성되어 10번 호출됨을 보여준다.
static 멤버 변수 외에도 static 멤버 함수가 존재하며, 가장 큰 특징은 this 포인터가 없다는 것이다:
static 멤버 함수는 static 멤버 변수를 접근하는 데 사용되며, 여기서는 자세히 설명하지 않는다.
static 멤버에 대한 요약은 다음과 같다:
1. static 키워드가 붙은 멤버 변수는 정적 멤버 변수로, 반드시 클래스 밖에서 초기화되어야 한다.
2. 정적 멤버 변수는 모든 클래스 객체가 공유하며, 특정 객체에 속하지 않고 정적 영역에 저장된다.
3. static 키워드가 붙은 멤버 함수는 정적 멤버 함수로, this 포인터가 없다.
4. 정적 멤버 함수는 다른 정적 멤버에 접근할 수 있지만 비정적 멤버에는 접근할 수 없다.
5. 비정적 멤버 함수는 모든 정적 멤버 변수와 함수에 접근할 수 있다.
6. 클래스 영역을 넘어서면 static 멤버에 접근할 수 있으며, 클래스명::정적멤버 또는 객체.정적멤버로 접근 가능하다.
7. static 멤버는 클래스의 멤버이며, public, protected, private 접근 제어자에 의해 제한된다.
8. static 멤버 변수는 선언 위치에서 기본값을 설정할 수 없으며, 기본값은 생성자 초기화 리스트에 해당하기 때문에 static 멤버 변수는 객체에 속하지 않기 때문이다.
관련 알고리즘 문제를 제시한다:
아래는 참고용 답안이다:
class Sum
{
public:
Sum()
{
_ret += _i;
_i++;
}
static int Get_ret()
{
return _ret;
}
private:
static int _i;
static int _ret;
};
int Sum::_i = 1;
int Sum::_ret = 0;
class Solution {
public:
int Sum_Solution(int n)
{
Sum arr[n];
return Sum::Get_ret();
}
};
- 친구(Friend)
클래스 내부의 private 멤버에 접근할 수 없기 때문에 일부 상황에서 친구 개념이 도입되었다:
1. 친구는 클래스의 접근 제어자를 우회하는 방법으로, 친구 함수와 친구 클래스로 구분된다. 함수 또는 클래스 선언 앞에 friend 키워드를 붙이고, 해당 선언을 클래스 내부에 위치시킨다.
2. 외부 친구 함수는 클래스의 private 및 protected 멤버에 접근할 수 있으며, 친구 함수는 단순한 선언일 뿐 클래스의 멤버 함수가 아니다.
3. 친구 함수는 클래스 정의의 어디든지 선언할 수 있으며, 접근 제어자에 제한되지 않는다.
4. 하나의 함수는 여러 클래스의 친구 함수가 될 수 있다.
이전 글에서 친구 함수는 다음과 같이 이해할 수 있다고 설명했다: 클래스는 집으로 비유되며, 집의 외부는 public으로, 내부는 private으로 구분된다. 주인이 허락하지 않으면 들어갈 수 없지만, 주인이 친구라고 말하면 들어갈 수 있다. 즉, 내부 내용을 볼 수 있게 된다.
네 번째에 대해 보충하자면:
동일한 함수가 여러 클래스의 친구가 될 수 있으나, B 타입에서 A 타입 매개변수와 B 타입 매개변수를 사용할 때, A 타입은 상위에서 찾을 수 있지만, A 타입에서 B 타입 매개변수를 사용할 때 class B를 미리 선언해야 컴파일 오류가 발생하지 않는다.
5. 친구 클래스의 멤버 함수는 다른 클래스의 친구 함수가 되며, 다른 클래스의 private 및 protected 멤버에 접근할 수 있다.
6. 친구 클래스 관계는 단방향이며 교환 불가능하다. 예를 들어 A가 B의 친구라면 B는 A의 친구가 아니다.
예시처럼, B 클래스에서 A 클래스의 private 멤버에 접근하고 싶다면 A의 친구로 등록해야 한다. 그러나 반대는 불가능하며, 이는 단방향성임을 보여준다.
7. 친구 관계는 전이적이지 않다. A가 B의 친구이고 B가 C의 친구라면 A는 C의 친구가 아니다.
8. 편리함을 제공하지만, 결합도를 증가시키고 캡슐화를 깨뜨리므로 과도하게 사용하지 않는 것이 좋다.
- 내부 클래스
1. 다른 클래스 내부에 정의된 클래스를 내부 클래스라고 한다. 내부 클래스는 독립적인 클래스로, 전역 범위에 정의된 것과 비교하면 외부 클래스의 클래스 영역과 접근 제어자에만 제한된다. 따라서 외부 클래스의 객체에는 내부 클래스가 포함되지 않는다.
2. 내부 클래스는 기본적으로 외부 클래스의 친구 클래스이다.
A 클래스 크기를 구해내는 예제를 통해 내부 클래스와 외부 클래스의 관계를 확인하자. sizeof(A)는 A 클래스의 크기를 반환하며, 결과는 다음과 같다:
A 클래스에는 int가 하나, B 클래스에는 int가 두 개 있으므로 3개의 int = 12바이트가 되어야 하지만, 결과는 4바이트이다. A의 구조를 보면 다음과 같다:
A 타입 객체 aa1을 인스턴스화한 후 디버깅하면 aa1 내부에는 _h 변수만 존재하고 B 타입은 존재하지 않는다. 이는 내부 클래스 형태로 작성되었더라도 A와 B는 포함 관계가 아닌 평행 관계임을 보여준다.
3. 내부 클래스는 캡슐화의 일종이며, A 클래스와 B 클래스가 긴밀하게 연관되어 있고 B가 A만을 위한 경우, B를 A의 내부 클래스로 설계할 수 있다. private 또는 protected에 위치시키면 A의 전용 내부 클래스가 되어 다른 곳에서는 사용할 수 없다.
이 개념은 다음과 같이 이해할 수 있다: 삼국 시대, 장비(刘备)가 아직 성장하지 않았을 때 오래(曹操)에게 투靠했다. 오래는 그의 부하가 아니었고, 자신의 군대를 가지고 있었지만, 투靠한 만큼 제약을 받았다.
여기서 B는 장비, A는 오래이다.
내부 클래스를 배운 후, 이전 알고리즘 문제에 대해 최적화할 수 있다. Sum이 다른 곳에서 호출되면 실제 결과에 영향을 줄 수 있으므로, Sum을 내부 클래스로 설정하고 private으로 제한하여 위험을 방지할 수 있다. 또한 Sum을 Solution의 내부 클래스로 만들면 자동으로 Solution의 친구가 되어 Solution의 private 멤버에도 접근할 수 있으므로 공용 멤버 함수가 필요하지 않다.
class Solution
{
class Sum
{
public:
Sum()
{
_ret += _i;
_i++;
}
};
public:
int Sum_Solution(int n)
{
Sum arr[n];
return _ret;
}
private:
static int _i;
static int _ret;
};
int Solution::_i = 1;
int Solution::_ret = 0;
- 익명 객체
1. 타입(인자)로 정의된 객체는 익명 객체라 하며, 기존의 타입 객체명(인자) 방식은 명명된 객체라고 한다.
익명 객체는 임시 변수와 유사하나, 생성자가 명시적으로 호출된다는 점에서 차이가 있다.
2. 익명 객체의 생명 주기는 현재 행에만 존재하며, 일반적으로 임시로 사용할 때 사용한다.
익명 객체의 생명 주기가 한 행뿐이므로 활용할 수 있는가?
예를 들어, 멤버 함수가 있는 클래스 타입을 사용할 때, 함수를 호출하려면 객체를 먼저 생성해야 하므로 불편하다. 익명 객체는 이러한 문제를 해결해준다.
- 객체 복사 시 컴파일러 최적화
1. 현대 컴파일러는 프로그램 효율을 극대화하기 위해 정확성에 영향을 주지 않는 범위 내에서 전달 및 반환 시 불필요한 복사를 줄이기 위해 최적화한다.
2. C++ 표준은 이러한 최적화 방식을 엄격하게 규정하지 않으며, 각 컴파일러가 상황에 맞춰 처리한다. 최근 컴파일러는 연속된 표현식에서의 복사 최적화를 수행하며, 일부 더 진보된 컴파일러는 여러 행에 걸친 복사 최적화도 수행한다.
아래 코드로 최적화 여부를 직접 확인해보자:
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "디폴트 생성자 호출" << endl;
}
A(A& x)
{
_a = x._a;
cout << "복사 생성자 호출" << endl;
}
~A()
{
cout << "소멸자 호출" << endl;
}
private:
int _a;
};
최적화가 적용되지 않은 경우, 디폴트 생성자 → 임시 객체 생성 → 복사 생성자 → 임시 객체 값 할당 → aa1에 할당의 순서로 진행된다. 그러나 이 경우, 최적화로 인해 디폴트 생성자만 호출된다.
본 문서는 여기서 마무리된다. 잘못된 내용이나 부족한 부분이 있다면 피드백을 환영한다.