나만의 작은 도서관
[C++][Class] 함수 / 생성자 오버로딩(overloading) 본문
오버로딩(Overloading)이란?
- 오버로딩은 같은 이름의 함수를 여러 개 정의할 수 있도록 하는 기능을 의미한다.
- 오버로딩 시 같은 이름의 함수를 호출했을 때 매개변수의 개수나 타입에 따라 서로 다른 함수가 호출되도록 할 수 있다.
- C++에서 생성자나 연산자는 특수 함수로 취급하므로 오버로딩이 가능하다. 따라서 오버로딩의 종류는 아래와 같다.
오버로딩 종류
- 함수 오버로딩(Function Overloading)
- 생성자 오버로딩(Constructor Overloading)
- 연산자 오버로딩(Operator Overloading)
C++ 오버로딩 규칙
- C++에서 오버로딩이 가능한 이유는 컴파일러가 인자 구성이 다른 함수는 이름이 같아도 서로 다른 함수로 취급하기 때문이다.
- 즉, 컴파일러는 함수의 이름만이 아닌 함수의 시그니처가 일치하는지까지 확인하여 구분한다.
- 오버로딩한 함수를 호출하면 컴파일러는 넣어준 인자의 개수와 타입과 일치하는 시그니처를 가진 함수를 선택한다.
- 만약 정확히 일치하는 시그니처를 가진 함수가 없다면 컴파일러는 “가장 근접한 함수”를 찾는다. 전반적인 오버로딩 함수 호출 과정은 다음과 같다.
오버로딩 호출 과정(Overload Resolution)
- 시그니처가 정확히 일치하는 함수를 찾는다. (리턴값 제외)
- 일치하는 함수가 없다면 아래와 같이 캐스팅했을 때 일치하는 함수를 찾는다.
- char, unsigned char, short → int
- unsigned short → int 또는 unsigned int (int의 크기에 따라 결정)
- float → double
- enum → int
- 2번 과정의 캐스팅에도 일치하는 함수가 없다면, 좀 더 포괄적으로 캐스팅했을 때 일치하는 함수를 찾는다.
- 숫자 타입(int, float 등) → 다른 숫자 타입
- enum → 숫자 타입
- 포인터 타입(NULL, nullptr)의 0 ↔ 숫자 타입 0
- 포인터 타입 → void 포인터
- 마지막으로, 유저가 정의한 타입의 캐스팅을 통하여 일치하는 함수를 찾는다.
- 만약, 1) 4가지 과정을 거쳤음에도 근접한 함수가 없거나, 2) 같은 단계에서 일치하는 함수가 여러 개인 경우 컴파일러는 “모호하다(ambigous)”라고 판단하여 컴파일 오류를 발생시킨다.
함수 오버로딩(Function Overloading)
- 같은 이름의 일반 함수를 여러 번 정의하는 경우, 이를 “함수 오버로딩(Function Overloading)”이라고 부른다. (보통 오버로딩은 함수 오버로딩을 의미한다.)
- 아래 코드는 함수 오버로딩 예시이다. 예시의 마지막 문장인 “print(3.2f)” 호출 결과를 보면 오버로딩 규칙에 따라 암시적 캐스팅이 발생하였음을 알 수 있다.
#include <iostream>
using namespace std;
void print(int x) {cout << "int : " << x << '\\n';}
void print(char x) {cout << "char : " << x << '\\n';}
void print(double x) {cout << "double : " << x << '\\n';}
int main(void){
print(1); // int : 1
print('c'); // char : c
print(3.2f); // double : 3.2 (float -> double)
}
클래스와 오버로딩
- 클래스 내에 존재하는 대부분의 함수들은 오버로딩이 가능하다. 따라서 멤버 함수와 특수 함수로 취급되는 생성자와 연산자를 오버로딩 할 수 있다. (소멸자는 제외)
객체 생성 시 자동으로 생성되는 특수 함수들
- 객체를 생성하면 조건에 따라 자동으로 생성되는 특수 함수들이 있다. 특수 함수들의 목록은 아래와 같다.
- (참고로, 자동으로 생성되는 특수 함수들은 “기본 XX 생성자” 또는 “기본 XX 연산자” (ex. 기본 복사 생성자)라고 부른다.)
자동 생성 특수 함수 종류 | 특징 | 형태 예시 | 자동 생성 조건 |
기본 생성자 | 매개변수 없는 생성자 | A() {} | 기본 생성자 정의 X + const 또는 레퍼런스 멤버 변수 X |
복사 생성자 | 같은 타입의 객체를 복사하여 초기화 (shallow copy) | B(const B& other) { this->x = other.x; } | 복사 생성자 정의 X |
이동 생성자 (C++11~) | 임시 객체를 이동하여 초기화 (shallow move) | C(C&& other) noexcept { this->x = other.x; } | 이동 생성자 정의 X + 복사 생성자, 복사 대입 연산자 정의 X |
소멸자 | 객체가 소멸될 때 호출됨 | ~D() {} | 소멸자 정의 X |
복사 대입 연산자 | 기존 객체에 다른 객체를 복사 (shallow copy) | E& operator=(const E& other) { this->x = other.x; return *this; } |
복사 대입 연산자 정의 X |
이동 대입 연산자 (C++11~) | 기존 객체에 다른 객체를 이동 (shallow move) | F& operator=(F&& other) noexcept { this->x = other.x; return *this; } |
이동 대입 연산자 X + 복사 생성자, 복사 대입 연산자 정의 X |
default / delete 사용하기
- 기본 생성자가 그랬듯 다른 종류의 기본 XX 생성자들도 default와 delete를 사용해서 사용 여부를 명시할 수 있다.
class G {
public:
G() = default; // 기본 생성자를 명시적으로 활성화
G(const G&) = delete; // 기본 복사 생성자 삭제
G(G&&) = default; // 기본 이동 생성자 활성화
};
생성자 오버로딩(Constructor Overloading)
- 클래스 내에서 생성자를 여러 번 정의하는 경우, 이를 생성자 오버로딩(Constructor Overloading)이라고 부른다.
- 생성자 오버로딩은 1) 다양한 타입의 인자를 받는 상황을 정의하거나, 2) 복사 생성자, 대입 생성자 등 다양한 종류의 생성자를 정의한다. 생성자 종류는 다음과 같다.
생성자 종류
- 기본(Default) 생성자
- 매개변수가 있는(Parameterized) 생성자
- 복사(Copy) 생성자
- 이동(Move) 생성자
- 기타 생성자(인자를 1개만 받는 기타 생성자. ex. 타입 변환 생성자)
각 생성자 종류 예시
class ClassName {
public:
// 1. 기본(Default) 생성자
ClassName() { ... }
// 2. 매개변수가 있는(Parameterized) 생성자
ClassName(T arg1, U arg2, ...) { ... }
// 3. 복사(Copy) 생성자
ClassName(const ClassName& other) { ... }
// 4. 이동(Move) 생성자
ClassName(ClassName&& other) noexcept { ... }
// 5. 기타 생성자 (타입 변환 생성자: var -> ClassName(var)로 암시적 변환)
// ex. ClassName className = 3;
ClassName(int var) { ... }
};
알아둬야 할 점
생성자와 대입 연산자
- 객체 초기화 시 컴파일러는 생성자를 사용하는 방식과 대입 연산자를 사용하는 방식을 동일하게 해석한다. 따라서 어느 방식이든 인자의 타입이 같다면 같은 종류의 생성자가 호출된다.
// 컴파일러는 아래 두 문장을 동일하게 해석한다.
MyClass myclass2(myclass1);
MyClass myclass2 = myclass1;
- 반면에 MyClass myclass2; myclass2 = myclass1;과 같이 객체 선언과 대입을 분리하면 대입 시 생성자가 아닌 대입 연산자가 호출되므로 조심해야 한다.
- (이러한 이유로, 생성자 오버로딩을 했다면 같은 종류의 연산자를 오버로딩하여 기본 대입 연산자가 호출되는 걸 방지해야 한다.)
#include <iostream>
using namespace std;
class MyClass {
public:
MyClass() { cout << "기본 생성자 호출!" << endl; }
// 복사 생성자
MyClass(const MyClass& other) {
cout << "복사 생성자 호출!" << endl;
}
void operator=(const MyClass& other){
cout << "복사 대입 연산자 호출!" << endl;
}
};
int main() {
MyClass myclass1;
// 1) 생성자 방식: "복사 생성자 호출!"
MyClass myclass2(myclass1);
// 2) 대입 초기화 방식: "복사 생성자 호출!"
MyClass myclass3 = myclass1;
// 3) 선언후 대입 방식: "기본 생성자 호출!" -> "복사 대입 연산자 호출!
MyClass myClass4;
myClass4 = myclass1;
return 0;
}
얕은 복사(Shallow Copy) 문제
- 기본 복사 생성자와 기본 대입 연산자는 복사 시 얕은 복사(Shallow Copy) 방식으로 복사해 온다.
얕은 복사(Shallow Copy)란?
- 원본 객체의 멤버 데이터 값만 복사하는 방식. (비트 패턴 단위로 메모리 영역값을 그대로 복사)
- 얕은 복사의 반대로 깊은 복사(Deep Copy)가 있다.
깊은 복사(Deep Copy)란?
- 원본 객체가 가리키는(참조하는) 대상까지 별개의 메모리에 복사.
- 복사한 객체는 완전히 독립적인 메모리를 가진다.
- 얕은 복사의 문제는 동적 할당 시 “더블 프리(double free)”가 발생한다는 것이다. 과정은 아래와 같다.
- 얕은 복사 방식으로 객체를 복사한다. 복사된 객체의 포인터는 기존 객체와 같은 동적 메모리를 가리키게 된다.
- 객체 소멸 시 동적 메모리가 해제되면서 다른 포인터는 댕글링 포인터가 된다.
- 남은 객체 소멸 시, 소멸자는 이미 해제된 메모리를 해제하려고 시도
- ⇒ 더블 프리(double free) 발생!
- 따라서 더블 프리가 발생하지 않으려면 깊은 복사를 수행하는 복사 생성자 / 복사 대입 연산자를 오버로딩해야 한다.
깊은 복사 시 주의 사항: 부모, 멤버 클래스의 생성자는 초기화 리스트로 명시적으로 지정해야 한다.
- 복사할 클래스가 상속 관계(is-a)나 포함 관계(has-a)에 있는 경우, “생성자 실행 순서”는 다음과 같다.
- 부모 클래스 → 멤버 (변수) 클래스 → 자식 클래스
💡 멤버 클래스(member class)는 특정 클래스의 멤버 변수로 포함되어 있는 클래스를 말합니다.
- 깊은 복사는 부모, 멤버 클래스의 오버로딩된 복사 생성자(또는 연산자)를 연쇄적으로 호출하는 방식으로 구현한다. 그런데 만약 현재 클래스의 복사 생성자에서 초기화 리스트로 부모, 멤버 클래스의 생성자를 지정해주지 않으면 기본 생성자로 호출되는 문제가 발생한다.
초기화 리스트 지정 X
#include <iostream>
using namespace std;
class Parent {
public:
Parent() { std::cout << "Parent 기본 생성자 호출\\n"; }
Parent(const Parent& other) { std::cout << "Parent 복사 생성자 호출\\n"; }
};
class Member {
public:
Member() { std::cout << "Member 기본 생성자 호출\\n"; }
Member(const Member& other) { std::cout << "Member 복사 생성자 호출\\n"; }
};
class Child : public Parent {
Member m;
public:
Child() {
std::cout << "Child 기본 생성자 호출\\n";
}
// 복사 생성자 오버로딩: 초기화 리스트 사용 X -> 멤버 클래스의 기본 생성자 호출
Child(const Child& other) {
std::cout << "Child 복사 생성자 호출\\n";
}
};
// 실행 코드
int main() {
Child c1;
cout << "-----------------" << '\\n';
Child c2 = c1; // 복사 생성자 호출
}
출력 결과
Parent 기본 생성자 호출
Member 기본 생성자 호출
Child 기본 생성자 호출
-----------------
Parent 기본 생성자 호출
Member 기본 생성자 호출
Child 복사 생성자 호출
- 따라서 깊은 복사를 위해 복사 생성자를 오버로딩했다면 반드시 생성자 초기화 리스트에 부모 클래스와 멤버 클래스의 복사 생성자를 호출하도록 명시적으로 지정해줘야 한다.
초기화 리스트 지정 O
class Child : public Parent {
Member m;
public:
Child() {
std::cout << "Child 기본 생성자 호출\\n";
}
// 복사 생성자 오버로딩: 초기화 리스트 사용 -> 멤버 클래스의 복사 생성자 호출
Child(const Child& other) : Parent(other), m(other.m){
std::cout << "Child 복사 생성자 호출\\n";
}
};
출력 결과
Parent 기본 생성자 호출
Member 기본 생성자 호출
Child 기본 생성자 호출
-----------------
Parent 복사 생성자 호출
Member 복사 생성자 호출
Child 복사 생성자 호출
복사 대입 연산자의 경우
- “복사 대입 연산자”의 경우 초기화 리스트대신(이미 초기화된 객체이므로) 명시적으로 부모 클래스와 멤버 클래스의 복사 대입 연산자를 호출한다.
// 복사 생성자 명시적 선언: 초기화 리스트 사용 -> 멤버 클래스의 복사 생성자 호출
Knight(const Knight& knight) : Player(knight), _pet(knight._pet)
{
_hp = knight._hp
}
// 복사 대입 연산자 명시적 선언: 부모 / 멤버 클래스의 복사 대입 연산자 호출 필요!
Knight& operator=(const Knight& knight)
{
Player::operator=(knight); // Player의 '='연산자 오버로딩 호출
_pet = knight._pet;
_hp = knight._hp;
return *this;
}
결론
- 깊은 복사를 하는 경우 필연적으로 명시적인 복사 생성자 또는 복사 대입 연산자가 필요하다
- 이때 복사 생성자는 초기화 리스트에서 부모, 멤버 클래스의 복사 생성자를,
- 복사 대입 연산자는 부모, 멤버 클래스의 복사 대입 연산자를 명시적으로 호출한다.
참고 자료
'C++ > 문법 및 메소드(STL)' 카테고리의 다른 글
[C++][Class] 상속(Inheritance) (0) | 2025.03.26 |
---|---|
[C++][Class] 연산자 오버로딩(Operator Overloading) (0) | 2025.03.25 |
[C++][Class] 클래스(Class) (0) | 2025.03.25 |
[C++] 동적 할당 (0) | 2025.03.19 |
[C++][STL Container] 비교 연산자와 반복자 지원 (0) | 2025.03.18 |