클래스 정의와 객체 생성
클래스와 멤버 함수 정의를 구분하고 객체의 저장 기간·수명·소유권에 맞는 RAII 생성 방식과 멤버 접근법을 익힙니다.
앞 글에서는 클래스 하나를 파일 하나에 모두 적었습니다. 실제 프로젝트에서는 클래스를 여러 파일에서 쓰므로, 인라인 함수에서 본 것처럼 선언은 헤더에, 함수의 본문은 소스 파일에 나누어 둡니다. 또 객체를 만들 때는 그 객체가 언제까지 살아 있어야 하는지와 누가 정리할지를 함께 정해야 합니다.
이 글은 클래스 정의와 멤버 함수 정의를 나누는 방법, 지역 객체와 동적 객체를 만드는 방법, 객체와 참조, 포인터에 따라 달라지는 멤버 접근 문법을 다룹니다. 아래 그림은 객체의 수명과 소유에 따라 만드는 방식을 고르는 기준입니다.
C++ · OBJECT OWNERSHIP
객체의 수명과 소유 책임에 맞춰 생성 방식을 고른다
클래스 정의와 멤버 함수 정의는 타입과 동작을 준비한다. 실제 객체의 수명·개수·관찰자 요구를 정한 뒤 아래 후보를 선택한다.
현재 블록의 지역 값
현재 블록의 수명으로 충분하면 객체를 직접 보유한다.
Person p1{"김영희", 25};automatic storage duration이며 블록 종료 때 자동 소멸한다. 더 긴 수명도 값 반환·이동이나 상위 객체의 멤버로 표현할 수 있는지 먼저 본다.
가변 개수의 연속 값
요소의 주소 안정성이나 다형적 간접 소유가 필요 없는 연속 값 컬렉션을 보유한다.
std::vector<Person> people;컨테이너가 요소의 동적 저장 공간과 수명을 관리한다. 재할당은 기존 요소의 참조·포인터·반복자를 무효화한다. 요구가 다르면 다른 소유 구조를 선택한다.
단일 동적 소유
동적 객체 한 개의 해제 책임을 하나의 소유자로 표현한다.
auto owner =
std::make_unique<Person>(
"박영희", 22);unique_ptr 소멸·reset 시 대상 객체를 정리한다. 이동은 소유권을 넘기는 방법이며 raw 관찰자는 수명을 연장하지 않는다.
실제 공동 소유
둘 이상의 소유자가 대상의 수명 종료 책임을 함께 가져야 할 때만 선택한다.
std::shared_ptr<Person> owner;마지막 강한 소유자가 사라지면 대상이 소멸한다. shared_ptr 순환은 적절한 weak_ptr 관찰 관계로 끊는다. 예제 선언은 비어 있는 핸들이다.
automatic과 dynamic은 저장 기간, 소유권은 수명 종료 책임을 설명한다. 물리적 “스택/힙” 별명으로 고르지 않으며, 정의만 작성했다고 객체별 저장 공간이 생기지는 않는다.
그림의 예는 이름을 우리말로 적었고, 이 글의 예제는 콘솔의 인코딩 차이를 피하려고 로마자 이름을 씁니다.
클래스 정의와 멤버 함수 정의
클래스 정의는 클래스의 이름과 멤버를 모두 적은 것입니다. class Person { ... };은 클래스 정의이고, 본문 없이 class Person;이라고만 적은 것은 이런 클래스가 있다고 알리는 전방 선언(forward declaration)입니다. 클래스 정의 안에는 멤버 함수의 선언만 두고, 본문은 소스 파일에 따로 정의할 수 있습니다.
#pragma once
#include <string>
class Person {
private:
std::string name;
int age = 0;
public:
Person(std::string n, int a);
void setName(std::string n);
void setAge(int a);
std::string getName() const;
int getAge() const;
void introduce() const;
};#include "Person.h"
#include <iostream>
Person::Person(std::string n, int a) : name(n) {
setAge(a);
}
void Person::setName(std::string n) {
name = n;
}
void Person::setAge(int a) {
if (a >= 0) {
age = a;
} else {
std::cerr << "age cannot be negative\n";
}
}
std::string Person::getName() const {
return name;
}
int Person::getAge() const {
return age;
}
void Person::introduce() const {
std::cout << "Hello, I am " << name << ", " << age << " years old.\n";
}클래스 밖에서 멤버 함수를 정의할 때는 Person::setName처럼 클래스 이름과 범위 지정 연산자 ::를 붙여, 어느 클래스의 멤버인지 밝힙니다. 이렇게 정의한 함수 안에서도 name과 age를 이름만으로 쓸 수 있습니다. 선언과 정의 뒤에 붙은 const는 그 함수가 객체를 바꾸지 않는다는 표시이며, 멤버 함수와 멤버 변수에서 자세히 다룹니다.
#pragma once는 한 번역 단위 안에서 같은 헤더가 두 번 포함되지 않게 하는 지시어입니다. 표준에 정해진 기능은 아니지만 GCC, Clang, MSVC가 모두 지원합니다.
더 깊이: #pragma once와 포함 가드
#pragma once보다 오래 쓰여 온 방법은 헤더 전체를 #ifndef PERSON_H와 #define PERSON_H, 그리고 맨 끝의 #endif로 감싸는 포함 가드(include guard)입니다. 두 번째로 포함될 때는 매크로가 이미 정의되어 있으므로 안쪽 내용이 모두 건너뛰어집니다. 표준 전처리기 기능만 쓰므로 어느 컴파일러에서나 동작하지만, 헤더마다 겹치지 않는 매크로 이름을 지어야 하고 이름이 겹치면 다른 헤더의 내용이 조용히 빠집니다.
GCC의 전처리기는 이런 포함 가드를 알아보고 기억해 두었다가, 매크로가 아직 정의되어 있으면 같은 헤더를 다시 포함할 때 파일을 다시 읽지 않습니다. GCC 전처리기 문서에 따르면 가드 바깥에 둔 주석은 이 최적화를 방해하지 않습니다. 같은 문서의 다른 절은 #pragma once가 모든 전처리기에서 인식되지는 않아 이식성이 필요한 프로그램에서는 기댈 수 없다고 보고 새 프로그램에는 포함 가드를 권하므로, 어느 쪽을 쓸지는 프로젝트가 지원할 컴파일러에 맞춰 정합니다.
세 파일을 함께 빌드하기
다음 main.cpp를 같은 폴더에 두고 두 소스를 함께 빌드합니다. 헤더는 두 소스가 포함할 뿐 따로 컴파일하지 않으며, Person.cpp를 빼면 main에서 부르는 함수의 정의를 찾지 못해 링크 단계에서 실패합니다.
#include "Person.h"
int main() {
Person p1("Younghee", 25);
p1.introduce();
p1.setAge(26);
p1.introduce();
Person p2("Cheolsu", 30);
p2.introduce();
}g++ -std=c++20 -Wall -Wextra Person.cpp main.cpp -o person.exe
.\person.exeHello, I am Younghee, 25 years old.
Hello, I am Younghee, 26 years old.
Hello, I am Cheolsu, 30 years old.main.cpp에서 std::cout을 직접 쓰려면 main.cpp에도 <iostream>을 포함해야 합니다. Person.cpp가 포함한 헤더는 다른 번역 단위인 main.cpp로 전해지지 않기 때문입니다.
지역 객체
가장 기본적인 방법은 블록 안에 객체를 바로 선언하는 것입니다. 선언문이 실행되어 생성자가 끝나면 객체의 수명이 시작되고, 블록을 벗어날 때 자동으로 정리됩니다. 이런 객체는 자동 저장 기간(automatic storage duration)을 가지며, 흔히 스택에 만든다고 말합니다.
위 main의 p1과 p2가 지역 객체이며, main이 끝나면 만든 순서의 역순인 p2, p1의 순서로 정리됩니다. 객체가 현재 블록의 수명이면 충분하다면 이 방식을 기본으로 씁니다. 블록보다 오래 써야 한다면, 동적 객체를 쓰기 전에 함수가 객체를 값으로 돌려주거나 더 오래 사는 객체의 멤버로 두는 방법을 먼저 생각합니다.
동적 객체와 RAII
객체 하나를 실행 중에 만들어 한 소유자가 관리하게 하려면 std::make_unique를 쓰고, 개수가 실행 중에 정해지는 객체들은 std::vector에 담습니다. 둘 다 동적 메모리 할당에서 본 RAII 소유자여서, 직접 delete할 필요가 없습니다.
#include "Person.h"
#include <memory>
#include <vector>
int main() {
auto p3 = std::make_unique<Person>("Minsu", 22);
p3->introduce();
p3->setAge(23);
p3->introduce();
std::vector<Person> people{
Person("First", 10),
Person("Second", 12),
Person("Third", 14)
};
people[0].introduce();
people[1].introduce();
}Hello, I am Minsu, 22 years old.
Hello, I am Minsu, 23 years old.
Hello, I am First, 10 years old.
Hello, I am Second, 12 years old.p3는 동적으로 만든 Person 하나를 소유하며, main이 끝나 p3가 사라질 때 그 Person도 정리됩니다. people은 Person 세 개의 저장 공간과 수명을 관리하고, vector가 사라질 때 원소들도 함께 정리됩니다. 이 main은 앞의 main.cpp를 바꾸거나 다른 폴더에서 Person.cpp와 함께 빌드하며, 두 main을 한 프로그램에 넣으면 안 됩니다.
멤버 접근: .과 ->
멤버에 접근하는 연산자는 왼쪽 식이 무엇인지로 정해집니다. 객체나 참조에는 점 연산자 .를, 포인터나 스마트 포인터에는 화살표 연산자 ->를 씁니다. 포인터의 p->introduce()는 (*p).introduce()와 같은 뜻입니다.
C++ · MEMBER ACCESS
왼쪽 표현식이 멤버 접근 문법을 정한다
객체·참조는 점, raw pointer·smart pointer는 화살표를 쓴다. 접근 문법과 대상의 유효성·소유권은 각각 확인해야 한다.
객체 · 점 연산자
값 자체가 Person 객체다. 블록 지역 객체는 블록이 끝날 때 소멸한다.
Person p1{"김영희", 25};
p1.introduce();점 연산자 자체가 저장 기간을 뜻하지 않는다. 다른 저장 기간의 객체 표현식에도 같은 문법을 쓴다.
참조 · 점 연산자
기존 객체의 non-owning 별명이며 대상을 다시 묶지 않는다.
Person& ref = p1;
ref.setAge(26);선언의 =는 초기 바인딩이다. 이후 ref=other는 p1에 대입한다. 이 예제의 lvalue 참조는 대상 수명을 연장하지 않는다.
raw pointer · 화살표
타입만으로 소유권을 나타내지 않는다. 관찰자는 null이거나 재대입될 수 있다.
Person* observer = &p1;
observer->introduce();살아 있는 대상을 가리켜야 한다. non-null만으로 유효 수명을 증명하지 못한다. built-in p->m은 (*p).m과 같다.
smart pointer · 화살표
unique_ptr는 단일 소유, 실제 공동 소유가 필요하면 shared_ptr를 검토한다.
owner->introduce();unique_ptr/shared_ptr 접근 전 get()!=nullptr여야 한다. 소유자가 정리한 뒤 꺼내 둔 raw 포인터·참조는 dangling일 수 있다.
pointer=nullptr는 그 변수 하나만 null로 바꾼다. 다른 별명의 dangling을 막지 않으며, owner의 수명과 컨테이너 무효화 계약을 지켜 관찰자를 사용한다.
Person p1("Younghee", 25);
Person& ref = p1; // p1의 별명
Person* observer = &p1; // p1을 가리키지만 소유하지 않음
p1.introduce();
ref.setAge(26); // p1의 나이가 26이 됨
observer->introduce(); // p1을 출력그림처럼 네 가지 모두 같은 Person을 다룰 수 있지만, 문법과 함께 소유와 유효성도 다릅니다. 참조와 원시 포인터는 대상을 빌려 볼 뿐 소유하지 않으므로 대상의 수명을 늘리지 않습니다. 스마트 포인터에 ->를 쓰려면 비어 있지 않아야 하고, 원시 포인터는 살아 있는 객체를 가리켜야 합니다.
observer = nullptr;은 그 포인터 변수 하나만 바꿉니다. 널은 검사할 수 있지만 널이 아니라고 대상이 살아 있다는 뜻은 아니며, 같은 주소를 복사한 다른 포인터는 대상이 사라진 뒤에도 그 주소를 들고 있습니다. 특히 std::vector는 원소를 추가하다 공간이 모자라면 원소들을 더 큰 공간으로 옮기는데, 이때 기존 원소를 가리키던 참조, 포인터, 반복자가 모두 무효가 됩니다. 그래서 vector 원소의 주소를 오래 들고 있지 않습니다.
저장 기간과 수명
저장 기간(storage duration)은 객체가 쓸 저장 공간이 얼마나 오래 유지되는지의 분류이고, 수명(lifetime)은 초기화가 끝나 객체로 존재하기 시작한 때부터 정리될 때까지입니다. 둘은 관련이 있지만 같은 말은 아닙니다.
| 저장 기간 | 예 | 정리되는 때 |
|---|---|---|
| 자동 | 블록 안의 지역 객체 | 블록을 벗어날 때, 예외로 벗어나도 마찬가지 |
| 동적 | std::make_unique, std::vector의 원소 | 소유자가 사라지거나 원소를 지울 때 |
| 정적 | 전역 변수, static 지역 변수 | 프로그램이 끝날 때 |
| 스레드 | thread_local 변수 | 그 스레드가 끝날 때 |
객체를 만드는 방식을 고를 때는 스택이냐 힙이냐보다, 언제 정리되어야 하는지, 누가 정리하는지, 빌려 보는 쪽이 언제까지 유효한지를 먼저 정합니다. 그러면 지역 객체, unique_ptr, vector 가운데 무엇을 쓸지가 자연스럽게 정해집니다.
지역 클래스
클래스는 함수 안에도 정의할 수 있으며, 이를 지역 클래스(local class)라고 합니다. 아래 LocalClass는 myFunction의 본문 안에 정의되어 있어서, 그 함수 안에서는 보통 클래스처럼 객체를 만들고 멤버 함수를 부를 수 있습니다. main에서 같은 이름을 쓰려는 줄은 주석으로 막아 두었습니다.
#include <iostream>
void myFunction() {
class LocalClass {
public:
void display() {
std::cout << "local class\n";
}
};
LocalClass obj;
obj.display();
}
int main() {
myFunction();
// LocalClass obj2; // 컴파일 오류: 함수 밖에서는 이름이 보이지 않음
}LocalClass라는 이름은 myFunction 안에서만 보입니다. 지역 클래스는 정적 데이터 멤버를 가질 수 없는 등의 제약이 있어 자주 쓰이지 않으며, 대부분의 클래스는 이름 공간 범위에 정의합니다.
정리
- 클래스 정의는 헤더에, 멤버 함수의 본문은
클래스이름::함수이름으로 소스 파일에 정의할 수 있습니다. - 헤더를 포함하는 소스와 멤버 함수를 정의한 소스를 함께 빌드해야 링크가 성공합니다.
- 현재 블록의 수명으로 충분한 객체는 지역 객체로 만들고, 동적 객체는
std::make_unique나std::vector같은 RAII 소유자에 맡깁니다. - 객체와 참조에는
., 포인터와 스마트 포인터에는->를 쓰며, 참조와 원시 포인터는 대상을 소유하지 않습니다. std::vector가 원소를 옮기면 기존 원소의 참조, 포인터, 반복자가 무효가 됩니다.- 생성 방식은 언제 정리되는지와 누가 정리하는지를 먼저 정한 뒤 고릅니다.
연습 문제
1. 거부된 나이 변경
main.cpp에서 p1.setAge(26); 다음에 p1.setAge(-1);을 추가하면 p1과 p2의 나이는 각각 얼마인가요? p1을 처음부터 Person p1("Younghee", -1);로 만들면 무엇이 달라지나요?
해설 보기
setAge(-1)은 거부되어 p1의 나이는 26 그대로이고, p2는 별도의 객체여서 30입니다. p1을 -1로 만들면 생성자의 setAge(-1)이 거부되어 만든 직후 나이는 기본값 0이고, 이어지는 setAge(26)이 26으로 바꿉니다. 그래서 첫 출력은 0살이고 둘째 출력부터 26살입니다.
2. 원소를 추가한 뒤의 포인터
main_dynamic.cpp에서 Person* first = &people[0]; 다음에 people.push_back(Person("Fourth", 16));을 하고 first->introduce();를 부르면 어떻게 되나요?
해설 보기
push_back이 공간을 늘리면서 원소들을 새 공간으로 옮겼다면, first는 옛 공간을 가리키는 허상 포인터가 되어 정의되지 않은 동작입니다. 옮겨졌는지는 남은 공간에 따라 달라 코드만 보고 안전하다고 말할 수 없으므로, 원소를 추가한 뒤에는 &people[0]을 다시 얻거나 인덱스로 접근합니다.
3. 참조에 다른 객체 대입하기
Person& ref = p1; 다음에 ref = p2;를 실행하면 ref는 p2를 참조하게 되나요? p1.introduce()는 무엇을 출력하나요?
해설 보기
참조자의 대상은 바꿀 수 없으므로 ref는 계속 p1을 참조하고, ref = p2;는 p2의 값을 p1에 복사합니다. 그래서 p1.introduce()는 Hello, I am Cheolsu, 30 years old.를 출력하며, p1과 p2는 값이 같은 서로 다른 객체가 됩니다.
다음 글 접근 지정자에서는 public, private, protected가 각각 누구에게 멤버를 열어 주는지와, class와 struct의 기본 접근의 차이를 다룹니다.