LearnCPP - 14 | 이전 챕터 리메이크 필요

Justin·2026년 2월 26일

LearnCPP.com

목록 보기
15/22

14.1 — 객체 지향 프로그래밍(OOP) 소개

절차적 프로그래밍

이전 레슨 1.3(객체와 변수 소개)에서 우리는 C++의 객체를 "값을 저장하는 데 사용할 수 있는 메모리의 한 조각"이라고 정의했습니다. 그리고 이름이 있는 객체를 우리는 변수라고 부릅니다.

지금까지 작성한 C++ 프로그램은 컴퓨터에 내리는 순차적인 명령들의 나열이었습니다.
이 명령들은 객체를 통해 데이터를 정의하고, 그 데이터에 대한 작업은 명령문과 표현식이 포함된 함수를 통해 수행합니다.

지금까지 우리는 절차적 프로그래밍 이라는 방식의 프로그래밍을 해왔습니다.
절차적 프로그래밍에서는 프로그램의 논리를 구현하는 "절차"(C++에서는 함수라고 부름)를 만드는 데 중점을 둡니다. 우리는 데이터 객체를 이런 함수에 전달하고, 함수는 데이터에 작업을 수행한 뒤, 그 결과를 함수를 호출한 쪽으로 반환합니다.

절차적 프로그래밍에서는 함수와 그 함수가 다루는 데이터가 서로 완전히 분리되어 있습니다. 프로그래머는 원하는 결과를 만들어내기 위해 함수와 데이터를 직접 엮어주어야 합니다.
그 결과 코드는 다음과 같은 형태가 됩니다.

eat(you, apple); // 먹는다(당신이, 사과를)

이제 주변을 한번 둘러보세요. 책, 건물, 음식, 심지어 여러분 자신까지 모든 것이 다 객체입니다. 이러한 객체들은 두 가지 큰 특징을 가지고 있습니다.

  1. 연관된 몇 가지 속성(Properties) (예: 무게, 색깔, 크기, 단단함, 모양 등)
  2. 보여줄 수 있는 몇 가지 동작(Behaviors) (예: 열리기, 다른 것을 뜨겁게 만들기 등)입니다.

이런 속성과 동작은 서로 떼어낼 수 없습니다. 프로그래밍에서 속성은 객체로 표현되고, 동작은 함수로 표현됩니다. 하지만 절차적 프로그래밍은 속성(객체)과 동작(함수)을 따로 분리해 놓기 때문에, 현실 세계를 제대로 반영하지 못한다는 단점이 있습니다.


객체 지향 프로그래밍이란?

객체 지향 프로그래밍(Object-oriented programming, 흔히 OOP로 줄여 부름) 에서는 속성과 잘 정의된 동작들을 모두 포함하는 사용자 정의 데이터 타입을 만드는 데 중점을 둡니다.

OOP에서 "객체"라는 말은 이러한 타입들로부터 우리가 찍어낼(인스턴스화할) 수 있는 결과물을 뜻합니다. 이 방식을 사용하면 코드가 다음과 같이 바뀝니다.

you.eat(apple); // 당신이.먹는다(사과를)

이렇게 하면 주체(you)가 누구인지, 어떤 동작이 실행되는지(eat()), 그리고 그 동작에 어떤 객체(apple)가 이용되는지 훨씬 명확해집니다.

속성과 동작이 더 이상 분리되어 있지 않기 때문에 객체를 모듈화하기가 훨씬 쉽습니다.
덕분에 프로그램을 작성하고 이해하기 쉬워지며, 코드를 재사용하기도 아주 좋아집니다.

또한 이런 객체들은 우리가 데이터와 어떻게 상호작용할지, 그리고 객체들이 서로 어떻게 상호작용할지 정의할 수 있게 해주어 데이터를 다루는 훨씬 직관적인 방법을 제공합니다.

이러한 객체를 만드는 방법은 다음 레슨에서 자세히 알아보겠습니다.


절차적 방식 vs OOP 방식 예제

다음은 동물의 이름과 다리 개수를 출력하는 짧은 프로그램입니다.
절차적 프로그래밍 스타일로 작성되었습니다.

#include <iostream>
#include <string_view>

enum AnimalType
{
    cat,
    dog,
    chicken,
};

constexpr std::string_view animalName(AnimalType type)
{
    switch (type)
    {
    case cat: return "cat";
    case dog: return "dog";
    case chicken: return "chicken";
    default:  return "";
    }
}

constexpr int numLegs(AnimalType type)
{
    switch (type)
    {
    case cat: return 4;
    case dog: return 4;
    case chicken: return 2;
    default:  return 0;
    }
}

int main()
{
    constexpr AnimalType animal{ cat };
    std::cout << "A " << animalName(animal) << " has " << numLegs(animal) << " legs\n";

    return 0;
}

이 프로그램에서 우리는 동물의 다리 개수를 구하거나 이름을 가져오는 등의 역할을 하는 함수들을 만들었습니다.

지금 당장은 잘 작동하지만, 만약 동물을 뱀으로 바꾸고 싶다면 어떻게 해야 할지 생각해 보세요. 코드에 뱀을 추가하려면 AnimalType, numLegs(), animalName()을 전부 고쳐야 합니다. 만약 코드가 훨씬 방대했다면 AnimalType을 사용하는 다른 모든 함수도 업데이트해야 할 것입니다. 수정해야 할 코드가 엄청나게 많아지고, 멀쩡하던 코드가 고장 날 위험도 커집니다.

이제 똑같은 출력 결과를 내는 동일한 프로그램을, OOP적인 사고방식을 조금 더 적용하여 작성해 보겠습니다.

#include <iostream>
#include <string_view>

struct Cat
{
    std::string_view name{ "cat" };
    int numLegs{ 4 };
};

struct Dog
{
    std::string_view name{ "dog" };
    int numLegs{ 4 };
};

struct Chicken
{
    std::string_view name{ "chicken" };
    int numLegs{ 2 };
};

int main()
{
    constexpr Cat animal;
    std::cout << "a " << animal.name << " has " << animal.numLegs << " legs\n";

    return 0;
}

이 예제에서는 각 동물이 프로그램 내에서 고유한 타입으로 정의되어 있습니다. 그리고 그 타입이 해당 동물과 관련된 모든 것(이 경우엔 이름과 다리 개수 저장)을 스스로 관리합니다.

이제 아까처럼 동물을 뱀으로 업데이트하고 싶은 경우를 생각해 볼까요?

우리가 해야 할 일은 그저 Snake 타입을 하나 만들어서 Cat 대신 사용하는 것뿐입니다. 기존 코드는 거의 건드릴 필요가 없으므로, 이미 잘 작동하고 있는 코드를 망가뜨릴 위험이 크게 줄어듭니다.

물론 위에 제시된 고양이, 개, 닭 예제는 중복되는 부분이 많습니다.
각 동물이 완전히 똑같은 종류의 속성을 가지고 있으니까요.

이런 경우에는 공통적인 Animal 구조체를 하나 만들고 각 동물마다 인스턴스를 생성하는 것이 더 나을 수 있습니다. 하지만 다른 동물에게는 필요 없지만 닭에게만 필요한 새로운 속성
(예: 하루에 먹는 벌레의 수)을 추가하고 싶다면 어떨까요?

공통된 Animal 구조체를 쓴다면 모든 동물이 그 속성을 가져야만 합니다.
반면 OOP 모델을 사용하면, 그 속성을 닭 객체에만 쏙 넣을 수 있습니다.


OOP가 가져다주는 또 다른 이점들

학교에서 프로그래밍 과제를 제출할 때는 한 번 제출하면 사실상 끝입니다. 교수님이나 조교가 코드를 실행해 보고 정답이 나오는지 확인한 뒤 점수를 매기고 나면, 여러분의 코드는 아마 버려질 것입니다.

하지만 다른 개발자들과 함께 쓰는 저장소나, 실제 사용자들이 쓰는 애플리케이션에 코드를 제출할 때는 상황이 전혀 다릅니다. 새로운 운영체제나 소프트웨어 업데이트가 여러분의 코드를 고장 낼 수도 있습니다. 사용자들이 여러분의 논리적 오류를 찾아낼 수도 있고, 비즈니스 파트너가 새로운 기능을 추가해 달라고 요구할 수도 있습니다. 다른 개발자들은 여러분의 코드를 망가뜨리지 않으면서 기능을 확장해야 합니다. 즉, 코드는 계속 진화해야 하며, 그 과정에서 시간과 골칫거리, 그리고 오류 발생을 최소화해야 합니다.

이런 문제를 해결하는 가장 좋은 방법은 코드를 최대한 모듈화하고 중복을 없애는 것입니다. 이를 위해 OOP는 상속, 캡슐화, 추상화, 다형성이라는 아주 유용한 무기들도 제공합니다.

우리는 앞으로 이 모든 개념이 무엇인지, 그리고 어떻게 코드를 덜 중복되게 만들고 수정 및 확장을 쉽게 해주는지 차근차근 배울 것입니다. 일단 OOP에 익숙해져서 감을 잡고 나면, 아마 다시는 순수 절차적 프로그래밍으로 돌아가고 싶지 않을 것입니다.

그렇다고 해서 OOP가 절차적 프로그래밍을 완전히 대체하는 것은 아닙니다. 코드가 복잡해질 때 그것을 잘 관리할 수 있도록 여러분의 도구 상자에 훌륭한 도구를 하나 더 추가하는 것이라고 보면 됩니다.


"객체(Object)"라는 용어에 대하여

참고로 객체 라는 용어는 너무 여러 의미로 쓰이고 있어서 약간의 혼란을 줍니다. 전통적인 프로그래밍에서 객체는 단순히 '값을 저장하는 메모리 공간'을 뜻하며, 그게 다입니다.

하지만 객체 지향 프로그래밍에서 "객체"는 데이터를 저장하는 공간이면서 동시에 속성과 동작을 결합한 것을 의미합니다. 이 튜토리얼 시리즈에서는 전통적인 의미의 객체라는 뜻을 기본으로 사용하며, OOP의 객체를 콕 집어 말할 때는 클래스 객체(Class object) 라는 용어를 사용할 것입니다.


14.2 — 클래스 소개

이전 장에서는 구조체 13.7 — 구조체, 멤버, 멤버 선택 소개 에 대해 다루었으며, 구조체가 어떻게 여러 멤버 변수를 하나의 객체로 묶어 초기화하고 통째로 전달하는 데 유용한지 이야기했습니다.

즉, 구조체는 관련 데이터 값들을 저장하고 이동시키기 편리한 '꾸러미' 역할을 합니다.
다음 구조체를 살펴봅시다.

#include <iostream>

struct Date
{
    int day{};
    int month{};
    int year{};
};

void printDate(const Date& date)
{
    std::cout << date.day << '/' << date.month << '/' << date.year; // 일/월/년 형식을 가정합니다
}

int main()
{
    Date date{ 4, 10, 21 }; // 집합체 초기화(aggregate initialization)를 사용하여 초기화합니다
    printDate(date);        // 구조체 전체를 함수에 전달할 수 있습니다

    return 0;
}

위 예제에서는 Date 객체를 생성한 후 날짜를 출력하는 함수에 전달합니다.
이 프로그램의 출력 결과는 다음과 같습니다.

4/10/21

참고 삼아 말씀드립니다
이 튜토리얼에서 다루는 모든 구조체는 집합체 입니다.
집합체에 대해서는 13.8 — 구조체 집합체 초기화 강의에서 설명했습니다.

구조체는 무척 유용하지만, 크고 복잡한 프로그램(특히 여러 개발자가 함께 작업하는 프로그램)을 만들 때는 몇 가지 단점 때문에 어려움을 겪을 수 있습니다.


클래스 불변성 문제

구조체의 가장 큰 문제점은 아마도 클래스 불변성을 명확하게 기록하고 강제할 효과적인 방법이 없다는 점일 것입니다.

9.6 — Assert와 static_assert 강의에서 우리는 불변성을 "어떤 구성 요소가 실행되는 동안 반드시 참이어야 하는 조건"이라고 정의했습니다.

클래스 타입과 관련해서, 클래스 불변성이란 객체가 평생 동안 유효한 상태를 유지하기 위해 항상 참이어야 하는 조건을 말합니다. 이 불변성이 깨진 객체는 유효하지 않은 상태에 있다고 말하며, 이런 객체를 계속 사용하면 예상치 못한 동작이나 미정의 동작이 발생할 수 있습니다.

핵심 포인트
클래스 불변성이 깨진 객체를 사용하면 예상치 못한 동작이나 미정의 동작이 발생할 수 있습니다.

먼저 다음 구조체를 살펴봅시다.

struct Pair
{
    int first {};
    int second {};
};

firstsecond 멤버는 각각 독립적으로 어떤 값이든 가질 수 있으므로,
Pair 구조체에는 불변성이 없습니다.

이제 위와 거의 똑같아 보이는 다음 구조체를 살펴봅시다.

struct Fraction
{
    int numerator { 0 };
    int denominator { 1 };
};

수학에서 분모가 0인 분수는 정의되지 않는다는 것을 알고 있습니다. 분수의 값은 분자를 분모로 나눈 값인데, 0으로 나누는 것은 수학적으로 정의되지 않기 때문입니다.

따라서 우리는 Fraction 객체의 denominator(분모) 멤버가 절대 0이 되지 않도록 보장하고 싶습니다. 만약 0이 된다면 해당 Fraction 객체는 유효하지 않은 상태가 되며, 이 객체를 계속 사용하면 미정의 동작이 발생할 수 있습니다.

예를 들어보겠습니다.

#include <iostream>

struct Fraction
{
    int numerator { 0 };
    int denominator { 1 }; // 클래스 불변성: 절대 0이 되어서는 안 됩니다
};

void printFractionValue(const Fraction& f)
{
     std::cout << f.numerator / f.denominator << '\n';
}

int main()
{
    Fraction f { 5, 0 };   // 분모가 0인 Fraction 객체를 생성합니다
    printFractionValue(f); // 0으로 나누기 에러를 유발합니다

    return 0;
}

위 예제에서는 Fraction 의 불변성을 기록하기 위해 주석을 사용했습니다.
또한 사용자가 초기값을 제공하지 않을 경우 분모가 1로 설정되도록 기본 멤버 초기화를 제공했습니다. 덕분에 사용자가 Fraction 객체를 값 초기화 하기로 선택한다면 객체가 유효한 상태를 유지할 수 있습니다. 여기까지는 꽤 괜찮은 시작입니다.

하지만 우리가 이 클래스 불변성을 명시적으로 어기는 것을 막을 방법은 없습니다.
Fraction f 를 생성할 때, 집합체 초기화를 사용해 분모를 0으로 명시적으로 초기화해 버렸습니다. 당장 문제가 발생하지는 않지만, 이제 우리 객체는 유효하지 않은 상태가 되었고, 이 객체를 계속 사용하면 예상치 못한 문제나 미정의 동작이 발생할 수 있습니다.

그리고 나중에 printFractionValue(f) 를 호출했을 때 정확히 그런 문제가 나타납니다. 프로그램은 '0으로 나누기 에러' 때문에 종료되어 버립니다.

참고로...
약간의 개선 방법으로는 printFractionValue 함수 몸체 맨 위에 assert(f.denominator != 0); 을 추가하는 것입니다. 이렇게 하면 코드의 의도가 더 명확해지고, 어떤 전제 조건이 위반되었는지 쉽게 알 수 있습니다. 하지만 실제 동작 면에서는 변하는 게 없습니다.

우리는 이런 문제가 발생한 하류(잘못된 값이 사용될 때)가 아니라, 문제의 근원(멤버가 처음 초기화되거나 잘못된 값이 할당될 때)에서 문제를 잡아내기를 원합니다.

Fraction 예제는 비교적 단순하기 때문에, 유효하지 않은 Fraction 객체를 만들지 않도록 조심하는 게 그리 어렵지 않을 수 있습니다. 하지만 많은 구조체를 사용하거나, 멤버가 많은 구조체, 또는 멤버들 간에 복잡한 관계가 있는 구조체를 다루는 더 복잡한 코드베이스에서는 어떤 값들의 조합이 클래스 불변성을 깨뜨리는지 한눈에 파악하기 어려울 수 있습니다.


좀 더 복잡한 클래스 불변성

Fraction 의 클래스 불변성은 단순합니다. denominator 멤버가 0이 될 수 없다는 것이죠. 개념적으로 이해하기 쉽고 피하기도 크게 어렵지 않습니다.

클래스 불변성은 구조체의 멤버들이 서로 연관된 값을 가져야 할 때 더욱 큰 골칫거리가 됩니다.

#include <string>

struct Employee
{
    std::string name { };
    char firstInitial { }; // 항상 `name`의 첫 번째 문자(또는 `0`)를 가져야 합니다
};

위의 구조체에서 firstInitial 멤버에 저장된 문자 값은 항상 name 의 첫 번째 문자와 일치해야 합니다.

Employee 객체가 초기화될 때 클래스 불변성을 유지하는 책임은 전적으로 사용자에게 있습니다. 만약 name 에 새로운 값이 할당된다면, 사용자는 firstInitial 도 함께 업데이트해야 합니다. Employee 객체를 사용하는 개발자는 이러한 연관성을 눈치채지 못할 수도 있고, 안다 하더라도 업데이트를 깜빡할 수 있습니다.

Employee 객체를 생성하고 업데이트하는 데 도움을 주는 함수(항상 name 의 첫 번째 문자에서 firstInitial 을 설정하도록 보장하는 함수)를 작성하더라도, 우리는 여전히 사용자가 이 함수들의 존재를 알고 사용해 주기를 기대해야만 합니다.

간단히 말해서, 클래스 불변성을 유지하는 것을 객체 사용자에게 맡기는 것은 문제투성이 코드를 낳을 가능성이 높습니다.

핵심 포인트
클래스 불변성 유지를 객체 사용자에게 의존하면 문제가 발생할 가능성이 큽니다.

이상적으로는 클래스 타입이 아예 유효하지 않은 상태가 될 수 없도록 만들거나, 만약 그런 상태가 된다면 (나중에 무작위로 미정의 동작이 발생하도록 내버려 두는 대신) 즉시 신호를 보내도록 코드를 튼튼하게 만들고 싶을 것입니다.

(집합체로서의) 구조체는 이 문제를 깔끔하게 해결하는 데 필요한 메커니즘을 갖추고 있지 않습니다.


클래스 소개

비야네 스트로스트룹은 C++를 개발할 때, 개발자들이 직관적으로 사용할 수 있는
'프로그램 정의 타입' 기능을 도입하고 싶어 했습니다.

그는 또한 크고 복잡한 프로그램을 괴롭히는 흔한 함정과 유지보수의 어려움(앞서 언급한 클래스 불변성 문제 등)에 대한 깔끔한 해결책을 찾는 데 관심이 많았습니다.

다른 프로그래밍 언어(특히 최초의 객체 지향 프로그래밍 언어인 Simula)를 다뤄본 경험을 바탕으로, 비야네는 거의 모든 것에 사용할 수 있을 만큼 일반적이고 강력한 프로그램 정의 타입을 개발할 수 있다고 확신했습니다.

그는 Simula의 영향을 받아 이 타입을 클래스(class) 라고 불렀습니다.

구조체와 마찬가지로, 클래스는 여러 가지 타입의 멤버 변수들을 많이 가질 수 있는 프로그램 정의 복합 타입입니다.

핵심 포인트
기술적인 관점에서 보면 구조체와 클래스는 거의 똑같습니다. 따라서 구조체로 구현된 예제는 클래스로도 구현할 수 있고 그 반대도 가능합니다. 하지만 실용적인 관점에서 우리는 구조체와 클래스를 다르게 사용합니다.

구조체와 클래스의 기술적, 실용적인 차이점은
14.5 — public, private 멤버와 접근 지정자(access specifiers) 강의에서 다룹니다.

관련 내용
클래스가 불변성 문제를 어떻게 해결하는지는
14.8 — 데이터 은닉(캡슐화)의 이점 강의에서 다룹니다.


클래스 정의하기

클래스는 프로그램이 정의하는 데이터 타입이므로 사용하기 전에 반드시 정의되어야 합니다.
클래스를 정의하는 방법은 구조체와 비슷하지만, struct 대신 class 키워드를 사용합니다.

예를 들어, 다음은 간단한 직원 클래스를 정의한 것입니다.

class Employee
{
    int m_id {};
    int m_age {};
    double m_wage {};
};

관련 내용
클래스의 멤버 변수 이름 앞에 "m_"을 붙이는 이유는
14.5 — public, private 멤버와 접근 지정자 강의에서 설명합니다.

클래스와 구조체가 얼마나 비슷한지 보여주기 위해, 강의 첫 부분에서 보았던 것과 똑같은 동작을 하는 다음 프로그램을 살펴봅시다.

다만 이번에는 Date 가 구조체가 아니라 클래스입니다.

#include <iostream>

class Date       // struct를 class로 변경했습니다
{
public:          // 그리고 이 줄을 추가했습니다. 이를 '접근 지정자(access specifier)'라고 합니다
    int m_day{}; // 각 멤버 이름 앞에 "m_" 접두사를 추가했습니다
    int m_month{};
    int m_year{};
};

void printDate(const Date& date)
{
    std::cout << date.m_day << '/' << date.m_month << '/' << date.m_year;
}

int main()
{
    Date date{ 4, 10, 21 };
    printDate(date);

    return 0;
}

이 프로그램의 출력 결과는 다음과 같습니다.

4/10/21

관련 내용
접근 지정자가 무엇인지는 다가오는
14.5 — public, private 멤버와 접근 지정자 강의에서 다룹니다.


C++ 표준 라이브러리의 대부분은 클래스입니다

여러분은 이미 자신도 모르는 사이에 클래스 객체를 사용하고 있었을지도 모릅니다. std::stringstd::string_view 모두 클래스로 정의되어 있습니다.

사실, 표준 라이브러리에서 별칭이 아닌 타입 대부분은 클래스로 정의되어 있습니다!

클래스는 진정한 C++의 심장이자 영혼입니다. 너무 기초적이고 중요해서 C++의 원래 이름이 "클래스가 있는 C"였을 정도입니다! 클래스에 익숙해지고 나면, C++ 프로그래밍 시간의 대부분을 클래스를 작성하고, 테스트하고, 사용하는 데 보내게 될 것입니다.


14.3 — 멤버 함수

레슨 13.7(구조체, 멤버, 멤버 선택 소개) 에서는 멤버 변수를 가질 수 있는 사용자 정의 타입인 구조체(struct)를 소개했습니다. 다음은 날짜를 저장하는 데 사용되는 구조체의 예시입니다.

struct Date
{
    int year {};
    int month {};
    int day {};
};

이제 이 날짜를 화면에 출력하고 싶다면(실제로 자주 하게 될 작업이죠), 이를 수행하는 함수를 따로 작성하는 것이 합리적입니다. 전체 프로그램 코드는 다음과 같습니다.

#include <iostream>

struct Date
{
    // 여기에 멤버 변수들이 있습니다
    int year {};
    int month {};
    int day {};
};

void print(const Date& date)
{
    // 멤버 선택 연산자(.)를 사용하여 멤버 변수에 접근합니다
    std::cout << date.year << '/' << date.month << '/' << date.day;
}

int main()
{
    Date today { 2020, 10, 14 }; // 구조체를 집합체(aggregate) 초기화합니다

    today.day = 16; // 멤버 선택 연산자(.)를 사용하여 멤버 변수에 접근합니다
    print(today);   // 일반적인 호출 방식을 사용하여 비멤버 함수를 호출합니다

    return 0;
}

이 프로그램은 다음과 같이 출력합니다.

2020/10/16

속성과 동작의 분리

주변을 한번 둘러보세요. 책, 건물, 음식, 심지어 여러분 자신까지 모든 것이 객체입니다.
현실 세계의 객체는 크게 두 가지 핵심 요소로 이루어져 있습니다.

  1. 눈으로 볼 수 있는 속성 (예: 무게, 색상, 크기, 단단함, 모양 등)
  2. 그 속성들을 바탕으로 할 수 있는 동작 (예: 열리기, 다른 물건 부수기 등)

현실에서 이러한 속성과 동작은 서로 뗄 수 없는 관계입니다.

프로그래밍에서는 이러한 속성을 변수로, 동작을 함수로 표현합니다.

위의 Date 예제를 보면 속성 Date 의 멤버 변수와 그 속성을 사용하는 동작 print() 함수를 따로따로 정의했습니다. 우리는 오직 print() 함수의 매개변수인 const Date& 만을 보고 Dateprint() 가 서로 관련이 있다고 짐작할 뿐입니다.

물론 Dateprint() 를 같은 네임스페이스 안에 묶어두어 "이 둘은 한 세트입니다"라고 명확히 할 수도 있지만, 그렇게 하면 프로그램에 이름과 네임스페이스 접두사가 더 많아져 코드가 복잡해집니다.

속성과 동작을 하나의 패키지처럼 묶어서 함께 정의할 수 있는 방법이 있다면 정말 좋을 것입니다.


멤버 함수

클래스 타입(구조체, 클래스, 공용체 포함)은 멤버 변수뿐만 아니라 자기 자신만의 함수도 가질 수 있습니다. 이렇게 클래스 타입에 속해 있는 함수를 멤버 함수 라고 부릅니다.

참고로...
Java나 C# 같은 다른 객체 지향 언어에서는 이를 메서드(method) 라고 부릅니다. C++에서는 "메서드"라는 용어를 잘 쓰지 않지만, 다른 언어를 먼저 배운 프로그래머들은 여전히 그 용어를 사용할 수도 있습니다.

멤버 함수가 아닌 일반 함수들은 멤버 함수와 구분하기 위해 비멤버 함수 라고 부릅니다.
위에서 작성했던 print() 함수는 비멤버 함수입니다.

저자의 노트
이번 레슨에서는 멤버 함수의 예를 보여주기 위해 구조체를 사용하지만, 여기서 설명하는 모든 내용은 클래스에도 똑같이 적용됩니다. 왜 구조체 대신 클래스를 사용하는지에 대해서는 다음 레슨에서 클래스와 멤버 함수 예제를 다룰 때 명확해질 것입니다.

멤버 함수는 클래스 타입 정의 안에서 선언되어야 하며, 정의는 클래스 내부나 외부 어디서든 할 수 있습니다. 기억하시겠지만, 함수를 정의하는 것은 곧 선언하는 것이기도 하므로 클래스 안에서 멤버 함수를 정의하면 그것도 선언으로 인정됩니다.

지금은 내용을 단순하게 유지하기 위해, 멤버 함수를 클래스 정의 안에서 바로 작성해 보겠습니다.

관련 내용
멤버 함수를 클래스 정의 외부에서 선언하고 정의하는 방법은 레슨 15.2(클래스와 헤더 파일)에서 다룹니다.


멤버 함수 예제

레슨 맨 처음에 있던 Date 예제를 다시 가져와서, print() 를 비멤버 함수에서 멤버 함수로 바꿔보겠습니다.

// 멤버 함수 버전
#include <iostream>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print() // print라는 이름의 멤버 함수를 정의합니다
    {
        std::cout << year << '/' << month << '/' << day;
    }
};

int main()
{
    Date today { 2020, 10, 14 }; // 구조체를 집합체(aggregate) 초기화합니다

    today.day = 16; // 멤버 선택 연산자(.)를 사용하여 멤버 변수에 접근합니다
    today.print();  // 멤버 선택 연산자(.)를 사용하여 멤버 함수에도 접근합니다

    return 0;
}

이 프로그램은 정상적으로 컴파일되며 이전과 똑같은 결과를 출력합니다.

2020/10/16

비멤버 함수 예제와 멤버 함수 예제 사이에는 세 가지 중요한 차이점이 있습니다.

  1. print() 함수를 선언(및 정의)하는 위치
  2. print() 함수를 호출하는 방법
  3. print() 함수 내부에서 멤버 변수에 접근하는 방법

이제 각각을 차례대로 살펴보겠습니다.

멤버 함수는 클래스 타입 정의 안에 선언됩니다
비멤버 예제에서 print() 비멤버 함수는 Date 구조체 외부의 전역 공간에 정의되었습니다. 기본적으로 외부에서 접근할 수 있으므로, 다른 소스 파일에서도 호출할 수 있습니다.

반면 멤버 예제에서 print() 멤버 함수는 Date 구조체 정의 안에서 선언 및 정의되었습니다. print()Date 의 일부로 선언되었기 때문에, 컴파일러는 이 print()Date 의 멤버 함수라는 것을 알게 됩니다.

클래스 정의 내부에 작성된 멤버 함수는 자동으로 인라인 처리되므로, 여러 파일에서 클래스를 포함하더라도 단일 정의 규칙(ODR)을 위반하는 에러가 발생하지 않습니다.

멤버 함수 호출하기 (그리고 암시적 객체)
비멤버 예제에서는 print(today) 처럼 함수 안에 today 를 직접(명시적으로) 집어넣어 호출했습니다.

반면 멤버 예제에서는 today.print() 라고 호출합니다. 멤버 선택 연산자(.)를 사용하여 호출할 멤버 함수를 고르는 이 방식은 멤버 변수를 사용할 때
(today.day = 16;) 와 똑같아서 아주 자연스럽습니다.

모든 (정적이지 않은) 멤버 함수는 반드시 그 클래스 타입의 객체와 연결해서 호출해야 합니다. 여기서는 todayprint() 를 호출하는 주체(객체)가 됩니다.

여기서 주목할 점은, 멤버 함수의 경우 today 를 괄호 안에 매개변수로 넘겨줄 필요가 없다는 것입니다. 멤버 함수를 호출한 객체는 멤버 함수 내부로 암시적으로 전달됩니다. 이런 이유로, 멤버 함수를 부른 주체 객체를 종종 암시적 객체 라고 부릅니다.

다시 말해, 우리가 today.print() 를 호출하면 today 가 암시적 객체가 되어 print() 멤버 함수 안으로 조용히 넘어가는 것입니다.

멤버 함수 내부에서 멤버에 접근할 때는 암시적 객체를 사용합니다
다시 비멤버 버전의 print() 를 살펴봅시다.

// 비멤버 버전의 print
void print(const Date& date)
{
    // 멤버 선택 연산자(.)를 사용하여 멤버 변수에 접근합니다
    std::cout << date.year << '/' << date.month << '/' << date.day;
}

이 버전은 const Date& date 라는 참조 매개변수를 받습니다.
함수 안에서는 이 date 를 통해서 date.year, date.month 처럼 멤버에 접근합니다. print(today) 라고 호출하면 datetoday 와 연결되어 각각 today.year 등으로 쓰이게 됩니다.

이제 print() 멤버 함수의 모습을 다시 보겠습니다.

void print() // print라는 이름의 멤버 함수를 정의합니다
{
    std::cout << year << '/' << month << '/' << day;
}

멤버 예제에서는 앞에 아무것도 붙이지 않고 그냥 year, month, day 라고만 적었습니다.

멤버 함수 안에서 앞에 점(.)이 붙지 않은 멤버 변수 이름은 자동으로 암시적 객체의 변수 로 연결됩니다.

즉, today.print() 가 호출되면 today 가 암시적 객체가 되므로,
함수 안의 year, month, day 는 알아서 today.year, today.month, today.day 의 값을 가리키게 되는 것입니다.

핵심 요약
비멤버 함수를 사용할 때는, 작업할 객체를 함수에 직접 던져주어야 하고 그 객체를 통해 명시적으로 변수를 꺼내 써야 합니다.
반면 멤버 함수를 사용할 때는, 객체가 알아서 함수로 전달되며 함수 안에서도 내 것처럼 자연스럽게 변수들을 꺼내 쓸 수 있습니다.


또 다른 멤버 함수 예제

조금 더 재미있는 멤버 함수 예제를 살펴보겠습니다.

#include <iostream>
#include <string>

struct Person
{
    std::string name{};
    int age{};

    void kisses(const Person& person)
    {
        std::cout << name << " kisses " << person.name << '\n';
    }
};

int main()
{
    Person joe{ "Joe", 29 };
    Person kate{ "Kate", 27 };

    joe.kisses(kate);

    return 0;
}

이 코드는 다음과 같이 출력합니다.
Joe kisses Kate

어떻게 작동하는지 살펴봅시다. 먼저 joekate 라는 두 사람을 만들었습니다.
그리고 joe.kisses(kate) 를 호출합니다.
여기서 joe 가 암시적 객체가 되고, kate 는 괄호 안의 명시적 인수로 전달됩니다.

kisses() 함수 안으로 들어가면, 점(.)이 없는 name 은 암시적 객체인 joe 를 가리켜서 joe.name 이 됩니다. 반면 person.name 은 점(.)이 붙어 있고 매개변수 person (즉, kate)을 가리키므로 kate.name 이 됩니다.

핵심 요약
멤버 함수가 없었다면 kisses(joe, kate) 라고 썼을 것입니다. 하지만 멤버 함수를 사용하면 joe.kisses(kate) 라고 쓸 수 있습니다. 코드가 영어 문장처럼 훨씬 자연스럽게 읽히며, 누가 행동을 주도하고 누가 대상이 되는지 아주 명확해집니다.


멤버 변수와 함수는 순서에 상관없이 정의할 수 있습니다

C++ 컴파일러는 보통 코드를 위에서 아래로 읽으며 번역합니다. 그래서 일반적인 함수(비멤버)는 사용하기 전에 반드시 먼저 선언되어 있어야 컴파일러가 에러를 내지 않습니다.

int x()
{
    return y(); // 에러: y가 아직 선언되지 않아서 컴파일러가 뭔지 모릅니다
}

int y()
{
    return 5;
}

하지만 클래스 정의 안에서는 이 규칙이 적용되지 않습니다. 멤버 변수와 멤버 함수를 미리 선언하지 않아도 서로 부르고 사용할 수 있습니다. 즉, 여러분이 편한 순서대로 정의해도 괜찮다는 뜻입니다.

struct Foo
{
    int z() { return m_data; } // 데이터 멤버가 아래에 정의되어 있어도 접근할 수 있습니다
    int x() { return y(); }    // 멤버 함수가 아래에 정의되어 있어도 접근할 수 있습니다

    int m_data { y() };        // 심지어 기본 멤버 초기화에서도 작동합니다 (아래 경고 참조)
    int y() { return 5; }
};

경고
데이터 멤버(변수)들은 코드가 작성된 순서대로 초기화됩니다. 만약 어떤 변수를 초기화할 때 그보다 아래에 적힌 다른 변수를 끌어다 쓰면, 아직 초기화되지 않은 쓰레기값을 가져오게 되어 미정의 동작 이 발생할 수 있습니다.
따라서 기본 초기화 값을 설정할 때는 다른 멤버 변수를 가져다 쓰지 않는 것이 안전합니다.


멤버 함수는 오버로딩할 수 있습니다

일반 함수들처럼, 멤버 함수들도 서로 매개변수가 달라 구분만 가능하다면 같은 이름으로 여러 개를 만들 수 있습니다.

#include <iostream>
#include <string_view>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print()
    {
        std::cout << year << '/' << month << '/' << day;
    }

    void print(std::string_view prefix)
    {
        std::cout << prefix << year << '/' << month << '/' << day;
    }
};

int main()
{
    Date today { 2020, 10, 14 };

    today.print(); // Date::print()를 호출합니다
    std::cout << '\n';

    today.print("The date is: "); // Date::print(std::string_view)를 호출합니다
    std::cout << '\n';

    return 0;
}

이 프로그램의 출력은 다음과 같습니다.

2020/10/14
The date is: 2020/10/14

구조체와 멤버 함수

과거 C 언어 시절의 구조체는 데이터만 담을 수 있었고 함수는 담을 수 없었습니다.
하지만 C++를 만든 비야네 스트라우스트룹은 고민 끝에 C++의 구조체도 멤버 함수를 가질 수 있도록 규칙을 단순하고 일관성 있게 통일했습니다.

따라서 현대의 C++에서는 구조체에 멤버 함수를 넣는 것이 전혀 문제 되지 않습니다.
단, 나중에 배울 특별한 함수인 '생성자(constructor)'는 예외입니다.

구조체에 생성자를 넣으면 구조체의 장점인 집합체 성질을 잃어버리게 되기 때문입니다.

모범 사례
멤버 함수는 구조체와 클래스 모두에서 자유롭게 사용할 수 있습니다.
단, 구조체는 본연의 성질(집합체)을 잃지 않도록 생성자 멤버 함수를 만들지 않는 것이 좋습니다.


데이터 멤버가 없는 클래스 타입

변수는 하나도 없고 멤버 함수만 덩그러니 있는 클래스나 구조체를 만드는 것도 가능하긴 합니다.

#include <iostream>

struct Foo
{
    void printHi() { std::cout << "Hi!\n"; }
};

int main()
{
    Foo f{};
    f.printHi(); // 호출하려면 객체가 필요합니다

    return 0;
}

하지만 데이터가 전혀 없다면 굳이 클래스 타입을 사용하는 것은 과도한 설계일 수 있습니다. 이런 경우에는 비멤버 함수를 묶어주는 네임스페이스 를 사용하는 것이 훨씬 좋습니다. 객체를 일부러 만들 필요도 없고, 데이터가 없다는 사실이 읽는 사람에게 더 명확해지기 때문입니다.

#include <iostream>

namespace Foo
{
    void printHi() { std::cout << "Hi!\n"; }
}

int main()
{
    Foo::printHi(); // 객체가 필요하지 않습니다

    return 0;
}

14.4 — Const 클래스 객체와 const 멤버 함수

이전 5.1 레슨에서 int, double, char 등 기본 데이터 타입의 객체들을 const 키워드를 사용해 상수로 만드는 방법을 배웠습니다.

모든 const 변수는 생성하는 시점에 반드시 초기화해야 합니다.

const int x;      // 컴파일 에러: 초기화되지 않음
const int y{};    // 성공: 값으로 초기화됨
const int z{ 5 }; // 성공: 리스트로 초기화됨

마찬가지로 클래스 타입의 객체 struct, class, union 역시 const 키워드를 사용해 상수로 만들 수 있습니다. 이러한 객체들도 반드시 생성 시점에 초기화되어야 합니다.

struct Date
{
    int year {};
    int month {};
    int day {};
};

int main()
{
    const Date today { 2020, 10, 14 }; // const 클래스 타입 객체

    return 0;
}

일반 변수와 마찬가지로, 클래스 타입 객체도 생성된 이후에 값이 변경되지 않도록 확실히 보장해야 할 때는 보통 const 또는 constexpr 로 선언하는 것이 좋습니다.


const 객체의 데이터 멤버 수정은 금지됩니다

const 클래스 타입 객체 가 한 번 초기화되고 나면, 해당 객체의 데이터 멤버를 수정하려는 모든 시도는 허용되지 않습니다. 객체의 상수성 을 위반하기 때문입니다.

여기에는 (멤버가 public인 경우) 멤버 변수를 직접 바꾸는 것은 물론이고, 멤버 변수의 값을 변경하는 멤버 함수를 호출하는 것도 포함됩니다.

struct Date
{
    int year {};
    int month {};
    int day {};

    void incrementDay()
    {
        ++day;
    }
};

int main()
{
    const Date today { 2020, 10, 14 }; // const 객체

    today.day += 1;        // 컴파일 에러: const 객체의 멤버를 수정할 수 없으므로
    today.incrementDay();  // 컴파일 에러: const 객체의 멤버를 수정하는 멤버 함수를 호출할 수 없으므로

    return 0;
}

const 객체는 비-const 멤버 함수를 호출할 수 없습니다

다음 코드가 컴파일 에러를 일으킨다는 사실에 조금 놀라실 수도 있습니다.

#include <iostream>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print()
    {
        std::cout << year << '/' << month << '/' << day;
    }
};

int main()
{
    const Date today { 2020, 10, 14 }; // const 객체

    today.print();  // 컴파일 에러: const가 아닌 멤버 함수는 호출할 수 없음

    return 0;
}

print() 함수가 멤버 변수를 수정하려고 시도하지 않음에도 불구하고, today.print()를 호출하면 const 위반이 발생합니다. 그 이유는 print() 멤버 함수 자체가 const로 선언되지 않았기 때문입니다.

컴파일러는 const 객체에서 'const가 아닌 멤버 함수'를 호출하는 것을 허용하지 않습니다.


Const 멤버 함수

위의 문제를 해결하려면 print()const 멤버 함수 로 만들어야 합니다.

const 멤버 함수란, 객체 자신을 수정하지 않으며 객체를 수정할 위험이 있는
다른 비-const 멤버 함수도 호출하지 않겠다고 보장하는 함수입니다.

print()const 멤버 함수 로 만드는 방법은 아주 간단합니다. 매개변수 목록 뒤, 그리고 함수 본문이 시작되기 전 사이에 const 키워드를 붙여주기만 하면 됩니다.

#include <iostream>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print() const // 이제 const 멤버 함수가 됨
    {
        std::cout << year << '/' << month << '/' << day;
    }
};

int main()
{
    const Date today { 2020, 10, 14 }; // const 객체

    today.print();  // 성공: const 객체는 const 멤버 함수를 호출할 수 있음

    return 0;
}

위 예제에서 print()는 const 멤버 함수가 되었으므로,
이제 today와 같은 const 객체에서도 문제없이 호출할 수 있습니다.


심화 학습자를 위해

클래스 정의 외부에 정의된 멤버 함수의 경우, 클래스 내부의 함수 선언부와 클래스 외부의 함수 정의부 양쪽 모두const 키워드를 붙여야 합니다.
(이 부분에 대한 예제는 15.2 레슨 -- 클래스와 헤더 파일 에서 다룹니다.)

생성자는 객체의 멤버들을 초기화해야 하고 이 과정에서 멤버를 수정하게 되므로 const로 만들 수 없습니다. (생성자는 14.9 레슨 -- 생성자 소개 에서 다룹니다.)

const 멤버 함수가 데이터 멤버를 변경하려고 시도하거나 비-const 멤버 함수를 호출하려고 하면 컴파일 에러가 발생합니다.

struct Date
{
    int year {};
    int month {};
    int day {};

    void incrementDay() const // const로 만듦
    {
        ++day; // 컴파일 에러: const 함수는 멤버를 수정할 수 없음
    }
};

int main()
{
    const Date today { 2020, 10, 14 }; // const 객체

    today.incrementDay();

    return 0;
}

이 예제에서 incrementDay()는 const 멤버 함수로 선언되었지만 day의 값을 변경하려고 시도하므로 컴파일 에러를 일으킵니다.

단, const 멤버 함수라도 클래스 멤버가 아닌 변수(지역 변수나 함수 매개변수 등)를 수정하거나 멤버가 아닌 일반 함수를 호출하는 것은 평소처럼 가능합니다.

const는 오직 '클래스 멤버'에만 적용됩니다.

핵심 요약

  • const 멤버 함수가 할 수 없는 일
    암시적 객체(자신) 수정하기, 비-const 멤버 함수 호출하기.
  • const 멤버 함수가 할 수 있는 일
    자기 자신이 아닌 다른 객체 수정하기, 다른 const 멤버 함수 호출하기, 클래스 소속이 아닌 일반 함수 호출하기.

const 멤버 함수는 일반(비-const) 객체에서도 호출될 수 있습니다

const 멤버 함수는 일반적인 비-const 객체에서도 호출이 가능합니다.

#include <iostream>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print() const // const 함수
    {
        std::cout << year << '/' << month << '/' << day;
    }
};

int main()
{
    Date today { 2020, 10, 14 }; // 비-const (일반) 객체

    today.print();  // 성공: 비-const 객체에서도 const 멤버 함수 호출 가능

    return 0;
}

const 멤버 함수는 const 객체와 비-const 객체 모두에서 호출될 수 있습니다. 따라서 멤버 함수가 객체의 상태를 변경하지 않는다면, 그 함수는 무조건 const로 선언하는 것이 좋습니다.

모범 사례
객체의 상태를 변경하지 않는 (그리고 앞으로도 절대 변경하지 않을) 멤버 함수는 const 객체와 비-const 객체 모두에서 사용할 수 있도록 const로 만들어야 합니다.
단, 어떤 멤버 함수에 const를 적용할지는 신중하게 결정하세요. 함수를 한 번 const로 만들면 그 함수는 여러 const 객체들에서 자유롭게 쓰일 텐데, 나중에 해당 함수에서 const를 제거해버리면 그 함수를 호출하고 있던 기존의 const 객체 관련 코드들이 모두 망가지게 됩니다.


const 참조를 통한 const 객체 생성

지역 변수를 const로 선언해서 const 객체를 만드는 방법도 있지만, 더 흔하게 const 객체를 얻는 방법은 함수에 객체를 const 참조 로 전달하는 것입니다.

12.5 레슨 -- lvalue reference로 전달하기 에서 클래스 타입의 인수를 값 대신 const 참조로 전달할 때의 장점을 다루었습니다.

짧게 복습하자면, 클래스 타입의 인수를 값으로 전달하면 속도가 느린 복사본이 생성됩니다. 대부분의 경우 복사본은 필요하지 않으며, 원본 인수에 대한 참조만으로도 복사 과정을 피하면서 코드를 잘 작동시킬 수 있습니다. 또한 함수가 const lvalue와 rvalue 모두 받아들일 수 있도록 하기 위해 우리는 주로 const 참조 를 사용합니다.

다음 코드에서 무엇이 잘못되었는지 알아내실 수 있나요?

#include <iostream>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print() // 비-const
    {
        std::cout << year << '/' << month << '/' << day;
    }
};

void doSomething(const Date& date)
{
    date.print();
}

int main()
{
    Date today { 2020, 10, 14 }; // 비-const (일반) 객체
    today.print();

    doSomething(today);

    return 0;
}

정답은, doSomething() 함수 내부에서 date가 const 참조로 전달되었기 때문에 const 객체 로 취급된다는 점입니다. 그런데 우리는 그 const 상태인 date를 사용해서 비-const 멤버 함수인 print()를 호출하려고 하고 있습니다. const 객체에서는 비-const 멤버 함수를 호출할 수 없으므로, 이는 컴파일 에러를 발생시킵니다.

해결책은 간단합니다. print()를 const 함수로 만들어 주면 됩니다.

#include <iostream>

struct Date
{
    int year {};
    int month {};
    int day {};

    void print() const // 이제 const 함수가 됨
    {
        std::cout << year << '/' << month << '/' << day;
    }
};

void doSomething(const Date& date)
{
    date.print();
}

int main()
{
    Date today { 2020, 10, 14 }; // 비-const (일반) 객체
    today.print();

    doSomething(today);

    return 0;
}

이제 doSomething() 함수 내에서 const date가 const 멤버 함수인 print()를 성공적으로 호출할 수 있게 되었습니다.


멤버 함수의 const 및 비-const 오버로딩

마지막으로, 자주 쓰이는 기법은 아니지만 동일한 이름의 멤버 함수를
const 버전과 비-const 버전 두 가지로 오버로딩하는 것이 가능합니다.

이것이 가능한 이유는 const 한정자도 함수 시그니처의 일부로 간주되기 때문입니다.
즉, const 여부만 다르고 나머지는 똑같은 두 함수는 서로 다른 함수로 인식됩니다.

#include <iostream>

struct Something
{
    void print()
    {
        std::cout << "non-const\n";
    }

    void print() const
    {
        std::cout << "const\n";
    }
};

int main()
{
    Something s1{};
    s1.print(); // print() 호출

    const Something s2{};
    s2.print(); // print() const 호출

    return 0;
}

이 코드의 출력 결과는 다음과 같습니다.

non-const
const

이처럼 동일한 함수를 const 버전과 비-const 버전으로 오버로딩하는 것은 주로 반환 값의 상수성을 다르게 설정해야 할 때 쓰입니다. 하지만 꽤 드물게 사용되는 방법입니다.


14.5 — 퍼블릭(Public), 프라이빗(Private) 멤버와 접근 지정자

쌀쌀한 가을날, 부리토를 먹으며 길을 걷고 있다고 상상해 보세요. 앉을 곳을 찾아 주변을 둘러봅니다. 왼쪽에는 잘 깎인 잔디와 그늘진 나무, 조금 불편해 보이는 벤치, 그리고 놀이터에서 뛰노는 아이들이 있는 공원이 있습니다. 오른쪽에는 낯선 사람의 가정집이 보입니다. 창문 너머로 푹신한 안락의자와 따뜻하게 타오르는 벽난로가 눈에 띕니다.
여러분은 깊은 한숨을 쉬며 공원을 선택합니다.

여러분이 공원을 선택한 결정적인 이유는 공원이 공공(public) 장소인 반면, 가정집은 개인적(private) 인 공간이기 때문입니다. 여러분을 포함한 누구나 공공장소에는 자유롭게 들어갈 수 있습니다. 하지만 개인의 집에는 그 집의 식구들(또는 명시적으로 허락받은 사람)만 들어갈 수 있죠.


멤버 접근 권한

클래스 타입의 멤버들에도 이와 비슷한 개념이 적용됩니다. 클래스 타입의 각 멤버는 누가 그 멤버에 접근할 수 있는지를 결정하는 접근 수준이라는 속성을 가집니다.

C++에는 퍼블릭(public) , 프라이빗(private) , 프로텍티드(protected) 라는 세 가지 접근 수준이 있습니다. 이번 레슨에서는 가장 많이 쓰이는 퍼블릭과 프라이빗에 대해 알아보겠습니다.

관련 내용
프로텍티드(protected) 접근 수준에 대해서는 상속을 다루는 장
레슨 24.5 -- 상속과 접근 지정자 에서 설명합니다.

어떤 멤버에 접근하려고 할 때마다, 컴파일러는 해당 멤버의 접근 수준이 접근을 허락하는지 검사합니다. 만약 허락되지 않은 접근이라면 컴파일러는 에러를 발생시킵니다. 이러한 접근 수준 시스템을 흔히 접근 제어라고 부릅니다.


구조체의 멤버는 기본적으로 퍼블릭입니다

퍼블릭 접근 수준을 가진 멤버를 퍼블릭 멤버라고 부릅니다.

퍼블릭 멤버는 접근 방식에 아무런 제한이 없는 클래스 타입의 멤버를 말합니다. 앞서 들었던 공원 비유처럼, 퍼블릭 멤버는 해당 범위 내에만 있다면 누구나 접근할 수 있습니다.

퍼블릭 멤버는 같은 클래스 내의 다른 멤버들이 접근할 수 있습니다. 중요한 점은, 특정 클래스 타입의 외부에 존재하는 코드를 뜻하는 외부에서도 퍼블릭 멤버에 접근할 수 있다는 것입니다. 여기서 외부 란 비멤버 함수나 다른 클래스의 멤버들을 의미합니다.

핵심 포인트
구조체의 멤버는 기본적으로 퍼블릭입니다. 퍼블릭 멤버는 같은 클래스 타입의 다른 멤버들뿐만 아니라 외부에서도 접근할 수 있습니다.
"외부"라는 용어는 특정 클래스의 멤버가 아닌 바깥쪽 코드를 가리킬 때 사용합니다. 여기에는 비멤버 함수와 다른 클래스의 멤버들이 포함됩니다.

기본적으로 구조체의 모든 멤버는 퍼블릭 멤버입니다. 다음 구조체를 살펴보겠습니다.

#include <iostream>

struct Date
{
    // 구조체 멤버는 기본적으로 퍼블릭(public)이며, 누구나 접근할 수 있습니다
    int year {};       // 기본적으로 퍼블릭
    int month {};      // 기본적으로 퍼블릭
    int day {};        // 기본적으로 퍼블릭

    void print() const // 기본적으로 퍼블릭
    {
        // 퍼블릭 멤버는 해당 클래스 타입의 멤버 함수 내에서 접근할 수 있습니다
        std::cout << year << '/' << month << '/' << day;
    }
};

// 비멤버 함수인 main은 "외부(the public)"에 해당합니다
int main()
{
    Date today { 2020, 10, 14 }; // 구조체를 집계 초기화(aggregate initialize)합니다

    // 퍼블릭 멤버는 외부에서 접근할 수 있습니다
    today.day = 16; // 정상 작동: day 멤버는 퍼블릭입니다
    today.print();  // 정상 작동: print() 멤버 함수는 퍼블릭입니다

    return 0;
}

이 예제에서는 세 곳에서 멤버에 접근하고 있습니다.

  1. 멤버 함수인 print() 내부에서 암시적 객체의 year, month, day 멤버에 접근합니다.
  2. main() 함수에서 today.day에 직접 접근하여 값을 설정합니다.
  3. main() 함수에서 멤버 함수인 today.print()를 호출합니다.

퍼블릭 멤버는 어디서든 접근이 가능하므로, 이 세 가지 접근은 모두 허용됩니다.

main() 함수는 Date 구조체의 멤버가 아니기 때문에 외부 로 간주됩니다.
하지만 외부 코드는 퍼블릭 멤버에 접근할 수 있는 권한이 있으므로,
main() 함수에서 Date의 멤버에 직접 접근할 수 있는 것입니다.


클래스의 멤버는 기본적으로 프라이빗입니다

프라이빗 접근 수준을 가진 멤버를 프라이빗 멤버 라고 부릅니다.
프라이빗 멤버 는 오직 같은 클래스에 속한 다른 멤버들만 접근할 수 있는 멤버를 말합니다.

위의 예제와 거의 똑같지만, 구조체 대신 클래스를 사용한 다음 예제를 살펴보겠습니다.

#include <iostream>

class Date // 이제 구조체가 아닌 클래스입니다
{
    // 클래스 멤버는 기본적으로 프라이빗(private)이며, 다른 멤버들만 접근할 수 있습니다
    int m_year {};     // 기본적으로 프라이빗
    int m_month {};    // 기본적으로 프라이빗
    int m_day {};      // 기본적으로 프라이빗

    void print() const // 기본적으로 프라이빗
    {
        // 프라이빗 멤버는 멤버 함수 내에서 접근할 수 있습니다
        std::cout << m_year << '/' << m_month << '/' << m_day;
    }
};

int main()
{
    Date today { 2020, 10, 14 }; // 컴파일 에러: 더 이상 집계 초기화를 사용할 수 없습니다

    // 프라이빗 멤버는 외부에서 접근할 수 없습니다
    today.m_day = 16; // 컴파일 에러: m_day 멤버는 프라이빗입니다
    today.print();    // 컴파일 에러: print() 멤버 함수는 프라이빗입니다

    return 0;
}

이 예제에서도 앞서 살펴본 것과 동일한 세 곳에서 멤버에 접근합니다.
하지만 이 프로그램을 컴파일해 보면 3개의 컴파일 에러가 발생하는 것을 알 수 있습니다.

  • main() 내부의 today.m_day = 16today.print() 코드는 이제 컴파일 에러를 발생시킵니다. main()은 외부에 속하는 코드인데, 외부에선 프라이빗 멤버에 직접 접근하는 것이 허용되지 않기 때문입니다.
  • 반면 print() 내부에서 m_year, m_month, m_day에 접근하는 것은 허용됩니다. print()는 클래스의 멤버 함수이고, 클래스의 멤버는 다른 프라이빗 멤버에 자유롭게 접근할 수 있기 때문입니다.

그렇다면 세 번째 컴파일 에러는 어디서 발생한 걸까요?

놀랍게도 today 객체를 초기화하는 부분에서 에러가 발생합니다.
이전 레슨(13.8)에서 배운 것처럼, 집계 타입은 "프라이빗 또는 프로텍티드인 비정적 데이터 멤버를 가질 수 없습니다". 우리의 Date 클래스는 프라이빗 데이터 멤버를 가지고 있기 때문에(클래스는 기본적으로 멤버가 프라이빗이므로), 더 이상 집계 타입의 조건을 만족하지 못합니다. 따라서 집계 초기화 방식을 사용할 수 없는 것입니다.

클래스(일반적으로 집계 타입이 아닌 경우)를 올바르게 초기화하는 방법은 다가오는 레슨 14.9 -- 생성자 소개)에서 자세히 다루겠습니다.

핵심 포인트
클래스의 멤버는 기본적으로 프라이빗입니다. 프라이빗 멤버는 클래스 내의 다른 멤버들만 접근할 수 있으며, 외부에서는 접근할 수 없습니다.

프라이빗 멤버를 가진 클래스는 더 이상 집계 타입이 아니므로,
집계 초기화를 사용할 수 없습니다.


프라이빗 멤버 변수의 이름 짓기

C++에서는 프라이빗 데이터 멤버의 이름 앞에 "m_" 접두사를 붙이는 것이 일반적인 관례입니다. 이렇게 하는 데는 몇 가지 중요한 이유가 있습니다.
어떤 클래스의 멤버 함수를 예로 들어보겠습니다.

// 프라이빗 멤버 m_name을 매개변수 name의 값으로 설정하는 멤버 함수
void setName(std::string_view name)
{
    m_name = name;
}

첫째, m_ 접두사는 이 변수가 멤버 함수 내의 매개변수나 지역 변수가 아니라 데이터 멤버라는 것을 쉽게 구분할 수 있게 해줍니다.

m_name 이 멤버이고 "name"은 멤버가 아니라는 것을 한눈에 알 수 있죠. 이는 이 함수가 클래스의 '상태'를 변경하고 있다는 것을 명확히 해줍니다. 데이터 멤버의 값을 변경하면 그 변경 사항은 멤버 함수가 끝난 후에도 계속 유지되지만, 함수의 매개변수나 지역 변수는 그렇지 않기 때문에 이 구분은 매우 중요합니다.
(지역 정적 변수에 s_를, 전역 변수에 g_를 붙이는 것을 권장하는 것과 같은 맥락입니다.)

둘째, "m_" 접두사는 프라이빗 멤버 변수와 지역 변수, 함수 매개변수, 멤버 함수의 이름이 서로 충돌하는 것을 막아줍니다.

만약 프라이빗 멤버 이름을 m_name 대신 name으로 지었다면 다음과 같은 문제가 생깁니다.

  • 함수의 매개변수인 name이 데이터 멤버인 name을 가려버리게(shadow) 됩니다.
  • 만약 멤버 함수의 이름마저 name이라면, name이라는 식별자가 중복 정의되었다며 컴파일 에러가 발생할 것입니다.

모범 사례
프라이빗 데이터 멤버의 이름을 지을 때는 "m_" 접두사로 시작하도록 하여, 지역 변수나 함수 매개변수, 멤버 함수와 쉽게 구분되도록 하는 것을 권장합니다.
원한다면 클래스의 퍼블릭 멤버에도 이 관례를 따를 수 있습니다. 하지만 구조체의 경우 멤버 함수를 가지는 일이 드물기 때문에 퍼블릭 멤버에 이 접두사를 잘 쓰지 않습니다.


접근 지정자로 접근 수준 설정하기

기본적으로 구조체의 멤버는 퍼블릭이고, 클래스의 멤버는 프라이빗입니다.
하지만 우리는 접근 지정자를 사용하여 멤버의 접근 수준을 직접 명시적으로 설정할 수 있습니다. 접근 지정자는 그 지정자 아래에 나오는 모든 멤버의 접근 수준을 설정합니다.
C++는 public:, private:, protected: 라는 세 가지 접근 지정자를 제공합니다.

다음 예제에서는 public: 지정자를 사용하여 외부에서 print() 멤버 함수를 사용할 수 있게 만들고, private: 지정자를 사용하여 데이터 멤버들을 프라이빗으로 만듭니다.

class Date
{
// 여기에 정의된 멤버는 기본적으로 프라이빗이 됩니다

public: // 여기에 퍼블릭 접근 지정자가 있습니다

    void print() const // 위의 public: 지정자 덕분에 퍼블릭이 됩니다
    {
        // 멤버들은 다른 프라이빗 멤버에 접근할 수 있습니다
        std::cout << m_year << '/' << m_month << '/' << m_day;
    }

private: // 여기에 프라이빗 접근 지정자가 있습니다

    int m_year { 2020 };  // 위의 private: 지정자 덕분에 프라이빗이 됩니다
    int m_month { 14 };   // 위의 private: 지정자 덕분에 프라이빗이 됩니다
    int m_day { 10 };     // 위의 private: 지정자 덕분에 프라이빗이 됩니다
};

int main()
{
    Date d{};
    d.print();  // 정상 작동: main()은 퍼블릭 멤버에 접근이 허용됩니다

    return 0;
}

이 코드는 정상적으로 컴파일됩니다.

print()public: 지정자 덕분에 퍼블릭 멤버가 되었으므로,
외부에 해당하는 main() 함수에서 접근할 수 있습니다.

반면 프라이빗 멤버가 존재하기 때문에 d를 집계 초기화할 수는 없습니다.
이 예제에서는 임시방편으로 기본 멤버 초기화를 사용했습니다.

클래스는 기본적으로 프라이빗 접근을 사용하기 때문에, 맨 처음 시작할 때 private: 지정자는 생략해도 됩니다.

class Foo
{
    // 클래스는 기본적으로 프라이빗 멤버를 가지므로 여기에 private 지정자가 필요 없습니다
    int m_something {};  // 기본적으로 프라이빗
};

하지만 구조체와 클래스의 기본 접근 수준이 다르기 때문에,
헷갈리는 것을 방지하기 위해 많은 개발자들이 명시적으로 적어주는 것을 선호합니다.

class Foo
{
private: // 기술적으로는 불필요하지만, 이후 멤버들이 프라이빗이라는 것을 명확히 해줍니다
    int m_something {};  // 기본적으로 프라이빗
};

기술적으로는 중복이더라도 이렇게 private: 지정자를 명시해주면,
Foo가 클래스로 정의되었는지 구조체로 정의되었는지 따져가며 기본 접근 수준을 유추할 필요 없이 코드의 의도를 명확하게 파악할 수 있습니다.


접근 수준 요약

각 접근 수준에 대한 간단한 요약표입니다.

접근 수준접근 지정자멤버의 접근파생 클래스의 접근외부(Public)의 접근
Publicpublic:
Protectedprotected:아니요
Privateprivate:아니요아니요

클래스 타입 안에서는 원하는 순서대로 얼마든지 여러 개의 접근 지정자를 번갈아 사용할 수 있습니다. (예: 퍼블릭 멤버들을 먼저 쓰고, 그 다음 프라이빗 멤버들을 쓰고, 다시 퍼블릭 멤버들을 쓰는 등).

실제로 대부분의 클래스는 용도에 맞게 프라이빗과 퍼블릭 접근 지정자를 모두 활용합니다.


구조체와 클래스의 접근 수준 모범 사례

이제 접근 수준이 무엇인지 알았으니, 이를 어떻게 활용해야 하는지 이야기해 봅시다.

  • 구조체(Structs) 에서는 접근 지정자를 아예 쓰지 않는 것이 좋습니다.
    즉, 모든 멤버가 기본값인 퍼블릭 상태로 남도록 두는 것입니다.
    구조체는 집계 타입으로 쓰는 것이 좋은데, 접근 지정자를 사용해
    private:이나 protected:를 넣으면 더 이상 집계 타입이 아니게 됩니다.

  • 클래스(Classes) 는 반대로 데이터 멤버를 프라이빗(또는 프로텍티드)으로 숨기는 것이 일반적입니다. 왜 그래야 하는지에 대해서는 다음 레슨 14.6 -- 접근 함수 에서 이유를 설명하겠습니다.

  • 클래스의 멤버 함수는 객체가 생성된 후 외부에서 사용할 수 있도록 대개 퍼블릭으로 만듭니다. 하지만 외부에서 직접 쓰라고 만든 기능이 아닐 경우에는 멤버 함수도 프라이빗(또는 프로텍티드)으로 만들 수 있습니다.

모범 사례
클래스에서는 일반적으로 멤버 변수를 프라이빗으로 숨기고, 멤버 함수를 퍼블릭으로 공개합니다.
구조체에서는 접근 지정자 사용을 피하는 것이 좋습니다 (모든 멤버가 기본값인 퍼블릭이 됩니다).


접근 수준은 객체가 아닌 '클래스' 단위로 작동합니다

C++ 접근 수준에 대해 많은 사람들이 놓치거나 오해하는 미묘한 점이 하나 있습니다.
바로 접근 권한이 '객체' 단위가 아니라 '클래스' 단위로 적용된다는 것입니다.

자신의 프라이빗 멤버에 멤버 함수가 접근할 수 있다는 사실은 이미 배우셨을 겁니다.
그런데 접근 권한이 클래스 단위로 적용되기 때문에, 어떤 멤버 함수는 같은 범위 내에 있는 같은 클래스 타입의 다른 객체 의 프라이빗 멤버에도 직접 접근할 수 있습니다.

예제로 확인해 보겠습니다.

#include <iostream>
#include <string>
#include <string_view>

class Person
{
private:
    std::string m_name{};

public:
    void kisses(const Person& p) const
    {
        std::cout << m_name << " kisses " << p.m_name << '\n';
    }

    void setName(std::string_view name)
    {
        m_name = name;
    }
};

int main()
{
    Person joe;
    joe.setName("Joe");

    Person kate;
    kate.setName("Kate");

    joe.kisses(kate);

    return 0;
}

이 프로그램의 출력 결과는 다음과 같습니다:

Joe kisses Kate

여기서 주목해야 할 몇 가지 포인트가 있습니다.

  • 첫째, m_name이 프라이빗으로 선언되었기 때문에 Person 클래스의 멤버들만 접근할 수 있고 외부에서는 접근할 수 없습니다.

  • 둘째, 프라이빗 멤버가 있어서 더 이상 집계 타입이 아니므로 집계 초기화를 할 수 없습니다. 임시방편으로 setName()이라는 퍼블릭 멤버 함수를 만들어 이름을 지정해주었습니다.

  • 셋째, kisses()는 멤버 함수이므로 프라이빗 멤버인 m_name에 직접 접근할 수 있습니다. 그런데 흥미로운 점은 p.m_name에도 직접 접근하고 있다는 것입니다.
    p 역시 Person 객체이고, kisses()는 같은 Person 클래스의 멤버 함수이기 때문에 다른 Person 객체의 프라이빗 멤버에도 마음대로 접근할 수 있는 것입니다.

이 원리는 연산자 오버로딩 챕터에서 아주 유용하게 쓰일 예정입니다.


구조체와 클래스의 기술적, 실용적 차이점

이제 구조체와 클래스의 '기술적인' 차이점에 대해 결론을 내려보겠습니다. 준비되셨나요?

  • 클래스는 멤버가 기본적으로 프라이빗이고, 구조체는 멤버가 기본적으로 퍼블릭입니다.
  • …네, 이게 전부입니다.

저자의 메모
아주 엄밀하게 따지자면 사소한 차이가 하나 더 있습니다. 구조체는 다른 클래스를 상속받을 때 기본적으로 '퍼블릭 상속'을 하고, 클래스는 '프라이빗 상속'을 합니다.
이것이 무슨 뜻인지는 상속 챕터에서 배우겠지만, 어차피 상속을 할 때는 명시적으로 방식을 지정해야 하므로 실질적으로는 거의 의미가 없는 차이입니다.

실제 실무에서 우리는 구조체와 클래스를 다음과 같이 구분해서 사용합니다.
다음 조건들을 모두 만족한다면 경험적으로 구조체를 사용하는 것이 좋습니다.

  • 접근을 제한할 필요가 없는 단순한 데이터 모음일 때
  • 집계 초기화만으로 충분할 때
  • 클래스의 불변성을 유지하거나 복잡한 초기화, 정리 작업이 필요 없을 때

구조체를 사용하기 좋은 예시: constexpr 전역 프로그램 데이터, 좌표를 나타내는 Point 구조체 (숨길 필요 없는 단순한 int 변수 모음), 함수에서 여러 개의 데이터를 한 번에 반환하기 위해 사용하는 구조체 등.

이 외의 경우에는 클래스를 사용하세요.

우리는 구조체를 '집계 타입'으로 유지하고 싶어 합니다. 따라서 구조체를 집계 타입이 아니게 만드는 어떤 기능(프라이빗 멤버 등)이라도 추가해야 한다면, 그 구조체는 클래스로 바꾸는 것이 낫습니다.


14.6 — 접근 함수

이전 레슨에서 퍼블릭과 프라이빗 접근 수준에 대해 이야기했습니다.
다시 한번 기억해 보자면, 클래스는 보통 데이터 멤버를 프라이빗으로 만들며, 외부에서는 이 프라이빗 멤버에 직접 접근할 수 없습니다.

다음 Date 클래스를 살펴봅시다.

#include <iostream>

class Date
{
private:
    int m_year{ 2020 };
    int m_month{ 10 };
    int m_day{ 14 };

public:
    void print() const
    {
        std::cout << m_year << '/' << m_month << '/' << m_day << '\n';
    }
};

int main()
{
    Date d{};  // Date 객체를 만듭니다
    d.print(); // 날짜를 출력합니다

    return 0;
}

이 클래스는 전체 날짜를 출력해 주는 print() 멤버 함수를 제공하지만, 사용자가 원하는 작업을 하기에는 충분하지 않을 수 있습니다.

예를 들어, Date 객체를 사용하는 사람이 연도만 따로 가져오고 싶다면 어떻게 해야 할까요? 아니면 연도를 다른 값으로 바꾸고 싶다면요?

m_year 변수가 프라이빗이기 때문에 그렇게 할 수 없습니다.

어떤 클래스에서는 프라이빗 멤버 변수의 값을 가져오거나 설정할 수 있게 해주는 것이 (클래스의 역할에 따라) 적절할 수 있습니다.


접근 함수

접근 함수는 프라이빗 멤버 변수의 값을 가져오거나 바꾸는 역할을 하는 간단한 퍼블릭 멤버 함수입니다. 접근 함수는 크게 두 가지 종류로 나뉩니다.

게터(Getter) (접근자라고도 부름) 는 프라이빗 멤버 변수의 값을 반환하는 퍼블릭 멤버 함수입니다.

세터(Setter) (변경자라고도 부름) 는 프라이빗 멤버 변수의 값을 새롭게 설정하는 퍼블릭 멤버 함수입니다.


용어 정리

"뮤테이터(Mutator)"라는 용어는 종종 "세터(setter)"와 같은 의미로 사용됩니다. 하지만 더 넓은 의미에서 뮤테이터(Mutator) 란 객체의 상태를 수정(변경)하는 모든 멤버 함수를 말합니다.

이 정의에 따르면 세터는 뮤테이터의 한 종류입니다.
세터가 아니더라도 객체의 상태를 바꾸는 함수라면 모두 뮤테이터라고 할 수 있습니다.

게터는 보통 const로 만들어져서 const 객체와 상수가 아닌 객체 모두에서 호출할 수 있게 합니다. 반면 세터는 데이터 멤버를 수정해야 하므로 const가 아니어야 합니다.

이해를 돕기 위해, 모든 게터와 세터가 포함되도록 Date 클래스를 업데이트해 보겠습니다.

#include <iostream>

class Date
{
private:
    int m_year { 2020 };
    int m_month { 10 };
    int m_day { 14 };

public:
    void print()
    {
        std::cout << m_year << '/' << m_month << '/' << m_day << '\n';
    }

    int getYear() const { return m_year; }        // 연도 게터
    void setYear(int year) { m_year = year; }     // 연도 세터

    int getMonth() const  { return m_month; }     // 월 게터
    void setMonth(int month) { m_month = month; } // 월 세터

    int getDay() const { return m_day; }          // 일 게터
    void setDay(int day) { m_day = day; }         // 일 세터
};

int main()
{
    Date d{};
    d.setYear(2021);
    std::cout << "The year is: " << d.getYear() << '\n';

    return 0;
}

이 코드는 다음을 출력합니다.

The year is: 2021

접근 함수의 이름 짓기

접근 함수의 이름을 짓는 데 있어 모두가 따르는 완벽한 표준은 없습니다.
하지만 다른 방식들보다 더 자주 쓰이는 몇 가지 이름 짓기 방식이 있습니다.

"get"과 "set"을 앞에 붙이는 방식:

int getDay() const { return m_day; }  // 게터
void setDay(int day) { m_day = day; } // 세터

"get"과 "set" 접두사를 사용하는 것의 장점은 이것들이 접근 함수라는 것을
(그리고 호출하는 데 컴퓨터 자원이 적게 든다는 것을) 아주 명확하게 보여준다는 점입니다.

접두사 없이 사용하는 방식:

int day() const { return m_day; }  // 게터
void day(int day) { m_day = day; } // 세터

이 스타일은 더 간결하며, 게터와 세터 모두에 같은 이름을 사용합니다 (두 개를 구분하기 위해 함수 오버로딩 기능을 활용합니다). C++ 표준 라이브러리가 이 방식을 사용합니다.

접두사가 없는 방식의 단점은 코드를 읽을 때 멤버 변수를 설정하는 작업인지 눈에 확 띄지 않는다는 점입니다.

d.day(5); // 이것이 day 멤버를 5로 설정하는 것처럼 보이나요?

핵심 포인트

프라이빗 데이터 멤버 앞에 "m_"을 붙이는 가장 큰 이유 중 하나는 데이터 멤버와 게터의 이름이 똑같아지는 것을 막기 위해서입니다.
(자바 같은 다른 언어는 이름이 같아도 되지만, C++은 지원하지 않습니다)

"set" 접두사만 사용하는 방식:

int day() const { return m_day; }     // 게터
void setDay(int day) { m_day = day; } // 세터

위의 방식 중 어떤 것을 선택할지는 개인의 취향에 달려 있습니다.
하지만 세터에는 "set" 접두사를 사용하는 것을 강력히 추천합니다.
게터는 "get" 접두사를 써도 되고 아무것도 쓰지 않아도 괜찮습니다.

세터에 "set" 접두사를 사용하여 객체의 상태를 바꾸고 있다는 사실을 더 확실하게 나타내세요.


게터는 값 또는 const lvalue 참조로 반환해야 합니다

게터는 데이터에 대한 "읽기 전용" 접근을 제공해야 합니다.
따라서 좋은 프로그래밍 습관은 (데이터를 복사하는 비용이 저렴하다면)
값으로 반환하거나, (복사하는 비용이 크다면) const 참조로 반환하는 것입니다.

데이터 멤버를 참조로 반환하는 것은 꽤 까다로운 주제이므로, 다음 레슨인
14.7 -- 데이터 멤버에 대한 참조를 반환하는 멤버 함수 에서 더 자세히 다루겠습니다.


접근 함수 사용 시 주의할 점

언제 접근 함수를 사용해야 하고 피해야 하는지에 대해서는 꽤 많은 논쟁이 있습니다.
많은 개발자들은 접근 함수를 남용하는 것이 좋은 클래스 설계 원칙에 어긋난다고 말합니다.
(이 주제만으로도 책 한 권을 쓸 수 있을 정도입니다)

우선은 조금 더 실용적인 접근법을 추천해 드립니다.
클래스를 만들 때 다음 사항들을 고려해 보세요.

  • 클래스에 유지해야 할 특별한 규칙(불변성)이 없고 여러 개의 접근 함수가 필요하다면, (데이터가 외부로 공개되는) struct를 사용하여 멤버에 직접 접근하도록 만드는 것을 고려해 보세요.
  • 접근 함수를 단순하게 만들기보다는 '행동'이나 '동작'을 구현하는 것을 우선하세요.
    예를 들어, setAlive(bool) 세터를 만드는 대신 kill()(죽이기)과 revive()(부활시키기) 함수를 구현하세요.
  • 외부에서 개별 멤버의 값을 가져오거나 설정할 필요가 정말로 합리적인 경우에만 접근 함수를 만드세요.

퍼블릭 접근 함수를 제공할 거라면 왜 데이터를 프라이빗으로 만드나요?

아주 좋은 질문입니다. 이 질문에 대한 답은 다가오는 레슨인
14.8 -- 데이터 은닉(캡슐화)의 이점 에서 자세히 알려드리겠습니다.


14.7 — 멤버 변수의 참조를 반환하는 멤버 함수

이전 레슨에서 우리는 '참조로 반환하기'에 대해 배웠습니다. 그때 가장 중요하게 강조했던 규칙은 "함수가 끝난 후에도 참조하는 대상이 반드시 살아있어야 한다" 는 것이었죠.

이 말은, 함수 안에서 잠깐 쓰려고 만든 '지역 변수'는 참조로 반환하면 안 된다는 뜻입니다. 함수가 끝나면 지역 변수는 파괴되어 사라지는데, 반환된 참조는 텅 빈 허공을 가리키게 되기 때문입니다.

하지만 매개변수로 받아온 참조나, 전역 변수, 정적(static) 변수처럼 함수가 끝나도 계속 살아있는 변수들 은 참조로 반환해도 괜찮습니다.

예를 들어보겠습니다.

// 두 개의 std::string 객체를 받아서 알파벳 순서상 먼저 오는 것을 참조로 반환합니다.
const std::string& firstAlphabetical(const std::string& a, const std::string& b)
{
	return (a < b) ? a : b; // std::string에 operator< 를 사용해서 어느 것이 알파벳순으로 먼저인지 알아낼 수 있습니다.
}

int main()
{
	std::string hello { "Hello" };
	std::string world { "World" };

	std::cout << firstAlphabetical(hello, world); // hello나 world 중 하나가 참조로 반환될 것입니다.

	return 0;
}

클래스 안에 있는 '멤버 함수'들도 이와 똑같은 규칙을 따릅니다. 하지만 멤버 함수에는 우리가 꼭 짚고 넘어가야 할 특별한 경우가 하나 있습니다. 바로 자신의 멤버 변수를 참조로 반환하는 경우 입니다.

주로 데이터를 읽어오는 역할을 하는 '게터(Getter)' 함수에서 이런 모습을 자주 볼 수 있습니다. 이 글에서는 게터 함수를 예로 들어 설명하겠지만, 이 원리는 멤버 변수의 참조를 반환하는 모든 멤버 함수에 똑같이 적용됩니다.


멤버 변수를 '값'으로 반환하면 프로그램이 느려질 수 있습니다

다음 예시를 볼까요?

#include <iostream>
#include <string>

class Employee
{
	std::string m_name{};

public:
	void setName(std::string_view name) { m_name = name; }
	std::string getName() const { return m_name; } // 게터(getter)가 '값'으로 반환합니다.
};

int main()
{
	Employee joe{};
	joe.setName("Joe");
	std::cout << joe.getName();

	return 0;
}

위 코드에서 getName() 함수는 m_name 변수를 값(Value) 으로 반환합니다.

이 방식이 가장 안전하긴 하지만, getName() 을 부를 때마다 m_name 문자열을 통째로 새롭게 복사(Copy) 해야 한다는 큰 단점이 있습니다. 데이터를 단순히 읽어오기만 하는 게터 함수는 프로그램 안에서 엄청나게 자주 호출되기 때문에, 이렇게 매번 데이터를 복사하는 것은 성능상 좋은 선택이 아닙니다.


멤버 변수를 'lvalue 참조'로 반환하기

다행히 멤버 함수는 멤버 변수를 참조로 반환할 수 있습니다. 쉽게 말해 원본 데이터를 복사하는 대신, 원본과 바로 연결되는 '직통 통로'만 넘겨주는 것입니다.

멤버 변수는 자신이 속해 있는 '객체(Object)'와 운명을 함께합니다.
즉, 객체가 살아있는 동안에는 멤버 변수도 살아있습니다.

멤버 함수는 항상 어떤 객체를 통해서 호출되는데, 함수를 부른 쪽(호출자)에서 그 객체가 살아있다면 멤버 함수가 변수의 통로(참조)를 반환해도 아주 안전합니다.
함수 호출이 끝나도 원본 데이터가 여전히 살아있기 때문이죠.

아까 본 예시에서 getName() 이 복사 대신 참조(const reference) 를 반환하도록 수정해 보겠습니다.

#include <iostream>
#include <string>

class Employee
{
	std::string m_name{};

public:
	void setName(std::string_view name) { m_name = name; }
	const std::string& getName() const { return m_name; } // 게터가 상수를 참조(const reference)하는 방식으로 반환합니다.
};

int main()
{
	Employee joe{}; // joe는 main 함수가 끝날 때까지 살아있습니다.
	joe.setName("Joe");

	std::cout << joe.getName(); // joe.m_name을 통째로 복사하지 않고, 참조(직통 연결)로 반환합니다.

	return 0;
}

이제 joe.getName() 을 부르면, 무거운 복사 과정을 거치지 않고 joe.m_name 에 접근할 수 있는 참조만 쏙 반환합니다. 그러면 main() 함수에서는 이 참조를 사용해 화면에 joe 의 이름을 출력합니다.

joe 라는 객체는 main() 함수가 끝날 때까지 살아있기 때문에, joe.m_name 을 가리키는 참조 역시 그 시간 동안 아무 문제 없이 안전하게 쓸 수 있습니다.

핵심 포인트
멤버 변수를 참조로 반환하는 것은 괜찮습니다. 함수 호출이 끝나더라도 그 변수를 품고 있는 원본 객체가 여전히 살아있기 때문에, 반환받은 참조를 안심하고 사용할 수 있습니다.


반환 타입은 멤버 변수의 타입과 똑같이 맞추세요

참조를 반환할 때 함수의 '반환 타입'은 원래 멤버 변수의 타입과 똑같아야 합니다.
위의 예시에서 m_namestd::string 타입이므로, getName() 의 반환 타입도 const std::string& 가 되어야 합니다.

만약 반환 타입을 std::string_view 같이 다른 타입으로 적는다면, 함수가 호출될 때마다 억지로 타입을 바꾸느라 불필요한 임시 객체가 만들어지게 됩니다. 이는 매우 비효율적입니다. 만약 밖에서 std::string_view 형태가 필요하다면, 참조를 받아간 쪽에서 알아서 변환해 쓰게 두는 것이 맞습니다.

모범 사례
쓸데없는 데이터 변환을 막으려면, 참조를 반환하는 함수의 타입은 원래 데이터 변수의 타입과 100% 똑같이 맞추세요.

게터를 만들 때 auto 키워드를 쓰면 컴파일러가 알아서 타입을 맞춰주기 때문에 실수를 줄일 수 있습니다.

#include <iostream>
#include <string>

class Employee
{
	std::string m_name{};

public:
	void setName(std::string_view name) { m_name = name; }
	const auto& getName() const { return m_name; } // m_name으로부터 반환 타입을 알아내기 위해 auto를 사용합니다.
};

int main()
{
	Employee joe{}; // joe는 main 함수가 끝날 때까지 살아있습니다.
	joe.setName("Joe");

	std::cout << joe.getName(); // joe.m_name을 참조로 반환합니다.

	return 0;
}

하지만 auto 를 쓰면 코드를 읽는 사람 입장에서는 이 함수가 정확히 어떤 타입을 반환하는지 한눈에 알기 어렵다는 단점이 있습니다. 그래서 보통은 명확하게 반환 타입을 직접 적어주는 방식을 더 선호합니다.


잠깐 생겼다 사라지는 임시 객체(Rvalue)와 참조 반환의 위험성

여기서 조금 주의해야 할 상황이 하나 있습니다.

앞서 본 예시에서 joe 는 함수가 끝날 때까지 튼튼하게 살아남는 객체(lvalue)였습니다.
그래서 joe.getName() 이 돌려준 참조도 끝까지 안전했죠.

하지만 만약 방금 만들어졌다가 금방 사라져버리는 임시 객체(rvalue) 를 다룬다면 어떨까요? (예를 들어, 어떤 함수가 객체를 '값'으로 새로 만들어서 툭 던져주는 경우입니다.)

이런 임시 객체(rvalue)들은 자신이 속한 한 줄의 코드가 실행되고 나면 곧바로 파괴되어 사라집니다. 객체가 파괴되면 그 안에 들어있던 멤버 변수들도 같이 날아가 버리죠. 이때 이 사라진 멤버 변수를 가리키고 있던 참조가 있다면, 텅 빈 공간을 가리키는 댕글링 참조가 되어버립니다. 이걸 계속 쓰려고 하면 프로그램에 미정의 동작이 발생합니다.

경고
임시 객체(rvalue)는 그 코드가 적힌 한 줄(전체 표현식)이 끝날 때 파괴됩니다. 따라서 임시 객체의 멤버에 연결된 참조는 딱 그 한 줄 안에서만 안전하게 쓸 수 있습니다.

코드를 보며 무슨 뜻인지 이해해 봅시다.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
	std::string m_name{};

public:
	void setName(std::string_view name) { m_name = name; }
	const std::string& getName() const { return m_name; } // 게터가 상수를 참조하는 방식으로 반환합니다.
};

// createEmployee()는 Employee를 '값'으로 반환합니다 (즉, 반환된 값은 수명이 짧은 임시 객체인 rvalue가 됩니다).
Employee createEmployee(std::string_view name)
{
	Employee e;
	e.setName(name);
	return e;
}

int main()
{
	// Case 1: 정상 - rvalue 객체의 멤버에 대한 참조를 같은 줄(표현식) 안에서 즉시 사용했습니다.
	std::cout << createEmployee("Frank").getName();

	// Case 2: 위험 - rvalue 객체의 멤버에 대한 참조를 나중에 쓰려고 저장해 두었습니다.
	const std::string& ref { createEmployee("Garbo").getName() }; // createEmployee()의 반환값이 파괴되면서 참조가 끊어집니다(댕글링).
	std::cout << ref; // 미정의 동작 (프로그램이 터지거나 이상한 값이 나올 수 있습니다)

	// Case 3: 정상 - 참조하던 값을 나중에 쓰기 위해 아예 새로운 지역 변수에 '복사'해 두었습니다.
	std::string val { createEmployee("Hans").getName() }; // 참조하던 멤버를 복사해 옵니다.
	std::cout << val; // 정상: val은 참조하던 멤버와 이제 완전히 별개인 독립적인 변수입니다.

	return 0;
}

createEmployee() 를 부르면 새로운 Employee 임시 객체(rvalue)가 만들어져서 돌아옵니다. 이 녀석은 그 줄의 코드 실행이 끝나면 흔적도 없이 파괴될 운명입니다.

  • Case 1: createEmployee("Frank") 가 만든 임시 객체에서 바로 .getName() 으로 이름의 참조를 가져온 뒤, 그 자리에서 바로 화면에 출력(std::cout)했습니다. 그 한 줄 안에서 모든 할 일을 무사히 마쳤으므로 아주 안전합니다.

  • Case 2: 이번에는 .getName() 으로 가져온 임시 객체의 이름 참조를 ref 라는 변수에 묶어(저장해) 두었습니다. 안타깝게도 이 줄이 넘어가는 순간 원본 임시 객체가 파괴되어 사라집니다. 다음 줄에서 ref 를 출력하려고 해 봤자, 원본이 이미 사라졌기 때문에 프로그램이 오작동하게 됩니다.

  • Case 3: 원본이 곧 파괴될 것을 알고, 참조를 저장하는 대신 아예 val 이라는 내 개인 변수에 데이터를 쏙 복사해버렸습니다. 이제 원본 임시 객체가 파괴되더라도, 내 손에는 복사본이 안전하게 남아있기 때문에 나중에 언제든지 자유롭게 쓸 수 있습니다.


참조를 반환하는 함수, 어떻게 안전하게 쓸 수 있을까?

임시 객체(rvalue)를 다룰 때의 위험성에도 불구하고, 게터 함수가 값을 무겁게 복사하지 않고 가벼운 참조로 반환하는 것은 C++의 아주 훌륭한 관행입니다.

그렇다면 어떻게 해야 오류 없이 안전하게 쓸 수 있을까요?
위 3가지 Case를 요약한 다음의 핵심 규칙만 기억하시면 됩니다.

  1. 참조를 돌려주는 멤버 함수를 썼다면, 반환받은 값을 그 자리에서 즉시 사용하세요 (Case 1 방식). 이렇게 습관을 들이면 원본이 튼튼한 녀석(lvalue)이든 임시 객체(rvalue)든 꼬일 일이 없습니다.

  2. 원본 객체가 오래 살아남는다는 확실한 보장이 없다면, 반환된 참조를 변수에 묶어서 저장해두지 마세요 (Case 2처럼 하면 안 됩니다).

  3. 만약 그 값을 나중에 또 써야 하는데 원본 객체가 곧 파괴될 것 같다면, 참조로 묶어두는 대신 그냥 일반 변수에 담아서 안전한 복사본을 만드세요 (Case 3 방식).

모범 사례
원본 객체가 임시 객체(rvalue)일 때 댕글링 참조(고장 난 참조)가 생기는 것을 막으려면, 참조를 반환하는 함수의 결과값은 받은 즉시 바로 그 자리에서 사용하는 것이 가장 좋습니다.


private 멤버 변수를 변경할 수 있는 참조로 반환하지 마세요

참조는 그 변수 본체와 완벽히 똑같이 행동합니다.
만약 외부에서 함부로 건드리면 안 되는 private 변수를 그냥 변경 가능한 참조로 밖으로 빼내버리면, 외부에서 그 참조를 이용해 내부 값을 마음대로 바꿀 수 있게 됩니다.

#include <iostream>

class Foo
{
private:
    int m_value{ 4 }; // private 멤버 (외부에서 함부로 접근 불가)

public:
    int& value() { return m_value; } // 변경 가능한(non-const) 참조를 반환합니다 (절대 이렇게 하지 마세요!)
};

int main()
{
    Foo f{};                // f.m_value가 기본값인 4로 초기화됩니다.
    f.value() = 5;          // m_value = 5 와 완벽히 같은 결과를 만듭니다. (private 변수가 밖에서 뚫렸습니다!)
    std::cout << f.value(); // 5가 출력됩니다.

    return 0;
}

보안을 위해 꽁꽁 숨겨둔 private 변수의 마스터키를 복사해서 밖으로 던져준 것이나 다름없습니다. 이렇게 하면 객체를 안전하게 보호하려는 목적이 완전히 무너집니다.


데이터 변경을 막는 const 멤버 함수는, 값을 바꿀 수 있는 참조를 반환할 수 없습니다

const 키워드가 붙은 멤버 함수는 "나는 객체의 상태를 절대 변경하지 않겠다"고 선언한 함수입니다. 따라서 이런 함수는 다른 사람이 멤버 변수를 바꿀 수 있도록 허락하는 'non-const 참조'를 밖으로 반환할 수 없도록 문법적으로 막혀 있습니다.

만약 이게 허용된다면, 함수 자체는 데이터를 안 바꾼다고 약속해 놓고 정작 호출한 사람에게는 "직접 값을 바꿀 수 있는 권한(참조)"을 넘겨주게 되니, const 함수의 진짜 목적을 속이는 셈이 되기 때문입니다.


14.8 — 데이터 은닉(캡슐화)의 장점

이전 레슨 14.5 -- Public과 private 멤버 및 접근 제어자 에서, 클래스의 멤버 변수는 보통 'private(비공개)'으로 만든다고 말씀드렸습니다. 클래스를 처음 배우는 초보자분들은 종종 왜 이렇게 해야 하는지 이해하기 어려워하십니다. 변수를 private으로 만들면 외부에서 접근할 수 없게 되니까요. 좋게 봐줘도 코드를 작성할 때 일거리만 더 늘어나는 것 같고, 나쁘게 보면
(특히 private 데이터에 접근할 수 있는 public 함수를 따로 만들어줄 거라면) 완전히 무의미한 짓거리처럼 보일 수도 있습니다.

이 질문에 대한 답은 프로그래밍에서 너무나도 중요하고 기초적인 내용이라,
이번 레슨 전체를 할애해서 설명해 보려고 합니다!

먼저 비유를 하나 들어볼게요.

현대 사회에서 우리는 수많은 기계나 전자 기기를 사용합니다.
리모컨으로 TV를 켜고 끄거나, 자동차의 가속 페달을 밟아 앞으로 나아가게 하거나, 스위치를 눌러 불을 켭니다. 이 기기들에는 공통점이 있습니다. 바로 사용자가 핵심적인 동작을 수행할 수 있도록 간단한 사용자 인터페이스(버튼, 페달, 스위치 등) 를 제공한다는 점입니다.

하지만 이 기기들이 실제로 어떻게 작동하는지는 여러분의 눈에 보이지 않게 '숨겨져' 있습니다. 리모컨 버튼을 누를 때 리모컨이 TV와 어떻게 통신하는지 몰라도 됩니다. 자동차 페달을 밟을 때 엔진이 어떻게 바퀴를 굴리는지 몰라도 됩니다. 사진을 찍을 때 센서가 어떻게 빛을 모아서 픽셀 이미지로 바꾸는지 몰라도 되죠.

이렇게 인터페이스(사용 방법)구현(내부 동작 원리) 을 분리하는 것은 엄청나게 유용합니다. 기기가 어떻게 작동하는지 낱낱이 이해하지 않아도, 그냥 기기를 '어떻게 다루는지'만 알면 사용할 수 있기 때문이죠. 덕분에 우리가 이런 물건들을 사용하는 과정이 훨씬 단순해지고, 우리가 다룰 수 있는 기기의 종류도 훨씬 많아집니다.


클래스에서의 구현과 인터페이스

프로그래밍에서도 비슷한 이유로 인터페이스와 구현을 분리하는 것이 유용합니다.
그 전에, 클래스에서 말하는 '인터페이스'와 '구현'이 정확히 무슨 뜻인지 정의해 봅시다.

클래스의 인터페이스란 사용자가 그 클래스로 만든 객체와 어떻게 상호작용할지를 정의하는 부분입니다. 클래스 외부에서는 'public(공개)' 멤버에만 접근할 수 있기 때문에, 클래스의 public 멤버들이 곧 인터페이스가 됩니다. 이런 이유로 public 멤버들로 구성된 인터페이스를 퍼블릭 인터페이스 라고 부르기도 합니다.

인터페이스는 클래스를 만든 사람과 클래스를 사용하는 사람 사이의 무언의 '계약'과도 같습니다. 만약 기존에 있던 인터페이스가 바뀌게 되면, 그걸 사용하던 기존 코드들이 고장 날 수 있습니다. 따라서 클래스의 인터페이스는 처음부터 설계를 잘해서 나중에 자주 바뀌지 않게 안정적으로 만드는 것이 중요합니다.

클래스의 구현 이란 클래스가 의도한 대로 동작하게 만드는 '실제 코드'를 말합니다. 데이터를 저장하는 멤버 변수들, 그리고 프로그램의 논리를 담고 데이터를 조작하는 멤버 함수의 알맹이(코드 내용)가 모두 여기에 포함됩니다.


데이터 은닉

프로그래밍에서 데이터 은닉이란 (정보 은닉 또는 데이터 추상화라고도 부름), 사용자가 데이터 타입의 내부 구현에 접근하지 못하게 숨김으로써 인터페이스와 구현을 강제로 분리하는 기법입니다.

C++ 클래스에서 데이터 은닉을 구현하는 방법은 아주 간단합니다.

  1. 클래스의 데이터 멤버(변수)들을 private으로 만듭니다 (사용자가 직접 건드리지 못하게요). 멤버 함수의 알맹이 코드는 원래부터 사용자가 직접 접근할 수 없으니 따로 건드릴 필요가 없습니다.

  2. 사용자가 호출할 수 있도록 멤버 함수들을 public으로 만듭니다.

이 규칙을 따르면, 사용자는 오직 '퍼블릭 인터페이스(public 함수들)'를 통해서만 객체를 다루게 되며, 내부의 복잡한 구현 세부 사항에는 직접 접근할 수 없게 됩니다.

C++에서 정의하는 클래스들은 데이터 은닉을 사용해야 합니다.
실제로 C++ 표준 라이브러리에서 제공하는 모든 클래스들은 다 이렇게 만들어져 있습니다. 반면에 구조체는 데이터 은닉을 사용하면 안 됩니다. 구조체에 public이 아닌 멤버가 있으면 집계형 타입으로 취급받지 못하기 때문입니다.

클래스를 이런 식으로 만들려면 코드를 작성하는 사람은 손이 좀 더 갑니다. 클래스를 사용하는 사람 입장에서도 변수에 직접 접근하는 것보다 퍼블릭 인터페이스를 거쳐야 하니 좀 번거롭게 느껴질 수 있습니다. 하지만 이렇게 하면 클래스의 재사용성과 유지보수성을 높여주는 엄청난 장점들이 생깁니다. 레슨의 나머지 부분에서는 이 장점들에 대해 이야기해 보겠습니다.


용어 정리

프로그래밍에서 캡슐화라는 용어는 보통 다음 두 가지 중 하나를 뜻합니다.

  1. 하나 이상의 항목을 어떤 상자(컨테이너) 안에 가둬두는 것.
  2. 데이터와 그 데이터를 다루는 함수들을 하나로 묶는 것.

C++에서는 데이터를 가지고 있고, 그 데이터를 다루기 위한 퍼블릭 인터페이스를 가진 클래스를 가리켜 '캡슐화되었다'고 말합니다. 캡슐화는 데이터 은닉을 하기 위한 필수 조건이고, 데이터 은닉이 워낙 중요한 기법이다 보니, 보통 '캡슐화'라고 하면 '데이터 은닉'의 의미까지 포함해서 부르는 경우가 많습니다.

이 튜토리얼 시리즈에서는 캡슐화된 모든 클래스가 데이터 은닉을 적용하고 있다고 가정하겠습니다.


장점 1: 데이터 은닉은 클래스 사용을 쉽게 만들고 복잡성을 줄여줍니다.

캡슐화된 클래스를 사용할 때는 그 클래스가 내부적으로 어떻게 만들어졌는지 알 필요가 없습니다. 오직 인터페이스만 알면 됩니다.

즉, '어떤 public 함수를 쓸 수 있는지', '거기에 무슨 값을 넘겨줘야 하는지', '결과로 뭘 돌려받는지'만 이해하면 끝입니다.

예를 들어볼게요.

#include <iostream>
#include <string_view>

int main()
{
    std::string_view sv{ "Hello, world!" };
    std::cout << sv.length();

    return 0;
}

이 짧은 프로그램에서, std::string_view가 내부적으로 어떻게 만들어졌는지는 우리에게 전혀 노출되지 않습니다. 데이터 멤버가 몇 개인지, 이름이 뭔지, 타입이 뭔지 우리는 볼 수 없죠. length() 함수가 문자열의 길이를 정확히 어떻게 계산해서 돌려주는지도 모릅니다.

가장 멋진 점은, 우리가 그걸 알 필요가 없다는 겁니다! 프로그램은 그냥 잘 돌아갑니다.
우리는 그저 std::string_view 객체를 처음 만들 때 어떻게 하는지, 그리고 length() 함수가 뭘 반환하는지만 알면 됩니다.

이런 세부 사항을 신경 쓰지 않아도 된다는 건 프로그램의 복잡성을 엄청나게 줄여주고, 결과적으로 실수도 줄여줍니다. 다른 어떤 이유보다도, 이것이 바로 캡슐화의 가장 핵심적인 장점입니다.

만약 std::string이나 std::vector, std::cout을 사용하기 위해 그것들이 내부적으로 어떻게 구현되었는지 몽땅 이해해야 한다면, C++가 얼마나 끔찍하게 복잡했을지 상상해 보세요!


장점 2: 데이터 은닉은 '불변성'을 유지하게 해줍니다.

예전 클래스 입문 레슨 14.2 -- Introduction to classes 에서 클래스 불변성 이라는 개념을 소개한 적이 있습니다. 이는 객체가 살아있는 동안 '정상적인 상태'를 유지하기 위해 항상 참이어야 하는 조건들을 말합니다.

다음 프로그램을 살펴볼까요?

#include <iostream>
#include <string>

struct Employee // 기본적으로 멤버들은 public(공개) 상태입니다
{
    std::string name{ "John" };
    char firstInitial{ 'J' }; // 이름의 첫 글자와 일치해야 합니다

    void print() const
    {
        std::cout << "Employee " << name << " has first initial " << firstInitial << '\n';
    }
};

int main()
{
    Employee e{}; // 기본값인 "John"과 'J'로 설정됩니다
    e.print();

    e.name = "Mark"; // 직원의 이름을 "Mark"로 바꿉니다
    e.print(); // 잘못된 첫 글자가 출력됩니다

    return 0;
}

이 프로그램의 출력 결과는 다음과 같습니다.

Employee John has first initial J
Employee Mark has first initial J

우리 Employee 구조체에는 firstInitial(첫 글자)이 항상 name(이름)의 첫 번째 문자와 같아야 한다는 '불변성(규칙)'이 있습니다. 만약 이 규칙이 깨지면 print() 함수는 엉뚱한 결과를 내놓게 됩니다.

name 멤버가 public이기 때문에, main() 함수에서 e.name"Mark"로 직접 바꿀 수 있었습니다. 하지만 이때 firstInitial 멤버는 업데이트되지 않았죠. 우리의 규칙(불변성)이 깨졌고, 두 번째 print() 호출은 예상대로 동작하지 않았습니다.

이렇게 사용자에게 클래스의 내부 구현에 직접 접근할 권한을 주면, 모든 불변성(규칙)을 유지해야 하는 책임이 고스란히 사용자에게 넘어갑니다. 하지만 사용자는 그걸 제대로 안 할 수도 있죠. 사용자에게 이런 짐을 지우는 것은 코드를 매우 복잡하게 만듭니다.

이제 변수들을 private으로 숨기고, 직원의 이름을 설정하는 멤버 함수(public)를 제공하도록 프로그램을 다시 써보겠습니다:

#include <iostream>
#include <string>
#include <string_view>

class Employee // 기본적으로 멤버들은 private(비공개) 상태입니다
{
    std::string m_name{};
    char m_firstInitial{};

public:
    void setName(std::string_view name)
    {
        m_name = name;
        m_firstInitial = name.front(); // std::string::front()를 사용해 `name`의 첫 글자를 가져옵니다
    }

    void print() const
    {
        std::cout << "Employee " << m_name << " has first initial " << m_firstInitial << '\n';
    }
};

int main()
{
    Employee e{};
    e.setName("John");
    e.print();

    e.setName("Mark");
    e.print();

    return 0;
}

이제 프로그램이 예상대로 잘 동작합니다:

Employee John has first initial J
Employee Mark has first initial M

사용자 입장에서 바뀐 건 딱 하나입니다. name 변수에 직접 값을 넣는 대신, setName()이라는 함수를 호출하는 거죠. 그러면 이 함수가 알아서 m_namem_firstInitial을 둘 다 올바르게 설정해 줍니다. 사용자는 이제 이 규칙(불변성)을 신경 써야 하는 부담에서 완전히 벗어났습니다!


장점 3: 데이터 은닉은 오류를 더 잘 잡아내고 처리하게 해줍니다.

위 프로그램에서 m_firstInitialm_name의 첫 글자와 같아야 한다는 규칙이 생긴 이유는, m_firstInitial이라는 변수가 m_name과 별개로 존재하기 때문입니다.

아예 m_firstInitial 변수를 없애버리고, 대신 첫 글자를 반환하는 멤버 함수를 만들면 이 규칙 자체를 없앨 수 있습니다.

#include <iostream>
#include <string>

class Employee
{
    std::string m_name{ "John" };

public:
    void setName(std::string_view name)
    {
        m_name = name;
    }

    // std::string::front()를 사용해 `m_name`의 첫 글자를 가져옵니다
    char firstInitial() const { return m_name.front(); }

    void print() const
    {
        std::cout << "Employee " << m_name << " has first initial " << firstInitial() << '\n';
    }
};

int main()
{
    Employee e{}; // 기본값인 "John"으로 설정됩니다
    e.setName("Mark");
    e.print();

    return 0;
}

하지만, 이 프로그램에도 여전히 지켜야 할 '클래스 불변성'이 하나 숨어 있습니다.
잠깐 멈춰서 그게 뭔지 한번 알아맞혀 보세요.

정답은 바로 m_name이 빈 문자열("")이면 안 된다 는 것입니다.
(모든 직원은 이름이 있어야 하니까요) 만약 m_name이 빈 문자열로 설정되면 당장은 아무 일도 안 일어나겠지만, 나중에 firstInitial()이 호출될 때 std::stringfront() 함수가 텅 빈 문자열의 첫 글자를 가져오려다가 '정의되지 않은 동작'을 일으키게 됩니다.

이상적으로는 m_name이 애초에 텅 빌 수 없게 막고 싶을 겁니다.

만약 사용자가 m_name에 직접 접근할 수 있다면,
그냥 m_name = "" 이라고 써버리면 그만이고 우리가 막을 방법이 없습니다.

하지만 우리는 사용자가 반드시 setName()이라는 public 함수를 통해서만 이름을 설정하도록 강제했기 때문에, setName() 함수 안에서 사용자가 준 이름이 올바른지 검사(validate) 할 수 있습니다. 이름이 비어있지 않다면 m_name에 저장하고, 만약 빈 문자열이라면 다음과 같은 여러 가지 대처를 할 수 있습니다:

  • 빈 문자열로 설정하려는 요청을 무시하고 그냥 돌아가기.
  • Assert(단언문)로 프로그램 중단시키기.
  • 예외(Exception) 던지기.
  • 호키포키 춤추기. 아, 잠깐, 이건 아니네요.

여기서 핵심은 사용자의 잘못된 사용을 미리 감지하고, 우리가 보기에 가장 적절한 방식으로 알아서 처리할 수 있다 는 점입니다. 이런 상황을 어떻게 처리하는 게 가장 좋은지는 나중에 다른 레슨에서 다루겠습니다.


장점 4: 데이터 은닉을 사용하면 기존 프로그램을 망가뜨리지 않고 내부 코드를 바꿀 수 있습니다.

이 간단한 예제를 보세요.

#include <iostream>

struct Something
{
    int value1 {};
    int value2 {};
    int value3 {};
};

int main()
{
    Something something;
    something.value1 = 5;
    std::cout << something.value1 << '\n';
}

이 프로그램은 잘 돌아가지만, 만약 우리가 클래스 내부를 다음과 같이 바꾼다면 어떻게 될까요?

#include <iostream>

struct Something
{
    int value[3] {}; // 3개의 값을 가진 배열을 사용합니다
};

int main()
{
    Something something;
    something.value1 = 5;
    std::cout << something.value1 << '\n';
}

아직 배열을 배우진 않았지만 신경 쓰지 마세요. 중요한 건, value1이라는 이름의 변수가 더 이상 존재하지 않기 때문에 이 프로그램은 아예 컴파일조차 되지 않는다 는 점입니다.
main() 함수에서는 여전히 사라진 value1이라는 이름을 쓰고 있으니까요.

데이터 은닉을 하면, 클래스를 사용하는 기존 프로그램들을 망가뜨리지 않고도 클래스의 내부 작동 방식을 마음대로 바꿀 수 있습니다.

위 예제를 캡슐화해서 m_value1에 접근하는 함수를 만든 버전입니다.

#include <iostream>

class Something
{
private:
    int m_value1 {};
    int m_value2 {};
    int m_value3 {};

public:
    void setValue1(int value) { m_value1 = value; }
    int getValue1() const { return m_value1; }
};

int main()
{
    Something something;
    something.setValue1(5);
    std::cout << something.getValue1() << '\n';
}

이제 이 클래스의 내부를 다시 배열로 바꿔보겠습니다.

#include <iostream>

class Something
{
private:
    int m_value[3]; // 참고: 이 클래스의 내부 구현을 변경했습니다!

public:
    // 새로운 내부 구현에 맞춰 멤버 함수들도 업데이트해야 합니다
    void setValue1(int value) { m_value[0] = value; }
    int getValue1() const { return m_value[0]; }
};

int main()
{
    // 하지만 이 클래스를 사용하는 바깥쪽 프로그램 코드는 수정할 필요가 없습니다!
    Something something;
    something.setValue1(5);
    std::cout << something.getValue1() << '\n';
}

우리가 클래스의 '퍼블릭 인터페이스(함수 이름이나 사용법)'를 바꾸지 않았기 때문에, 이 인터페이스를 사용하는 바깥쪽 프로그램(main())은 단 한 줄도 고칠 필요가 없었고 예전과 똑같이 잘 돌아갑니다.

비유하자면, 밤에 요정들이 몰래 집에 들어와서 TV 리모컨의 내부 부품을 다른 (하지만 호환되는) 기술로 싹 바꿔놓아도, 여러분은 겉보기엔 똑같이 작동하니까 아마 눈치채지도 못할 겁니다!


장점 5: 인터페이스가 있는 클래스는 버그 찾기(디버깅)가 더 쉽습니다.

마지막으로, 캡슐화는 프로그램에 문제가 생겼을 때 원인을 찾는 데 큰 도움을 줍니다. 프로그램이 오작동하는 경우는 보통 멤버 변수 중 하나에 잘못된 값이 들어갔기 때문입니다. 만약 누구나 변수에 직접 접근해서 값을 바꿀 수 있다면, 수많은 코드 중에서 도대체
'어느 부분'이 이 변수를 망쳐놨는지 추적하기가 굉장히 힘듭니다. 변수를 수정하는 모든 곳에 중단점을 걸고 확인해야 하는데, 그런 곳이 수십, 수백 군데일 수도 있거든요.

하지만 오직 '단 하나의 멤버 함수'를 통해서만 변수를 바꿀 수 있게 해 두면, 그냥 그 함수 딱 하나에만 중단점을 걸어두고 누가 어떤 값을 넘겨주는지 지켜보면 됩니다. 범인을 잡아내기가 훨씬 쉬워지는 거죠.


멤버 함수보다는 '비멤버 함수'를 선호하세요

C++에서는 어떤 함수를 클래스 바깥의 일반 함수(비멤버 함수)로 만들 수 있다면,
클래스 안의 멤버 함수로 만드는 것보다 비멤버 함수로 만드는 것을 권장 합니다.

여기에는 수많은 장점이 있습니다.

  1. 비멤버 함수는 클래스 인터페이스의 일부가 아닙니다. 따라서 클래스의 인터페이스가 훨씬 작고 단순해져서, 클래스를 이해하기가 더 쉬워집니다.

  2. 비멤버 함수는 클래스의 내부 구현에 직접 접근할 수 없고 반드시 퍼블릭 인터페이스를 거쳐야 하므로, 캡슐화 원칙을 강제로 지키게 만듭니다.
    '편하다'는 이유로 내부 구현을 몰래 건드리고 싶은 유혹을 차단합니다.

  3. 클래스의 내부 구현을 바꿀 때, 비멤버 함수는 신경 쓰지 않아도 됩니다
    (인터페이스 사용법 자체가 바뀌지 않는 한).

  4. 비멤버 함수가 보통 디버깅하기 더 쉽습니다.

  5. 특정 프로그램에만 필요한 데이터나 논리를 담은 비멤버 함수를, 여기저기 재사용하기 좋은 클래스 본체로부터 분리해 낼 수 있습니다.

만약 Java나 C# 같은 최신 객체 지향 언어(OOP)를 써보신 적이 있다면, 이 조언이 꽤 놀랍게 들리실 겁니다. 그 언어들은 '클래스가 우주의 중심'이고 모든 것이 클래스를 중심으로 돌아간다는 개념을 사용합니다. 그래서 멤버 함수를 가장 중요하게 생각하죠
(사실 Java나 C#은 비멤버 함수 자체를 아예 지원하지 않습니다).

모범 사례
가능하면 함수는 비멤버(클래스 밖의 일반 함수)로 구현하는 것이 좋습니다.
(특히 특정 프로그램에만 딱 맞춰진 논리나 데이터가 들어간 함수일수록 더 그렇습니다).

팁 (Tip)
함수를 멤버로 만들지 비멤버로 만들지 결정하는 간단한 가이드라인입니다.

  1. 어쩔 수 없을 때는 멤버 함수를 쓰세요.
    C++에서는 생성자, 소멸자, 가상 함수, 특정 연산자 등 몇몇 함수들은 반드시 멤버 함수로만 만들어야 합니다. (다음 레슨에서 생성자에 대해 배울 때 보게 될 거예요).

  2. 외부로 노출하면 안 되는 private(또는 protected) 데이터에 접근해야만 하는 함수라면
    멤버 함수를 우선 고려하세요.

  3. 그 외의 경우(특히 객체의 상태를 바꾸지 않는 함수라면)에는 비멤버 함수를 우선 고려하세요.

마지막 두 가지 규칙에는 예외가 좀 있는데, 이건 나중에 관련 주제를 다룰 때 설명해 드릴게요.

한 가지 고민되는 상황은, 비멤버 함수를 쓰려고 했더니 그러기 위해 클래스에 새로운 접근 함수(Getter 등)를 억지로 추가해야 할 때입니다. 이럴 때는 장단점을 따져봐야 합니다.
접근 함수를 추가한다는 건 인터페이스의 크기와 복잡성이 커진다는 뜻입니다. 이 새로운 접근 함수를 다른 곳에서도 여러 번 쓸 게 아니라면, 굳이 추가할 가치가 없을 수도 있습니다.
(내부 상태라서) 밖으로 노출되면 안 되거나, 사용자가 클래스의 불변성을 깨뜨릴 위험이 있는 데이터에는 절대 접근 함수(Getter/Setter)를 추가하지 마세요.

세 가지 비슷한 예제를 통해 '가장 안 좋은 방식'부터 '가장 좋은 방식'까지 순서대로 비교해 보겠습니다.

#include <iostream>
#include <string>

class Yogurt
{
    std::string m_flavor{ "vanilla" };

public:
    void setFlavor(std::string_view flavor)
    {
        m_flavor = flavor;
    }

    const std::string& getFlavor() const { return m_flavor; }

    // 최악: getter 함수가 있는데도 print() 멤버 함수가 m_flavor에 직접 접근합니다
    void print() const
    {
        std::cout << "The yogurt has flavor " << m_flavor << '\n';
    }
};

int main()
{
    Yogurt y{};
    y.setFlavor("cherry");
    y.print();

    return 0;
}

위 코드는 최악의 버전 입니다.
맛을 알려주는 getter(getFlavor())가 있는데도 print() 멤버 함수가 m_flavor에 직접 접근하고 있습니다. 나중에 클래스 내부 구현이 바뀌면 print()도 같이 뜯어고쳐야 할 확률이 높습니다. 게다가 print()가 출력하는 문구는 이 프로그램에만 맞춰진 내용이라, 다른 프로그램에서 이 클래스를 쓰려면 문구를 바꾸기 위해 클래스를 복사하거나 수정해야 합니다.

#include <iostream>
#include <string>

class Yogurt
{
    std::string m_flavor{ "vanilla" };

public:
    void setFlavor(std::string_view flavor)
    {
        m_flavor = flavor;
    }

    const std::string& getFlavor() const { return m_flavor; }

    // 더 나음: print() 멤버 함수가 데이터 멤버에 직접 접근하지 않습니다
    void print(std::string_view prefix) const
    {
        std::cout << prefix << ' ' << getFlavor() << '\n';
    }
};

int main()
{
    Yogurt y{};
    y.setFlavor("cherry");
    y.print("The yogurt has flavor");

    return 0;
}

위 버전은 더 낫지만, 아직 완벽하진 않습니다.
print()가 여전히 멤버 함수이긴 하지만, 최소한 변수에 직접 접근하진 않습니다.
내부 구현이 바뀌더라도 print()를 고칠 필요가 없습니다. 출력할 앞부분 문구(prefix)를 매개변수로 받게 만들어서, 문구를 결정하는 역할을 클래스 밖의 main()으로 넘겼습니다. 하지만 이 함수는 여전히 출력 방식(앞문구 + 공백 + 맛 + 줄바꿈)을 강제합니다. 만약 다른 프로그램에서 출력 형식을 다르게 하고 싶다면 또 다른 함수를 추가해야 합니다.

#include <iostream>
#include <string>

class Yogurt
{
    std::string m_flavor{ "vanilla" };

public:
    void setFlavor(std::string_view flavor)
    {
        m_flavor = flavor;
    }

    const std::string& getFlavor() const { return m_flavor; }
};

// 최고: 멤버 함수가 아닌 일반 함수 print()는 클래스 인터페이스의 일부가 아닙니다
void print(const Yogurt& y)
{
    std::cout << "The yogurt has flavor " << y.getFlavor() << '\n';
}

int main()
{
    Yogurt y{};
    y.setFlavor("cherry");
    print(y);

    return 0;
}

위 버전이 가장 좋은 버전 입니다. print()는 이제 클래스 밖으로 완전히 빠져나온 비멤버 함수입니다. 멤버 변수에도 직접 접근하지 않습니다. 클래스 내부가 바뀌어도 print()는 전혀 신경 쓸 필요가 없습니다. 게다가, 이 클래스를 가져다 쓰는 각 프로그램들은 자기들 입맛에 맞게 출력하는 자신만의 print() 함수를 따로 만들어서 쓰면 됩니다.


클래스 멤버를 적는 순서

일반적인 코드를 작성할 때는 변수나 함수를 사용하기 '전에' 무조건 먼저 선언해야 합니다. 하지만 클래스 안에서는 이런 제약이 없습니다. 이전 레슨(14.3 -- Member functions)에서 말씀드렸듯, 멤버들을 원하는 순서대로 마음껏 배치할 수 있습니다.

그렇다면 어떤 순서로 적는 게 제일 좋을까요?

크게 두 가지 파벌(?)이 있습니다.

  1. private 멤버를 먼저 쓰고, 그 아래에 public 멤버 함수를 쓰는 방식: 전통적인 '선언 후 사용' 스타일에 가깝습니다. 코드를 읽는 사람은 멤버 함수가 쓰기 전에 데이터 변수들이 어떻게 생겼는지 먼저 볼 수 있어서, 세부적인 내부 구현을 이해하는 데 도움이 됩니다.
  2. public 멤버를 맨 위에 쓰고, private 멤버를 맨 아래로 빼는 방식: 이 클래스를 사용하는 다른 사람들은 '퍼블릭 인터페이스(사용법)'에 가장 관심이 많습니다. 그래서 가장 필요한 정보를 맨 위에 두고, (가장 안 중요한) 내부 구현을 맨 밑으로 밀어버리는 겁니다.

현대 C++에서는, 특히 다른 개발자들과 코드를 공유할 때는 두 번째 방법(public 먼저, private 나중에) 을 훨씬 더 많이 추천합니다.

모범 사례
public 멤버를 가장 먼저 선언하고, 그다음에 protected 멤버, 마지막으로 private 멤버를 선언하세요. 이렇게 하면 외부에 공개되는 인터페이스를 돋보이게 하고, 내부 세부 구현은 덜 눈에 띄게 숨겨줍니다.

저자의 말
이 사이트에 있는 대부분의 예제 코드는 추천 방식과 정반대의 순서(private 먼저)를 사용하고 있습니다. 역사적인 이유도 있지만, 언어의 원리를 막 배우는 단계에서는 내부가 어떻게 돌아가는지 해부하고 분석하는 데 집중하기 때문에 변수가 먼저 보이는 게 더 직관적이라고 생각하기 때문입니다.

고급 독자를 위해
Google C++ 스타일 가이드에서는 다음 순서를 권장합니다.

  1. 타입 및 타입 별칭 (typedef, using, enum, 중첩 구조체/클래스, friend 타입)
  2. Static(정적) 상수
  3. 팩토리 함수
  4. 생성자 및 대입 연산자
  5. 소멸자
  6. 그 외 모든 함수 (static 및 비-static 멤버 함수, friend 함수)
  7. 데이터 멤버 변수 (static 및 비-static)

14.9 — 생성자(Constructors) 소개

만약 클래스가 집계 형태일 때는, 우리는 '집계 초기화'라는 방법을 써서 클래스를 아주 직접적이고 간단하게 초기화할 수 있습니다.

struct Foo // Foo는 집계(aggregate) 형태입니다
{
    int x {};
    int y {};
};

int main()
{
    Foo foo { 6, 7 }; // 집계 초기화를 사용합니다

    return 0;
}

집계 초기화는 멤버 변수들이 정의된 순서대로 차례차례 값을 넣어줍니다. 그래서 위 예제에서 foo 객체가 짠 하고 만들어질 때, foo.x 에는 6이 들어가고 foo.y 에는 7이 들어갑니다.

관련 내용
집계가 정확히 무엇인지, 그리고 집계 초기화에 대한 자세한 내용은
13.8 - 구조체 집계 초기화 강의에서 다루었습니다.

하지만 데이터를 안전하게 숨기기 위해 멤버 변수 중 단 하나라도 private (비공개)으로 만드는 순간, 이 클래스는 더 이상 집계 형태가 아닙니다. (집계 형태는 private 멤버를 가질 수 없다는 규칙이 있거든요.) 즉, 더 이상 그 편리했던 집계 초기화를 쓸 수 없다는 뜻입니다.

class Foo // Foo는 집계 형태가 아닙니다 (private 멤버를 가지고 있기 때문이죠)
{
    int m_x {};
    int m_y {};
};

int main()
{
    Foo foo { 6, 7 }; // 컴파일 에러: 집계 초기화를 사용할 수 없습니다

    return 0;
}

private 멤버가 있는 클래스에서 집계 초기화를 막아둔 것은 사실 아주 합리적인 이유가 있습니다.

  1. 집계 초기화를 하려면 클래스 내부가 어떻게 생겼는지(멤버가 무엇이고 어떤 순서로 만들어졌는지) 다 알아야 합니다. 하지만 우리가 데이터를 private 으로 숨기는 이유는 바로 그런 내부 사정을 외부에서 모르게 감추려고 하는 것이니까요.

  2. 만약 클래스가 반드시 지켜야 하는 규칙(불변성)을 가지고 있다면,
    사용자가 마음대로 아무 값이나 집어넣게 놔두면 안 되기 때문입니다.

그렇다면 private 멤버 변수를 가진 클래스는 도대체 어떻게 초기화해야 할까요?
아까 예제에서 컴파일러가 뿜어낸 에러 메시지에 힌트가 있습니다.

"error: no matching constructor for initialization of ‘Foo'"
(에러: 'Foo'를 초기화할 수 있는 일치하는 생성자가 없습니다)

아하! 우리에게는 딱 맞는 생성자가 필요한 거였네요. 그런데 생성자가 대체 뭘까요?


생성자

생성자(Constructor) 란 집계 형태가 아닌 클래스 객체가 만들어진 직후에 알아서 자동으로 실행되는 아주 특별한 함수입니다.

우리가 집계 형태가 아닌 객체를 만들려고 할 때, 컴파일러는 우리가 넘겨준 초기화 값
(예: 6과 7)을 받아줄 수 있는 '접근 가능한 생성자'가 있는지 열심히 찾아봅니다.

  • 만약 딱 맞는 생성자를 찾으면, 객체가 들어갈 메모리 공간을 먼저 확보한 다음,
    그 생성자 함수를 실행합니다.
  • 만약 딱 맞는 생성자를 찾지 못하면, 아까 보신 것처럼 컴파일 에러가 발생합니다.

핵심 포인트
초보 프로그래머분들이 "생성자가 객체를 만들어내는(create) 건가요?" 하고 많이들 헷갈려합니다. 정답은 '아닙니다' 에요. 컴파일러가 생성자를 부르기 전에 이미 객체가 들어갈 빈 공간(메모리)을 미리 다 준비해 둡니다. 생성자는 그저 '아직 텅 비어있는 객체'에 불려가서 내부를 꾸며주는(초기화하는) 역할만 할 뿐입니다.
하지만 주의할 점은, 우리가 넘겨준 값과 짝이 맞는 생성자를 찾지 못하면 컴파일러가 에러를 내기 때문에, 결과적으로 생성자가 없으면 객체 자체를 만들 수 없는 것은 맞습니다.

생성자는 객체를 어떻게 만들지 결정하는 것 외에도 보통 다음 두 가지 중요한 일을 합니다.

  1. 멤버 변수들을 초기화합니다 (보통 '멤버 초기화 리스트'라는 것을 사용합니다).
  2. 다른 필요한 준비 작업을 합니다. 예를 들어, 들어온 값이 올바른지 검사하거나, 파일이나 데이터베이스를 여는 등의 작업을 생성자 안에서 할 수 있습니다.

생성자가 무사히 자기 할 일을 마치면, 우리는 "객체가 잘 생성(constructed) 되었다" 고 말합니다. 이제 이 객체는 안심하고 사용할 수 있는 완벽한 상태가 된 것입니다.

참고로, 집계 형태는 생성자를 가질 수 없게 되어 있습니다. 만약 여러분이 집계 형태였던 구조체나 클래스에 생성자를 직접 추가해버리면, 그건 더 이상 집계 형태가 아니게 됩니다.


생성자 이름 짓기

일반적인 멤버 함수들과 다르게, 생성자는 이름을 지을 때 꼭 지켜야 할 엄격한 규칙이 있습니다.

  • 생성자의 이름은 반드시 클래스 이름과 똑같아야 합니다 (대소문자까지 완벽히 같아야 합니다). 만약 템플릿 클래스라면, 템플릿 매개변수 부분은 뺀 기본 이름만 사용합니다.
  • 생성자는 반환값(return type)이 아예 없습니다 (void 조차도 쓰지 않습니다).
  • 생성자는 주로 사람들이 외부에서 객체를 만들 때 사용하므로 보통 public (공개)으로 설정합니다.

기본적인 생성자 예제

아까 에러가 났던 코드에 기본적인 생성자를 추가해서 고쳐볼까요?

#include <iostream>

class Foo
{
private:
    int m_x {};
    int m_y {};

public:
    Foo(int x, int y) // 여기가 바로 두 개의 초기화 값을 받아주는 생성자 함수입니다
    {
        std::cout << "Foo(" << x << ", " << y << ") constructed\n";
    }

    void print() const
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
    }
};

int main()
{
    Foo foo{ 6, 7 }; // Foo(int, int) 형태의 생성자를 자동으로 부릅니다
    foo.print();

    return 0;
}

이제 이 프로그램은 정상적으로 실행되고 다음과 같은 결과를 보여줍니다:

Foo(6, 7) constructed
Foo(0, 0)

컴파일러가 Foo foo{ 6, 7 } 이라는 코드를 보면, 정수(int) 두 개를 받을 수 있는 Foo 생성자가 있는지 찾아봅니다. 방금 우리가 만든 Foo(int, int) 가 딱 맞기 때문에 컴파일러가 통과시켜 준 것입니다!

프로그램이 실행될 때 foo 객체가 만들어지면, 메모리 자리를 먼저 맡아둔 다음
Foo(int, int) 생성자가 실행됩니다. 이때 매개변수 x 에는 6이, y 에는 7이 들어갑니다. 그리고 생성자 안의 내용이 실행되면서 화면에 Foo(6, 7) constructed 라고 출력되죠.

그런데 화면에 출력된 두 번째 줄을 보면 쪼금 이상합니다.
print() 함수를 불렀을 때 멤버 변수인 m_xm_y 의 값이 0으로 나오죠?
그 이유는 우리가 Foo(int, int) 생성자를 부르긴 했지만, 생성자 안에서 실제로 멤버 변수들에 그 값을 넣어주는 코드를 작성하지 않았기 때문입니다. 진짜로 멤버 변수에 값을 넣어주는 방법은 다음 강의에서 자세히 배울 예정입니다.

관련 내용
생성자로 객체를 초기화할 때 쓰이는 복사(copy), 직접(direct), 리스트(list) 초기화 방식의 차이점은 14.15 - 클래스 초기화와 복사 생략 강의에서 다룹니다.


생성자의 암시적 형 변환 (알아서 타입 맞춰주기)

이전 10.1 - 암시적 형 변환 강의에서, 함수에 넘겨준 값의 타입이 함수가 원래 기대했던 타입과 다를 경우, 컴파일러가 알아서(암시적으로) 타입을 변환해 준다는 것을 배웠습니다.

void foo(int, int)
{
}

int main()
{
    foo('a', true); // 'a'와 true가 알아서 int로 바뀌어 foo(int, int)와 짝지어집니다

    return 0;
}

생성자도 이와 전혀 다르지 않습니다!.
Foo(int, int) 생성자는 int 로 몰래 바꿀 수 있는 값이라면 어떤 값이 들어와도 찰떡같이 받아줍니다.

class Foo
{
public:
    Foo(int x, int y)
    {
    }
};

int main()
{
    Foo foo{ 'a', true }; // 'a'와 true가 int로 변환되어 Foo(int, int) 생성자를 부릅니다

    return 0;
}

생성자는 const(상수)이면 안 됩니다

생성자가 존재하는 이유는 바로 새로 만들어지는 객체에 처음 값을 세팅해 주는 것입니다.
값을 세팅(변경)해야 하는데, 절대 변경할 수 없음을 뜻하는 const 를 생성자에 붙이면 안 되겠죠? 따라서 생성자는 절대로 const 일 수 없습니다.

#include <iostream>

class Something
{
private:
    int m_x{};

public:
    Something() // 생성자는 반드시 non-const(const가 아닌 상태)여야 합니다
    {
        m_x = 5; // non-const 생성자 안에서 멤버 값을 바꾸는 건 전혀 문제 없습니다
    }

    int getX() const { return m_x; } // 이 함수는 const 함수입니다
};

int main()
{
    const Something s{}; // const 객체를 만들지만, 그 과정에서 (non-const인) 생성자를 알아서 부릅니다

    std::cout << s.getX(); // 5가 출력됩니다

    return 0;
}

원래 const 로 만들어진 객체에는 값을 바꿀 여지가 있는 함수(non-const 함수)를 쓸 수 없습니다.

하지만 C++ 표준 규칙에 따르면, "객체가 처음 조립(생성)되고 있는 과정 중에는 const 규칙이 적용되지 않으며, 생성자가 완전히 끝난 후부터 비로소 const 규칙이 켜진다" 고 명시되어 있습니다. (그래서 안심하고 초기화할 수 있는 것입니다!)


생성자(Constructors) vs 세터(Setters)

  • 생성자는 객체가 처음 세상에 태어나는 순간에 객체 전체를 한 번에 초기화하기 위해 만들어졌습니다.
  • 세터(Setter) 함수는 이미 만들어져서 살아가고 있는 객체의 특정 멤버 변수 하나의 값만 콕 집어서 바꿀 때 사용하기 위해 만들어졌습니다.

14.10 — 생성자 멤버 초기화 리스트

멤버 초기화 리스트로 멤버 변수 초기화하기

생성자가 클래스 안의 멤버 변수들에 처음 값을 초기화하도록 만들려면,
멤버 초기화 리스트라는 것을 사용해요. (배열 같은 걸 초기화할 때 쓰는 그냥 '초기화 리스트'와 이름이 비슷해서 헷갈릴 수 있으니 주의하세요!)

백문이 불여일견이죠. 예제를 보는 게 가장 이해하기 쉽습니다.
아래 예제에서 Foo(int, int) 생성자는 m_xm_y 에 값을 넣기 위해 멤버 초기화 리스트를 사용하도록 업데이트되었습니다.

#include <iostream>

class Foo
{
private:
    int m_x {};
    int m_y {};

public:
    Foo(int x, int y)
        : m_x { x }, m_y { y } // 여기가 바로 우리의 멤버 초기화 리스트입니다!
    {
        std::cout << "Foo(" << x << ", " << y << ") 가 생성되었습니다\n";
    }

    void print() const
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
    }
};

int main()
{
    Foo foo{ 6, 7 };
    foo.print();

    return 0;
}

멤버 초기화 리스트는 생성자가 받는 매개변수 괄호 바로 뒤에 적어줍니다.
시작할 때 콜론(:)을 찍고, 초기화할 멤버 변수와 그 값을 쉼표(,)로 구분해서 나열하면 돼요.

여기서는 반드시 중괄호 {} 나 괄호 () 를 사용하는 직접 초기화 방식을 써야 합니다.
(중괄호를 쓰는 것을 더 권장해요)

등호(=)를 사용하는 복사 초기화 방식은 여기선 작동하지 않거든요.
그리고 리스트 맨 끝에는 세미콜론(;)을 붙이지 않는다는 점도 꼭 기억해 주세요!

이 프로그램을 실행하면 다음과 같은 결과가 나옵니다.

Foo(6, 7) 가 생성되었습니다
Foo(6, 7)

foo 라는 객체가 짜잔~ 하고 만들어질 때, 리스트에 적혀 있던 멤버 변수들이 지정된 값으로 초기화됩니다. 위 코드에서는 m_xx 의 값인 6으로, m_yy 의 값인 7로 초기화되는 거죠. 그 작업이 끝나면 비로소 생성자의 몸체(중괄호 안의 코드)가 실행됩니다.

print() 함수를 불러보면, m_xm_y 가 성공적으로 6과 7의 값을 가지고 있는 걸 볼 수 있습니다.


멤버 초기화 리스트 작성 스타일 (줄바꿈 팁)

C++ 에서는 빈칸이나 줄바꿈을 자유롭게 할 수 있어서, 본인이 보기 편한 스타일로 코드를 꾸밀 수 있어요. 아래의 세 가지 스타일 모두 문법적으로 완벽하게 정상입니다
(실제 실무에서도 세 가지 모두 흔히 볼 수 있어요).

Foo(int x, int y) : m_x { x }, m_y { y }
{
}
Foo(int x, int y) :
    m_x { x },
    m_y { y }
{
}
Foo(int x, int y)
    : m_x { x }
    , m_y { y }
{
}

가장 추천하는 방식은 세 번째 스타일입니다:

  • 생성자 이름 바로 아랫줄에 콜론(:)을 두면,
    함수 부분과 초기화 리스트 부분이 눈에 띄게 깔끔하게 분리됩니다.
  • 초기화 리스트 부분을 들여쓰기하면 함수 이름이 훨씬 더 잘 보여요.

만약 초기화할 멤버가 아주 적고 간단하다면,
첫 번째 스타일처럼 한 줄에 다 적어도 괜찮습니다.

Foo(int x, int y)
    : m_x { x }, m_y { y }
{
}

하지만 내용이 길어진다면, 줄을 맞추기 위해 쉼표(,)를 앞에 두고 각 변수를 새로운 줄에 적는 세 번째 방식을 많이 씁니다.

Foo(int x, int y)
    : m_x { x }
    , m_y { y }
{
}

멤버 초기화 순서

C++ 의 규칙에 따르면, 변수들이 초기화되는 순서는 초기화 리스트에 적어둔 순서가 아닙니다. 반드시 클래스 내부에서 변수들이 선언된 순서대로 초기화됩니다.

위의 예제에서 클래스 안을 보면 m_xm_y 보다 먼저 선언되어 있죠? 그렇기 때문에 설령 초기화 리스트 맨 끝에 m_x 를 적어두더라도, 무조건 m_x 가 먼저 초기화됩니다.

우리는 보통 글을 읽듯 '왼쪽에서 오른쪽으로 초기화되겠지?' 라고 직관적으로 생각하기 때문에, 이 규칙을 모르면 아주 찾기 힘든 버그를 만들 수 있어요. 다음 예제를 볼까요?

#include <algorithm> // std::max 를 사용하기 위해
#include <iostream>

class Foo
{
private:
    int m_x{};
    int m_y{};

public:
    Foo(int x, int y)
        : m_y { std::max(x, y) }, m_x { m_y } // 바로 이 줄에 문제가 있습니다!
    {
    }

    void print() const
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
    }
};

int main()
{
    Foo foo { 6, 7 };
    foo.print();

    return 0;
}

이 코드를 짠 사람의 의도는 이랬을 거예요.
"들어온 x, y 값 중 더 큰 값을 찾아서 m_y 에 먼저 넣고, 그 m_y 값을 이용해서 m_x 에도 똑같이 넣어줘야지!"

하지만 작성자의 컴퓨터에서 이 코드를 실행해 보면 다음과 같이 이상한 결과가 나옵니다.

Foo(-858993460, 7)

무슨 일이 일어난 걸까요?
초기화 리스트에는 m_y 가 먼저 적혀 있지만, 클래스 안에서 선언된 순서를 보면 m_x 가 먼저입니다. 따라서 m_x 가 먼저 초기화를 시작합니다.
그런데 m_x 에 넣으려는 값은 m_y 네요? m_y 는 아직 초기화도 안 된 상태인데 말이죠! 그래서 m_x 에는 알 수 없는 쓰레기값이 들어가 버립니다.
그후에서야 m_y 가 정상적으로 큰 값을 받아 초기화됩니다.

핵심 포인트
이런 실수를 막기 위해, 멤버 초기화 리스트의 순서는 반드시 클래스 안에 선언된 순서와 똑같이 맞춰서 적어주세요. 똑똑한 컴파일러들은 순서가 꼬여있으면 경고를 띄워주기도 합니다.

또한, 어떤 멤버 변수를 초기화할 때 '다른 멤버 변수'의 값을 가져다 쓰는 것은 최대한 피하는 것이 좋습니다. 그렇게 하면 실수로 순서가 틀리더라도 값들이 서로 얽혀있지 않아서 문제가 생기지 않거든요.


멤버 초기화 리스트 vs 기본 멤버 초기화

멤버 변수에 처음 값을 넣어주는 방법은 여러 가지가 있고, 우선순위가 정해져 있습니다.

  1. 1순위 멤버 변수가 '멤버 초기화 리스트'에 적혀 있다면, 그 값이 가장 먼저 선택됩니다.
  2. 2순위 리스트에는 없지만, 변수를 선언할 때 아예 기본값을 지정해 뒀다면(기본 멤버 초기화), 그 값이 선택됩니다.
  3. 3순위 둘 다 없다면, C++ 의 기본 방식대로 처리됩니다 (숫자 같은 경우엔 보통 의미 없는 쓰레기값이 남아있게 됩니다).

즉, 클래스에 기본값도 적혀있고 초기화 리스트에도 값이 있다면,
초기화 리스트의 값이 우선 한다는 뜻이에요!
세가지 경우가 모두 들어있는 예제를 볼게요.

#include <iostream>

class Foo
{
private:
    int m_x {};    // 기본 멤버 초기화 (초기화 리스트가 이기므로 무시됩니다)
    int m_y { 2 }; // 기본 멤버 초기화 (초기화 리스트에 없으므로 이 값이 사용됩니다)
    int m_z;       // 초기화 값 없음

public:
    Foo(int x)
        : m_x { x } // 멤버 초기화 리스트
    {
        std::cout << "Foo 가 생성되었습니다\n";
    }

    void print() const
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ", " << m_z << ")\n";
    }
};

int main()
{
    Foo foo { 6 };
    foo.print();

    return 0;
}

출력 결과는 다음과 같습니다.

Foo 가 생성되었습니다
Foo(6, 2, -858993460)

foo 가 만들어질 때 상황을 살펴볼게요.

  • m_x 는 초기화 리스트에 있으므로 전달받은 6이 됩니다.
    (선언부의 빈 중괄호 {} 는 무시돼요)
  • m_y 는 리스트에 없지만 기본값 2가 설정되어 있어서 2가 됩니다.
  • m_z 는 리스트에도 없고 기본값도 없기 때문에 초기화되지 않고 방치됩니다.
    그래서 출력해보면 쓰레기값(-858993460 같은)이 나오는 예상치 못한 결과가 발생합니다.

생성자 함수 몸체 (중괄호 내부)

생성자의 몸체(중괄호 {} 안)는 보통 텅 비워두는 경우가 많습니다.
왜냐하면 우리가 생성자를 쓰는 주된 목적인 '초기화'는 방금 배운 멤버 초기화 리스트에서 이미 다 끝났기 때문이에요. 더 할 일이 없으면 비워두면 됩니다.

하지만 초기화 리스트가 끝난 다음 추가로 뭔가 더 설정해야 할 일이 있다면, 이 몸체 안에 코드를 적어주면 됩니다. 파일이나 데이터베이스를 열거나, 메모리를 추가로 배정하거나 하는 일들 말이죠. 위 예제들에서는 생성자가 잘 실행되었다고 화면에 문구를 출력하는 용도로 썼네요.

초보 프로그래머분들이 자주 하는 실수 중 하나는, 생성자 몸체 안에서 멤버 변수에 값을 넣으려고 하는 거예요.

#include <iostream>

class Foo
{
private:
    int m_x { 0 };
    int m_y { 1 };

public:
    Foo(int x, int y)
    {
        m_x = x; // 잘못된 방법: 이것은 '초기화'가 아니라 이미 만들어진 변수에 값을 덮어씌우는 '대입(할당)' 입니다.
        m_y = y; // 잘못된 방법: 이것도 '대입(할당)' 입니다.
    }

    void print() const
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
    }
};

int main()
{
    Foo foo { 6, 7 };
    foo.print();

    return 0;
}

이런 간단한 코드에서는 겉보기에 결과가 똑같이 나오니까 문제가 없어 보일 수 있습니다. 하지만, C++ 에는 한 번 값이 정해지면 바꿀 수 없는 변수(const)나 참조자(reference) 같은 특별한 변수들이 있어요. 이 친구들은 오직 '초기화' 단계에서만 값을 가질 수 있기 때문에, 위 코드처럼 몸체 안에서 값을 '대입(할당)'하려고 하면 에러가 뻥! 하고 터집니다.

핵심 포인트
멤버 초기화 리스트의 실행이 끝나면, 이 객체는 비로소 '초기화' 가 끝난 것으로 간주됩니다. 그리고 생성자의 함수 몸체까지 싹 다 실행이 완료되어야, 이 객체가 완전히 '생성' 된 것으로 간주됩니다.

결론: 생성자 몸체 안에서 값을 대입하기보다는, 반드시 멤버 초기화 리스트 를 사용하여 변수들을 초기화하는 습관을 기르세요!


잘못된 값이 들어왔을 때 대처하기 (생성자가 실패할 때)

분수를 나타내는 클래스를 만든다고 생각해 볼까요?

class Fraction
{
private:
    int m_numerator {};   // 분자
    int m_denominator {}; // 분모

public:
    Fraction(int numerator, int denominator):
        m_numerator { numerator }, m_denominator { denominator }
    {
    }
};

분수에서는 분모가 0이 되면 수학적으로 불가능하죠? (0으로 나눌 수는 없으니까요).
즉, 이 클래스에는 분모 m_denominator 는 절대 0이 될 수 없다"는 불변성이 있습니다.

그런데 만약 사용자가 Fraction f { 1, 0 }; 처럼 분모에 0을 넣어서 객체를 만들려고 시도하면 어떻게 해야 할까요?

안타깝게도 멤버 초기화 리스트 안에서는 우리가 오류를 막아낼 수 있는 도구가 거의 없습니다. 조건부 연산자(?:)를 써서 임시방편으로 막아볼 순 있겠지만...

class Fraction
{
private:
    int m_numerator {};
    int m_denominator {};

public:
    Fraction(int numerator, int denominator):
        m_numerator { numerator }, m_denominator { denominator != 0.0 ? denominator : ??? } // 여기서 어떻게 해야 할까요? 임의의 숫자를 넣어야 할까요?
    {
    }
};

분모가 0일 때 강제로 1 같은 숫자로 바꿔버리면, 사용자는 자기가 요청하지도 않은 이상한 분수 객체를 받게 됩니다. 게다가 우리는 "네가 입력한 값이 잘못되어서 내가 마음대로 바꿨어!" 라고 알려줄 방법도 없죠.
그래서 보통은 멤버 초기화 리스트에서 억지로 오류를 고치려 하지 않고, 일단 들어온 값으로 초기화를 한 다음 다른 방식으로 이 곤란한 상황을 처리하려고 시도합니다.

이렇게 생성자가 정상적이고 유효한 객체를 만들어내지 못한 상황을 가리켜 "생성자가 실패했다" 고 부릅니다.


생성자가 실패했을 때 (간단한 예고편)

일반적인 함수들은 실행하다가 문제가 생기면,
함수를 호출한 쪽으로 에러 코드를 반환해서 알려주면 됩니다.

하지만 생성자는 반환값이라는 게 아예 존재하지 않습니다. 그래서 이 방식은 쓸 수가 없어요.

  • isValid() 같이 현재 객체가 정상인지 아닌지 확인해주는 함수를 추가로 만들 수도 있어요. 하지만 이건 사용자가 객체를 만들 때마다 그 함수를 호출해서 체크해야 한다는 단점이 있고, 사람이 하는 일이라 까먹기 십상입니다. 그러면 결국 버그로 이어지죠.
  • 프로그램을 아예 강제로 종료시켜버리는 것도, 대부분의 프로그램에서는 너무 극단적인 방법이라 쓰기 어렵습니다.

이럴 때 남은 가장 좋은 방법은 바로 예외를 던지는 것 입니다!
예외를 던지면 객체를 만드는 과정 자체가 아예 취소되어 버립니다. 사용자는 비정상적인 객체를 만질 기회조차 얻지 못하게 되죠. 그래서 생성자가 실패할 것 같을 때는 보통 예외 처리를 사용하는 것이 가장 깔끔한 해결책입니다. (이 부분은 나중에 더 깊게 배울 테니 지금은 이런 게 있구나~ 하고 넘어가셔도 좋습니다.)

조금 더 심화된 내용 (아직은 몰라도 괜찮아요!)
만약 예외 처리를 사용할 수 없거나 사용하고 싶지 않다면, 사용자가 직접 객체를 만들게 놔두지 말고 특별한 함수를 거치게 하는 방법도 있습니다.
아래 예제처럼 createFraction 이라는 함수를 만들어두면, 분모가 0일 때는 텅 빈 값을 돌려주고, 정상일 때만 제대로 된 객체를 포장해서 돌려주는 식으로 안전하게 처리할 수 있어요 (std::optional 활용).

#include <iostream>
#include <optional>

class Fraction
{
private:
    int m_numerator { 0 };
    int m_denominator { 1 };

    // private 생성자는 외부(public)에서 마음대로 호출할 수 없습니다
    Fraction(int numerator, int denominator):
        m_numerator { numerator }, m_denominator { denominator }
    {
    }

public:
    // 이 함수가 private 멤버(생성자 포함)에 접근할 수 있도록 권한을 줍니다
    friend std::optional<Fraction> createFraction(int numerator, int denominator);
};

std::optional<Fraction> createFraction(int numerator, int denominator)
{
    if (denominator == 0) // 분모가 0이면
        return {};        // 아무것도 없는 빈 값을 반환합니다

    return Fraction{numerator, denominator}; // 정상일 때만 객체를 만들어 반환합니다
}

int main()
{
    auto f1 { createFraction(0, 1) };
    if (f1) // 정상적으로 만들어졌는지 확인
    {
        std::cout << "분수 객체가 성공적으로 생성되었습니다\n";
    }

    auto f2 { createFraction(0, 0) }; // 분모가 0인 잘못된 요청
    if (!f2) // 실패했는지 확인
    {
        std::cout << "잘못된 분수입니다\n";
    }
}

14.11 — 기본 생성자와 기본 인수

기본 생성자 란 아무런 인수도 넘겨받지 않는 생성자를 말해요.
쉽게 말해 괄호 안에 아무런 조건이 없는 텅 빈 생성자라고 생각하시면 됩니다.

기본 생성자를 가지고 있는 클래스의 예시를 한번 볼까요?

#include <iostream>

class Foo
{
public:
    Foo() // 기본 생성자
    {
        std::cout << "Foo 기본 생성자가 호출되었습니다\n";
    }
};

int main()
{
    Foo foo{}; // 초기화 값이 없으므로, Foo의 기본 생성자를 부릅니다.

    return 0;
}

위 프로그램을 실행하면 Foo 타입의 객체가 하나 만들어져요. 우리가 괄호 안에 아무런 초기값을 주지 않았기 때문에, 자동으로 기본 생성자인 Foo() 가 불리고, 화면에는 이렇게 출력된답니다.

Foo 기본 생성자가 호출되었습니다

클래스 타입의 값 초기화 vs 기본 초기화

어떤 클래스에 기본 생성자가 있다면, 값 초기화 방식이나 기본 초기화 방식 모두 기본 생성자를 똑같이 부르게 됩니다. 그래서 위 예제의 Foo 클래스 같은 경우, 아래 두 코드는 사실상 완전히 똑같이 작동해요.

Foo foo{}; // 값 초기화, Foo() 기본 생성자를 부릅니다.
Foo foo2;  // 기본 초기화, Foo() 기본 생성자를 부릅니다.

하지만 이전 레슨에서 배웠듯이, 값 초기화가 더 안전한 방법이에요.
컴퓨터 입장에서는 어떤 클래스가 단순한 데이터 모음인지 아닌지 구별하기 어렵기 때문에, 마음 편하게 모든 곳에 중괄호 {} 를 쓰는 '값 초기화'를 사용하는 것이 신경 쓸 일도 없고 제일 안전하답니다.

모범 사례
모든 클래스 타입을 만들 때는 기본 초기화보다 값 초기화(중괄호 {} 사용)를 우선해서 사용하세요.


기본 인수를 가진 생성자

다른 일반 함수들처럼, 성자도 오른쪽 끝에 있는 매개변수들에 기본값을 미리 정해둘 수 있어요.

#include <iostream>

class Foo
{
private:
    int m_x { };
    int m_y { };

public:
    Foo(int x=0, int y=0) // 기본 인수를 가지고 있습니다.
        : m_x { x }
        , m_y { y }
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ") 가 생성되었습니다\n";
    }
};

int main()
{
    Foo foo1{};     // 기본 인수를 사용해서 Foo(int, int) 생성자를 부릅니다.
    Foo foo2{6, 7}; // Foo(int, int) 생성자를 부릅니다.

    return 0;
}

이 코드를 실행하면 이렇게 나옵니다.
Foo(0, 0) 가 생성되었습니다
Foo(6, 7) 가 생성되었습니다

만약 어떤 생성자의 모든 매개변수에 기본값이 다 채워져 있다면, 그 생성자는 '기본 생성자' 역할도 할 수 있어요. 아무런 값을 넘겨주지 않고도 부를 수 있으니까요.


생성자 오버로딩

생성자도 결국엔 함수이기 때문에 '오버로딩'이 가능해요.
오버로딩이란 이름은 같지만 매개변수가 다른 함수를 여러 개 만드는 것을 말해요.
덕분에 우리는 객체를 입맛에 맞게 여러 가지 방법으로 만들 수 있습니다.

#include <iostream>

class Foo
{
private:
    int m_x {};
    int m_y {};

public:
    Foo() // 기본 생성자
    {
        std::cout << "Foo가 생성되었습니다\n";
    }

    Foo(int x, int y) // 기본 생성자가 아님 (인수를 받음)
        : m_x { x }, m_y { y }
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ") 가 생성되었습니다\n";
    }
};

int main()
{
    Foo foo1{};     // Foo() 생성자를 부릅니다.
    Foo foo2{6, 7}; // Foo(int, int) 생성자를 부릅니다.

    return 0;
}

여기서 꼭 기억해야 할 사실이 있어요. 클래스는 단 하나의 기본 생성자만 가져야 합니다.
만약 아무 값도 받지 않는 기본 생성자가 두 개 이상 있다면, 컴파일러는 도대체 둘 중 어떤 걸 써야 할지 몰라서 헷갈리게 됩니다.

#include <iostream>

class Foo
{
private:
    int m_x {};
    int m_y {};

public:
    Foo() // 기본 생성자
    {
        std::cout << "Foo가 생성되었습니다\n";
    }

    Foo(int x=1, int y=2) // 이것도 기본 생성자 역할을 함
        : m_x { x }, m_y { y }
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ") 가 생성되었습니다\n";
    }
};

int main()
{
    Foo foo{}; // 컴파일 에러: 어떤 생성자 함수를 불러야 할지 모호합니다.

    return 0;
}

위 예제에서 우리가 아무 값 없이 foo를 만들려고 했죠? 그럼 컴퓨터는 기본 생성자를 찾게 되는데, 앗! 두 개나 발견해 버렸습니다. 컴퓨터는 스스로 결정을 내리지 못하기 때문에 결국 컴파일 에러를 뱉어내고 멈춰버립니다.


암시적 기본 생성자

만약 우리가 클래스에 생성자를 단 한 개도 만들지 않았다면, 컴퓨터가 우리를 위해 텅 빈 기본 생성자를 몰래 하나 만들어 줍니다. 이것을 암시적 기본 생성자라고 불러요.

#include <iostream>

class Foo
{
private:
    int m_x{};
    int m_y{};

    // 참고: 선언된 생성자가 하나도 없습니다.
};

int main()
{
    Foo foo{};

    return 0;
}

우리가 생성자를 만들지 않았기 때문에 컴퓨터가 알아서 암시적 기본 생성자를 만들어 주고, foo{}를 만들 때 그 자동 생성자를 사용하게 됩니다.

컴퓨터가 만들어주는 암시적 기본 생성자는 사실 아무런 매개변수도 없고, 내용물도 텅 빈 생성자와 똑같아요. 즉, 위 코드에서 컴퓨터는 몰래 아래와 같은 코드를 만들어 넣은 거랍니다.

public:
    Foo() // 암시적으로 자동 생성된 기본 생성자
    {
    }

이 자동 생성자는 클래스 안에 저장할 데이터가 아예 없을 때나 쓸만해요.
데이터가 있다면 우리가 직접 원하는 값을 넣어줘야 할 텐데,
이 텅 빈 자동 생성자로는 아무것도 할 수 없으니까요.


= default 를 사용해서 명시적으로 기본 생성자 만들기

가끔은 우리가 직접 생성자를 만들었음에도 불구하고, 컴퓨터가 알아서 만들어주는
'텅 빈 기본 생성자'가 필요할 때가 있어요.

이럴 때는 = default 라는 문법을 써서 "컴퓨터야, 네가 알아서 기본 생성자 하나 만들어줘!" 라고 당당하게 요구할 수 있습니다. 이걸 명시적으로 요구한 기본 생성자라고 해요.

#include <iostream>

class Foo
{
private:
    int m_x {};
    int m_y {};

public:
    Foo() = default; // 명시적으로 요구한 기본 생성자를 만듭니다.

    Foo(int x, int y)
        : m_x { x }, m_y { y }
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ") 가 생성되었습니다\n";
    }
};

int main()
{
    Foo foo{}; // Foo() 기본 생성자를 부릅니다.

    return 0;
}

원래 규칙대로라면, 우리가 이미 Foo(int, int)라는 생성자를 만들었기 때문에 컴퓨터는 기본 생성자를 자동으로 만들어주지 않아요. 하지만 우리가 = default를 써서 특별히 부탁했기 때문에 만들어준 거랍니다. 덕분에 foo{} 처럼 빈 괄호로도 객체를 무사히 만들 수 있게 되죠.

모범 사례
텅 빈 내용물 {} 을 가진 기본 생성자를 직접 쓰는 것보다, = default 를 사용하는 것이 훨씬 좋습니다.


명시적으로 요구한 기본 생성자(= default) vs 내용이 빈 사용자 정의 생성자({})

이 두 가지는 적어도 두 가지 상황에서 다르게 작동해요.
가장 중요한 차이는 바로 값을 채워 넣는 방식(초기화)에 있어요.

객체를 만들 때, 만약 사용자가 직접 빈 생성자 {} 를 만들었다면 객체는 그냥 기본 초기화만 됩니다. 하지만, 컴퓨터가 알아서 만들거나 = default 로 만든 생성자를 쓰면, 객체 내부의 값들이 먼저 0으로 깨끗하게 초기화 된 다음에 기본 초기화가 진행된답니다!

#include <iostream>

class User
{
private:
    int m_a; // 참고: 기본 초기화 값이 없습니다.
    int m_b {};

public:
    User() {} // 사용자가 직접 정의한 빈 생성자

    int a() const { return m_a; }
    int b() const { return m_b; }
};

class Default
{
private:
    int m_a; // 참고: 기본 초기화 값이 없습니다.
    int m_b {};

public:
    Default() = default; // 명시적으로 요구한 기본 생성자

    int a() const { return m_a; }
    int b() const { return m_b; }
};

class Implicit
{
private:
    int m_a; // 참고: 기본 초기화 값이 없습니다.
    int m_b {};

public:
    // 암시적 기본 생성자 (컴퓨터가 알아서 만듦)

    int a() const { return m_a; }
    int b() const { return m_b; }
};

int main()
{
    User user{}; // 기본 초기화됨
    std::cout << user.a() << ' ' << user.b() << '\n';

    Default def{}; // 0으로 먼저 초기화된 후, 기본 초기화됨
    std::cout << def.a() << ' ' << def.b() << '\n';

    Implicit imp{}; // 0으로 먼저 초기화된 후, 기본 초기화됨
    std::cout << imp.a() << ' ' << imp.b() << '\n';

    return 0;
}

이 코드를 실행해 보면 이런 결과가 나옵니다. (첫 번째 숫자는 쓰레기 값이에요)

782510864 0
0 0
0 0

user.a 는 0으로 깨끗하게 청소되지 않아서, 알 수 없는 이상한 쓰레기 값이 그대로 남아있는 걸 볼 수 있죠?

하지만 실무에서는 크게 걱정할 필요 없어요. 우리가 클래스를 만들 때 변수들에 미리미리 기본값(예: int m_a{};)을 잘 적어주기만 하면 예방할 수 있는 문제랍니다!


말이 될 때만 기본 생성자를 만드세요

기본 생성자는 우리가 아무런 재료를 주지 않아도 객체를 뚝딱 만들 수 있게 해 줘요.
그러니까, 아무런 값 없이 만들어져도 말이 되는 객체일 때만 기본 생성자를 제공해야 해요.

예를 들어 볼까요?

#include <iostream>

class Fraction // 분수를 나타내는 클래스
{
private:
    int m_numerator{ 0 };   // 분자
    int m_denominator{ 1 }; // 분모

public:
    Fraction() = default;
    Fraction(int numerator, int denominator)
        : m_numerator{ numerator }
        , m_denominator{ denominator }
    {
    }

    void print() const
    {
        std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";
    }
};

int main()
{
    Fraction f1 {3, 5};
    f1.print();

    Fraction f2 {}; // 0/1 분수를 얻게 됩니다.
    f2.print();

    return 0;
}

분수를 나타내는 클래스라면, 사용자가 아무 값을 안 주었을 때 그냥 '0/1'이라고 치면 되니까 기본 생성자가 있어도 아주 자연스럽죠.

하지만 다음 클래스는 어떨까요?

#include <iostream>
#include <string>
#include <string_view>

class Employee // 직원을 나타내는 클래스
{
private:
    std::string m_name{ }; // 이름
    int m_id{ };           // 사번

public:
    Employee(std::string_view name, int id)
        : m_name{ name }
        , m_id{ id }
    {
    }

    void print() const
    {
        std::cout << "Employee(" << m_name << ", " << m_id << ")\n";
    }
};

int main()
{
    Employee e1 { "Joe", 1 };
    e1.print();

    Employee e2 {}; // 컴파일 에러: 일치하는 생성자가 없습니다.
    e2.print();

    return 0;
}

직원을 나타내는 클래스인데 직원의 '이름'이 없다는 건 상식적으로 말이 안 되잖아요?
이런 경우에는 아예 기본 생성자를 안 만드는 게 맞아요. 그래야 누군가 실수로 이름 없는 유령 직원을 만들려고 할 때 컴퓨터가 딱! 에러를 내서 막아줄 수 있으니까요.


14.12 — 위임 생성자

코딩을 할 때는 똑같은 코드를 여러 번 쓰는 것을 최대한 피하는 것이 좋습니다.
다음 함수들을 한 번 살펴볼까요?

void A()
{
    // 작업 A를 수행하는 코드들
}

void B()
{
    // 작업 A를 수행하는 코드들
    // 작업 B를 수행하는 코드들
}

두 함수 모두 완전히 똑같은 일(작업 A)을 하는 코드를 가지고 있네요.
이럴 때는 아래처럼 코드를 깔끔하게 고칠 수 있습니다(리팩토링).

void A()
{
    // 작업 A를 수행하는 코드들
}

void B()
{
    A();
    // 작업 B를 수행하는 코드들
}

이렇게 하면 A()B() 함수에 중복으로 들어있던 코드를 없앨 수 있어요.
나중에 수정할 일이 생겨도 한 군데만 고치면 되니까 코드를 관리하기가 훨씬 쉬워집니다.

클래스 안에 생성자가 여러 개 있을 때도 마찬가지예요.
각 생성자 안에 들어있는 코드가 완전히 똑같거나 아주 비슷한 경우가 정말 많거든요.
함수에서 했던 것처럼, 생성자에서도 중복되는 코드를 없애고 싶을 겁니다.

다음 예시를 볼까요?

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };
    bool m_isManager { false };

public:
    Employee(std::string_view name, int id) // 직원은 반드시 이름과 ID를 가져야 합니다
        : m_name{ name }, m_id { id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }

    Employee(std::string_view name, int id, bool isManager) // 선택적으로 관리자(manager)가 될 수도 있습니다
        : m_name{ name }, m_id{ id }, m_isManager { isManager }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James", 7 };
    Employee e2{ "Dave", 42, true };
}

위 코드를 보면 두 생성자의 몸체(중괄호 { } 안)에 완전히 똑같은 출력문(std::cout)이 들어있죠?

참고 사항
생성자가 무언가를 화면에 출력하게 만드는 건 사실 좋은 습관은 아니에요.
(디버깅 목적 제외) 아무것도 출력하고 싶지 않을 때 그 생성자를 쓸 수 없게 되니까요. 여기서는 단지 어떤 일이 일어나고 있는지 쉽게 보여드리기 위해 출력문을 넣은 것뿐입니다.

생성자도 다른 함수(같은 클래스 안의 다른 멤버 함수 포함)를 부를 수 있어요.
그래서 아까 일반 함수에서 했던 것처럼 이렇게 고쳐볼 수 있습니다.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id{ 0 };
    bool m_isManager { false };

    void printCreated() const // 우리가 새로 만든 도우미 함수
    {
        std::cout << "Employee " << m_name << " created\n";
    }

public:
    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id }
    {
        printCreated(); // 여기서 호출합니다
    }

    Employee(std::string_view name, int id, bool isManager)
        : m_name{ name }, m_id{ id }, m_isManager { isManager }
    {
        printCreated(); // 그리고 여기서도요
    }
};

int main()
{
    Employee e1{ "James", 7 };
    Employee e2{ "Dave", 42, true };
}

이전 버전보다는 나아졌어요. (중복되던 코드가 도우미 함수 호출 하나로 바뀌었으니까요.) 하지만 굳이 새로운 함수를 만들어야 한다는 단점이 있습니다.

게다가 두 생성자 모두 m_namem_id 를 똑같이 초기화하고 있잖아요?
이상적으로는 이 초기화 중복도 없애버리고 싶을 겁니다.

더 좋은 방법이 없을까요? 당연히 있습니다!
하지만 바로 이 부분에서 초보 프로그래머들이 실수를 많이 하곤 해요.


함수 몸체 안에서 생성자를 부르면 '임시 객체'가 만들어집니다

아까 B() 함수 안에서 A() 함수를 불렀던 것처럼, 그냥 생성자 안에서 다른 생성자를 부르면 되지 않을까? 라고 생각하기 쉽습니다.

예를 들어, Employee(이름, ID, 관리자여부) 생성자 안에서 Employee(이름, ID) 생성자를 불러서 초기화도 하고 출력문도 실행하는 거죠. 코드로 보면 이렇습니다.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };
    bool m_isManager { false };

public:
    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id } // 이 생성자는 이름과 ID를 초기화합니다
    {
        std::cout << "Employee " << m_name << " created\n"; // 출력문이 다시 여기로 돌아왔습니다
    }

    Employee(std::string_view name, int id, bool isManager)
        : m_isManager { isManager } // 이 생성자는 관리자 여부(m_isManager)만 초기화합니다
    {
        // m_name과 m_id를 초기화하기 위해 Employee(std::string_view, int) 생성자를 호출합니다
        Employee(name, id); // 이 코드는 예상대로 작동하지 않습니다!
    }

    const std::string& getName() const { return m_name; }
};

int main()
{
    Employee e2{ "Dave", 42, true };
    std::cout << "e2 has name: " << e2.getName() << "\n"; // e2.m_name을 출력합니다
}

하지만 이 코드는 제대로 작동하지 않아요. 프로그램을 실행해 보면 다음과 같이 출력됩니다.

Employee Dave created
e2 has name: ???

"Employee Dave created"라는 문구가 출력되긴 했지만, e2 객체 생성이 끝난 후 e2.m_name 을 확인해 보니 여전히 처음 기본값인 "???" 가 들어있네요! 도대체 어떻게 된 일일까요?

우리는 Employee(name, id) 가 현재 우리가 만들고 있는 e2 객체를 이어서 초기화해 줄 거라고 기대했어요. 하지만 객체의 초기화는 멤버 초기화 리스트가 끝나는 순간 이미 완료된 것으로 간주됩니다. 즉, 생성자의 몸체(중괄호 안)가 실행될 때는 이미 뭔가 더 초기화하기엔 너무 늦어버린 거예요.

함수 몸체 안에서 생성자를 부르는 건, 현재 객체를 초기화하는 게 아니라 임시로 쓸 새로운 객체를 만들어서 초기화해 버리는 것과 같습니다. 위 예시에서 Employee(name, id); 라는 코드는 이름 없는 '임시 Employee 객체'를 하나 뚝딱 만듭니다.

그리고 이 임시 객체 의 이름이 "Dave"로 설정되면서 "Employee Dave created"가 출력되는 거예요. 그러고 나서 이 임시 객체는 파괴되어 버립니다. 결국 진짜 우리가 원했던 e2 객체는 건드리지도 못한 채 기본값 그대로 남아있게 되는 거죠.

권장 사항
생성자를 다른 함수의 몸체(중괄호 안)에서 직접 호출하지 마세요.
에러가 나거나 엉뚱한 임시 객체만 만들어질 뿐입니다.
만약 정말로 임시 객체가 필요하다면 중괄호를 쓰는 리스트 초기화 방식을 사용하세요. (예: Employee{"Dave", 42}) 이렇게 하면 "나는 지금 객체를 새로 만들고 있다"는 의도가 명확해집니다.

그렇다면 생성자 안에서 다른 생성자를 부를 수 없다면, 이 중복 문제는 어떻게 해결해야 할까요?


위임 생성자

다행히 C++에서는 한 생성자가 같은 클래스에 있는 다른 생성자에게 초기화 작업을 떠넘기는(위임하는) 기능을 제공합니다. 이 과정을 생성자 체이닝 이라고도 부르고, 이렇게 책임을 떠넘기는 생성자를 위임 생성자 라고 부릅니다.

한 생성자가 다른 생성자에게 초기화를 위임하려면, 그냥 멤버 초기화 리스트 안에서 다른 생성자를 호출하기만 하면 됩니다. 이렇게요!

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };

public:
    Employee(std::string_view name)
        : Employee{ name, 0 } // Employee(std::string_view, int) 생성자에게 초기화를 위임합니다
    {
    }

    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id } // 실제로 멤버 변수들을 초기화하는 곳입니다
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

e1 { "James" } 가 만들어질 때 어떤 일이 일어나는지 순서대로 볼까요?

  1. 먼저 name 에 "James"를 받는 Employee(std::string_view) 생성자가 호출됩니다.
  2. 이 생성자의 초기화 리스트를 보니 다른 생성자에게 일을 떠넘기네요!
    그래서 Employee(std::string_view, int) 생성자가 이어서 호출됩니다.
  3. 이때 첫 번째 인자로 "James"가, 두 번째 인자로 숫자 0이 넘어갑니다.
  4. 일을 넘겨받은 생성자가 실제로 멤버 변수들을 초기화하고, 자신의 중괄호 안 코드를 실행합니다. ("Employee James created" 출력)
  5. 그 작업이 다 끝나면 다시 처음 호출됐던 생성자로 돌아오고, 텅 빈 몸체를 훑고 지나간 뒤 모든 과정이 끝납니다.

이 방법의 단점은 가끔 초기화할 '값'을 중복해서 적어야 한다는 거예요.
위임할 때 정수형 매개변수 값으로 0을 직접 적어주었죠(하드코딩).
변수에 설정해 둔 기본값을 알아서 끌어다 쓸 방법이 없기 때문입니다.

위임 생성자에 대해 몇 가지 더 알아둘 점이 있습니다.
첫째, 다른 생성자에게 위임하는 생성자는 스스로 멤버 초기화를 할 수 없습니다.
즉, 내 할 일을 남에게 '위임'하거나, 내가 직접 '초기화'하거나 둘 중 하나만 할 수 있어요.

참고로...
방금 예시에서는 매개변수가 적은 생성자가 매개변수가 많은 생성자에게 위임을 했죠? 이게 아주 흔하게 쓰이는 일반적인 방식입니다.
만약 반대로 매개변수가 많은 쪽에서 적은 쪽으로 위임을 했다면, id 값을 이용해 m_id 를 초기화할 방법이 없었을 거예요. (위임과 초기화를 동시에 할 수는 없으니까요!)

둘째, 생성자끼리 핑퐁을 하듯이 서로 무한정 위임하는 것도 문법적으로 가능은 합니다. 하지만 이렇게 무한 루프에 빠지면 프로그램의 메모리(스택)가 꽉 차서 프로그램이 튕겨버리게(crash) 됩니다. 반드시 모든 위임의 끝에는 책임을 떠안고 직접 초기화해 주는 '최종 보스' 생성자가 하나 있어야 해요.

권장 사항
클래스에 생성자가 여러 개 있다면, 위임 생성자를 써서 중복되는 코드를 줄일 수 없는지 고민해 보세요.


기본 인자를 이용해 생성자 개수 줄이기

때로는 '기본값'을 잘 활용하면 굳이 생성자를 여러 개 만들 필요 없이 하나로 줄일 수도 있습니다. 예를 들어, id 매개변수에 기본값을 주면, 이름만 필수로 받고 id 는 선택적으로 받는 만능 생성자 하나를 만들 수 있어요.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name{};
    int m_id{ 0 }; // 기본 멤버 초기화 값

public:

    Employee(std::string_view name, int id = 0) // id에 대한 기본 인자(default argument)
        : m_name{ name }, m_id{ id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

함수를 호출할 때 기본값은 항상 오른쪽 매개변수부터 채워져야 하죠?
그래서 클래스를 만들 때 좋은 습관은 이렇습니다.

  • 사용자가 반드시 입력해야 하는 값(이름 같은 것)을 먼저 선언하고, 생성자의 가장 왼쪽 매개변수로 둡니다.
  • 사용자가 굳이 입력하지 않아도 되는 값(기본값으로 충분한 것들)은 나중에 선언하고, 생성자의 가장 오른쪽 매개변수로 둡니다.

권장 사항
초기화할 때 사용자가 필수적으로 값을 넣어야 하는 멤버를 가장 먼저 선언하세요. (생성자의 제일 왼쪽 매개변수로 설정)
사용자가 값을 안 넣어도 되는(기본값이 있는) 멤버는 그 다음에 선언하세요.
(생성자의 제일 오른쪽 매개변수로 설정)

이 방법 역시 m_id 의 기본값인 0을 두 번(멤버 변수 옆에 한 번, 생성자 매개변수에 한 번) 써야 한다는 단점이 있습니다.


딜레마: 중복되는 생성자 vs 중복되는 기본값

지금까지 생성자의 중복 코드를 줄이기 위해 '위임 생성자'와 '기본 인자'를 써보았습니다. 하지만 두 방법 모두 초기화할 '값'을 여러 번 똑같이 적어줘야 한다는 아쉬움이 있었죠.
현재 C++에는 "생성자의 기본값은 그냥 멤버 변수에 적힌 기본값을 끌어다 써라!"라고 명령할 방법이 없습니다.

그렇다면 값이 중복되더라도 생성자 개수를 확 줄이는 게 나을까요, 아니면 생성자가 많아지더라도 값을 한 번만 쓰는 게 나을까요?
사람마다 의견이 다르지만, 보통은 초기화 값이 조금 중복되더라도 생성자 개수를 적게 유지하는 것이 코드를 이해하기 더 쉽습니다.


심화 학습

초기화 값(예: 0)이 여러 군데에서 중복으로 쓰일 때는, 그 값에 이름을 붙여서 상수로 만들어두고 필요한 곳마다 그 이름을 가져다 쓰는 방법이 있습니다. 이렇게 하면 값을 딱 한 곳에서만 편하게 관리할 수 있어요.

전역 변수를 쓸 수도 있겠지만, 클래스 안에 static constexpr 멤버로 만드는 것이 더 좋습니다.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    static constexpr int default_id { 0 }; // 원하는 초기화 값을 가진 이름 있는 상수를 정의합니다

    std::string m_name {};
    int m_id { default_id }; // 여기서 사용할 수 있습니다

public:

    Employee(std::string_view name, int id = default_id) // 그리고 여기서도 사용할 수 있죠
        : m_name { name }, m_id { id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1 { "James" };
    Employee e2 { "Dave", 42 };
}

여기서 static 이라는 단어를 쓰면, 모든 Employee 객체들이 단 하나의 default_id 값을 함께 공유하게 됩니다. static 이 없다면 객체를 만들 때마다 default_id 가 각자 하나씩 쓸데없이 복사되어 생길 테니 메모리 낭비가 되겠죠.

이 방법의 단점은 default_id 같은 새로운 이름이 추가되면서 클래스가 아주 조금 더 복잡해 보일 수 있다는 것입니다. 상수가 얼마나 많이 필요한지, 얼마나 여러 곳에서 쓰이는지에 따라 이 방법을 쓸지 말지 결정하면 됩니다.

(정적 멤버 변수(static)에 대한 더 자세한 내용은 15.6절에서 다룹니다!)


14.13 — 임시 클래스 객체

다음 예제를 한 번 살펴볼까요?

#include <iostream>

int add(int x, int y)
{
    int sum{ x + y }; // x + y의 결과를 변수에 저장합니다
    return sum;       // 그 변수의 값을 반환합니다
}

int main()
{
    std::cout << add(5, 3) << '\n';

    return 0;
}

위의 add() 함수를 보면, sum 이라는 변수를 만들어서 x + y 의 결과를 저장하고 있습니다. 그리고 return 문에서 이 변수를 사용해 결과값을 반환하죠.

물론 이렇게 쓰면 버그를 잡을 때(디버깅) sum 에 무슨 값이 들어갔는지 슬쩍 확인해 보기 좋을 수는 있습니다. 하지만 객체를 하나 만들어 놓고 딱 한 번만 쓰고 버리기 때문에, 함수가 굳이 필요 이상으로 복잡해지는 단점이 있습니다.

보통 이렇게 변수를 딱 한 번만 쓸 거라면, 애초에 변수 자체를 만들 필요가 없습니다.
변수를 만들어서 담아두는 대신, 계산식 자체를 변수 자리에 바로 쓱 밀어 넣으면 되거든요. 이렇게 고친 add() 함수를 확인해 보세요.

#include <iostream>

int add(int x, int y)
{
    return x + y; // x + y를 직접 반환합니다
}

int main()
{
    std::cout << add(5, 3) << '\n';

    return 0;
}

이 방법은 값을 반환할 때뿐만 아니라,
함수에 값을 전달할 때(인자)도 똑같이 써먹을 수 있습니다.

#include <iostream>

void printValue(int value)
{
    std::cout << value;
}

int main()
{
    int sum{ 5 + 3 };
    printValue(sum);

    return 0;
}

이렇게 쓸 수 있다는 거죠:

#include <iostream>

void printValue(int value)
{
    std::cout << value;
}

int main()
{
    printValue(5 + 3);

    return 0;
}

어때요, 코드가 훨씬 깔끔해졌죠? 굳이 이름을 지어서 변수를 만들 필요도 없고, 나중에 코드를 읽을 때 "어, 이 변수가 혹시 다른 곳에서도 쓰이나?" 하고 함수 전체를 뒤적일 필요도 없습니다. 5 + 3 은 그냥 계산식일 뿐이니까, 딱 저 한 줄에서만 쓰이고 끝난다는 걸 한눈에 알 수 있죠.

단, 이 방법은 rvalue가 허용되는 곳에서만 쓸 수 있습니다.
lvalue가 꼭 필요한 자리라면 어쩔 수 없이 변수(객체)를 만들어야 합니다.

#include <iostream>

void addOne(int& value) // non-const 참조로 전달하려면 lvalue(메모리 주소가 있는 값)가 필요합니다
{
    ++value;
}

int main()
{
    int sum { 5 + 3 };
    addOne(sum);   // 성공: sum은 lvalue입니다

    addOne(5 + 3); // 컴파일 에러: lvalue가 아닙니다

    return 0;
}

임시 클래스 객체

이 문제는 int 같은 기본 타입뿐만 아니라 클래스 타입에서도 똑같이 적용됩니다.

참고로...
여기서는 클래스를 예로 들지만, 이 레슨에서 배우는 '리스트 초기화' 개념은 구조체에도 완벽하게 똑같이 적용됩니다.

아래 예제는 위에서 본 것과 비슷하지만,
int 대신 우리가 직접 만든 IntPair 라는 클래스를 사용합니다.

#include <iostream>

class IntPair
{
private:
    int m_x{};
    int m_y{};

public:
    IntPair(int x, int y)
        : m_x { x }, m_y { y }
    {}

    int x() const { return m_x; }
    int y() const { return m_y; }
};

void print(IntPair p)
{
    std::cout << "(" << p.x() << ", " << p.y() << ")\n";
}

int main()
{
    // 케이스 1: 변수를 전달하기
    IntPair p { 3, 4 };
    print(p); // (3, 4) 출력

    return 0;
}

케이스 1을 보면, IntPair p 라는 변수를 먼저 만들고 나서 print() 함수에 p 를 전달하고 있습니다.

하지만 여기서도 p 는 딱 한 번만 쓰였죠? 게다가 print() 함수는 굳이 이름 있는 변수(lvalue)를 고집하지 않기 때문에, 굳이 변수를 만들 이유가 없습니다. 자, 그럼 p 를 없애봅시다.

이름표가 붙은 변수 대신 임시 객체를 전달하면 됩니다. 임시 객체란 이름이 없고 딱 한 번 계산될 때만 잠깐 살았다가 사라지는 객체를 말합니다. (마치 한 번 물을 마시고 버리는 종이컵 같은 녀석이죠!) 이름이 없어서 익명 객체이름 없는 객체라고도 부릅니다.

이런 임시 클래스 객체를 만드는 가장 흔한 두 가지 방법은 다음과 같습니다.

#include <iostream>

class IntPair
{
private:
    int m_x{};
    int m_y{};

public:
    IntPair(int x, int y)
        : m_x { x }, m_y { y }
    {}

    int x() const { return m_x; }
    int y() const{ return m_y; }
};

void print(IntPair p)
{
    std::cout << "(" << p.x() << ", " << p.y() << ")\n";
}

int main()
{
    // 케이스 1: 변수를 전달하기
    IntPair p { 3, 4 };
    print(p);

    // 케이스 2: 임시 IntPair 객체를 만들어서 함수에 전달하기
    print(IntPair { 5, 6 } );

    // 케이스 3: { 7, 8 }을 임시 IntPair 객체로 암시적 변환하여 전달하기
    print( { 7, 8 } );

    return 0;
}

케이스 2에서는, 컴파일러에게 IntPair 객체를 만들되 { 5, 6 } 으로 채워 달라고 명령합니다. 이 객체는 이름표가 없기 때문에 '임시 객체'가 됩니다.
이 녀석은 print() 함수의 매개변수 p 로 쏙 들어가고,
함수가 끝나면(물 마시고 나면 종이컵 버리듯이) 깔끔하게 파괴됩니다.

케이스 3에서도 print() 함수에 전달할 임시 IntPair 객체를 만들고 있습니다. 다만 이번엔 우리가 "이거 IntPair 타입으로 만들어줘!"라고 딱 집어서 말하지 않았죠. 그래도 컴파일러가 아주 똑똑하게 함수가 원하는 타입을 눈치채고, 알아서 { 7, 8 }IntPair 객체로 변환해 줍니다.

요약하자면 이렇습니다.

IntPair p { 1, 2 }; // { 1, 2 }로 초기화된 이름이 있는 객체 p 만들기
IntPair { 1, 2 };   // { 1, 2 }로 초기화된 임시 객체 만들기
{ 1, 2 };           // 컴파일러가 { 1, 2 }를 예상되는 타입(보통 매개변수나 반환 타입)에 맞는 임시 객체로 변환하려고 시도함

마지막 케이스(알아서 변환해 주는 것)에 대해서는 나중에
14.16 — 변환 생성자와 explicit 키워드 레슨에서 더 자세히 다룰 거예요.

몇 가지 예시를 더 볼까요?

std::string { "Hello" }; // "Hello"로 초기화된 임시 std::string 만들기
std::string {};          // 값 초기화 / 기본 생성자를 사용하여 임시 std::string 만들기

직접 초기화를 통한 임시 객체 생성 (선택 사항)

지금까지 중괄호 {} 를 써서 임시 객체를 만들 수 있다는 걸 배웠는데요,
"그럼 다른 초기화 방법으로도 임시 객체를 만들 수 있나?" 하고 궁금해하실 수도 있습니다. 복사 초기화 방식으로는 임시 객체를 만드는 문법이 따로 없습니다.

하지만 소괄호 () 를 사용하는 직접 초기화로는 만들 수 있습니다.

Foo (1, 2); // (1, 2)로 직접 초기화된 임시 Foo 객체 (Foo { 1, 2 }와 비슷함)

언뜻 보면 함수를 호출하는 것 같이 생겼지만, 사실 Foo { 1, 2 } 와 똑같은 결과를 만들어냅니다 (단지 데이터가 깎여나가는 걸 방지해주는 기능만 없을 뿐이죠). 평범해 보이죠?

하지만 지금부터 왜 이 방법을 쓰면 안 되는지 그 이유를 설명해 드릴게요.

저자의 참고 사항
이 부분은 그냥 "아, 이런 것도 있구나~" 하고 가볍게 읽어 넘기시면 됩니다. 굳이 외우거나 남에게 설명할 수 있을 정도로 파고들 필요는 없어요. 읽다가 머리가 아프더라도, "아, 이래서 요즘 C++에서는 중괄호 {} 를 쓰라고 하는 거구나!" 하고 깨닫게 되실 거예요.

아무런 값을 넣지 않을 때(인자가 없을 때)를 한 번 볼까요?

Foo();     // 값으로 초기화된 임시 Foo 객체 (Foo {}와 동일함)

아마 Foo()Foo {} 처럼 임시 객체를 만들어 줄 거라고 기대하셨을 겁니다. 네, 맞아요. 그런데 문제는... 이 문법이 이름 있는 변수 랑 같이 쓰일 때는 뜻이 완전히 엉뚱하게 바뀌어버린다는 겁니다!

Foo bar{}; // 변수 bar의 정의, 값으로 초기화됨
Foo bar(); // 매개변수가 없고 Foo를 반환하는 함수 bar의 선언 (Foo bar{} 및 Foo()와 일관성 없음)

진짜 이상해질 준비 되셨나요?!?

Foo(1);    // 리터럴 1의 함수 스타일 캐스트, 임시 Foo 객체 반환 (Foo { 1 }과 비슷함)
Foo(bar);  // Foo 타입의 변수 bar 정의 (Foo { bar } 및 Foo(1)과 일관성 없음)

잠깐, 뭐라고요?

소괄호 안에 숫자 1 을 넣은 건 우리가 예상했던 대로 임시 객체를 만들어냅니다.
그런데 소괄호 안에 글자 bar 를 넣으면 갑자기 bar 라는 이름의 변수를 떡하니 만들어버립니다! (마치 Foo bar; 라고 쓴 것처럼요). 만약 bar 가 이미 있는 이름이라면 "이름이 겹쳤어!" 라며 컴파일 에러를 뱉어냅니다.

컴파일러는 숫자 1 같은 '리터럴(그냥 값)'은 변수 이름이 될 수 없다는 걸 알기 때문에, 그 경우에는 알아서 임시 객체로 처리해 주는 거죠.

여담이지만...
Foo(bar);Foo bar; 와 똑같이 동작하는지 궁금하시다면...
소괄호 () 의 가장 흔한 역할은 무언가를 '묶는(그룹화)' 겁니다. 수학에서 (1 + 2) * 3 처럼요. (1 + 2) * 3 이 된다면, (3) * 3 도 당연히 돼야겠죠?
같은 이유로, C++ 문법은 소괄호로 무언가를 묶는 걸 허용하고, 그 묶음 안에 딱 한 개만 들어있는 것도 허용합니다. 그래서 Foo(bar)Foo 타입에 괄호로 예쁘게 포장된 bar 라는 이름표가 붙은 변수로 해석되는 겁니다. 우리 눈엔 좀 웃기게 보이지만, 언어 규칙을 꼬지 않으려면 이걸 허용할 수밖에 없었거든요.

심화 학습자를 위해
조금 더 복잡한 경우를 볼까요? Foo * bar(); 라는 문장을 생각해 봅시다. 괄호를 어떻게 쓰냐에 따라 뜻이 완전히 뒤집힙니다.

  • Foo * bar(); (괄호 없음) 기본적으로 *Foo 에 붙습니다. 매개변수가 없고 Foo* (포인터)를 반환하는 함수 bar 의 선언입니다.
  • Foo (*bar)(); *bar 와 명시적으로 묶습니다. 이건 매개변수가 없고 Foo 를 반환하는 함수의 주소를 담는 함수 포인터 bar 를 정의하는 겁니다.
  • Foo (* bar()); 이건 Foo * bar(); 와 같습니다. 여기서 괄호는 그냥 쓸데없이 붙은 거예요.
  • 마지막으로 (Foo *) bar(); 이건 Foo* bar() 와 같을 것 같죠? 아닙니다! 이건 bar() 함수를 실행한 다음, 그 결과값을 Foo* 타입으로 C 스타일 강제 형변환(캐스트)을 하고, 그냥 버려버리는 문장입니다!

참... C++는 가끔 진짜 이상하죠.

핵심 포인트
소괄호 () 는 역할이 너무 많아서 복잡합니다. 함수 호출, 직접 초기화, 임시 객체 생성, C 스타일 캐스팅, 기호 묶기, 변수 정의 등등... 그래서 소괄호를 보면 "이게 도대체 무슨 역할을 하는 거지?" 하고 헷갈리기 쉽습니다.
반면에 중괄호 {} 를 보면 무조건 "아, 이건 객체를 다루는 거구나!" 하고 명확하게 알 수 있죠.

자, 머리 아픈 이야기는 여기까지. 다시 지루하지만 중요한 이야기로 돌아갑시다.


임시 객체와 값 반환

함수가 '값'으로 무언가를 반환할 때, 그 반환되는 녀석의 정체는 바로 임시 객체입니다.
(return 문에 있는 값을 이용해 만들어집니다.)

예제를 볼까요.

#include <iostream>

class IntPair
{
private:
    int m_x{};
    int m_y{};

public:
    IntPair(int x, int y)
        : m_x { x }, m_y { y }
    {}

    int x() const { return m_x; }
    int y() const { return m_y; }
};

void print(IntPair p)
{
    std::cout << "(" << p.x() << ", " << p.y() << ")\n";
}

// 케이스 1: 이름이 있는 변수를 만들고 반환하기
IntPair ret1()
{
    IntPair p { 3, 4 };
    return p; // 임시 객체 반환 (p를 사용해 초기화됨)
}

// 케이스 2: 임시 IntPair 객체를 만들고 반환하기
IntPair ret2()
{
    return IntPair { 5, 6 }; // 임시 객체 반환 (다른 임시 객체를 사용해 초기화됨)
}

// 케이스 3: { 7, 8 }을 IntPair로 암시적 변환하여 반환하기
IntPair ret3()
{
    return { 7, 8 }; // 임시 객체 반환 (다른 임시 객체를 사용해 초기화됨)
}

int main()
{
    print(ret1());
    print(ret2());
    print(ret3());

    return 0;
}

케이스 1을 보면, return p 를 할 때 p 의 값을 베껴서 '임시 객체'를 하나 만든 다음, 그걸 반환합니다. 앞서 봤던 함수 인자로 넘겨줄 때와 똑같은 원리입니다.

몇 가지 알아둘 점
첫째, 기본 int 타입과 마찬가지로 임시 클래스 객체가 계산식에 쓰일 때는 rvalue 취급을 받습니다. 즉, rvalue가 들어갈 수 있는 자리에만 쓸 수 있습니다.
둘째, 임시 객체는 만들어진 그 줄(정확히는 전체 표현식)이 끝날 때 미련 없이 파괴됩니다.


static_cast vs 임시 객체의 명시적 생성

안전한 변환(데이터 손실이 없는 변환)을 할 때는 보통 두 가지 선택지가 있습니다. static_cast 를 쓰거나, 아니면 대놓고 임시 객체를 새로 만들어버리는 거죠.

예를 들어,

#include <iostream>

int main()
{
    char c { 'a' };

    std::cout << static_cast<int>( c ) << '\n'; // static_cast는 c의 값으로 직접 초기화된 임시 int를 반환합니다
    std::cout << int { c } << '\n';             // c의 값으로 리스트 초기화된 임시 int를 명시적으로 만듭니다

    return 0;
}

static_cast<int>(c)c 의 값으로 세팅된 임시 int 를 반환하고,
int { c } 역시 c 의 값으로 세팅된 임시 int 를 새로 만듭니다.
어찌 됐든 우리가 원하는 'c의 값을 가진 임시 int'를 얻는 건 똑같습니다.

그럼 조금 더 복잡한 예시를 볼까요?

printString.h 파일:

#include <string>

void printString(const std::string &s)
{
    std::cout << s << '\n';
}

main.cpp 파일:

#include "printString.h"
#include <iostream>
#include <string>
#include <string_view>

int main()
{
    std::string_view sv { "Hello" };

    // printString() 함수를 사용해서 sv를 출력하고 싶습니다
//    printString(sv); // 컴파일 에러: std::string_view는 std::string으로 암시적 변환되지 않습니다

    printString( static_cast<std::string>(sv) ); // 케이스 1: static_cast는 sv로 직접 초기화된 임시 std::string을 반환합니다
    printString( std::string { sv } );           // 케이스 2: sv로 리스트 초기화된 임시 std::string을 명시적으로 만듭니다
    printString( std::string ( sv ) );           // 케이스 3: C 스타일 캐스트는 sv로 직접 초기화된 임시 std::string을 반환합니다 (이 방법은 피하세요!)

    return 0;
}

만약 저 printString.h 헤더 파일이 우리가 함부로 고칠 수 없는 남이 만든 라이브러리 코드라고 쳐봅시다. 그런데 우리는 string_view 타입인 sv 를 저 함수에 넣고 싶어요. 하지만 성능 문제 때문에 std::string_viewstd::string 으로 스스로(암시적) 변환되지 않기 때문에 냅다 집어넣으면 에러가 납니다.

이럴 때는 우리가 "이걸로 바꿔!"라고 콕 집어서(명시적으로) 말해줘야 합니다.

  • 케이스 1: static_cast 를 써서 svstd::string 으로 바꿉니다.
  • 케이스 2: 중괄호 {} 를 써서 아예 새로운 임시 std::string 을 만듭니다. 우리가 대놓고 만들어달라고 했으니 에러 없이 잘 넘어갑니다.
  • 케이스 3: 옛날 C 스타일 캐스팅 방식입니다. 결과는 나오지만, 이 방식은 위험할 수 있어서 최신 C++에서는 피하는 게 좋습니다!

베스트 프랙티스 (권장 사항)
간단한 꿀팁을 드릴게요: int 나 char 같은 기본 타입 으로 바꿀 때는 static_cast 를 쓰고, 클래스(Class) 타입 으로 바꿀 때는 중괄호 {} 를 써서 임시 객체를 만드세요.
static_cast 를 써야 하는 경우:

  • 데이터가 잘려 나갈 위험이 있는 변환(Narrowing conversion)을 해야 할 때
  • 타입을 바꿔서 뭔가 다르게 행동하길 원한다는 걸 남들에게 팍팍 티 내고 싶을 때 (예: char를 int로)
  • 무슨 이유가 있어서 꼭 '직접 초기화(괄호 사용)' 방식이 필요할 때

중괄호 {} 를 써서 새로운 임시 객체를 만들어야 하는 경우:

  • 리스트 초기화 방식(중괄호 사용)의 혜택을 받고 싶을 때 (데이터가 잘려 나가는 걸 컴파일러가 막아줌)
  • 변환할 때 생성자에 여러 가지 옵션(인자)을 더 넘겨줘야 할 때

관련 콘텐츠
리스트 생성자에 대한 내용은 16.2 — std::vector 및 리스트 생성자 소개 레슨에서 더 자세히 다룹니다.


14.14 — 복사 생성자 소개

다음 프로그램을 한 번 살펴볼까요?

#include <iostream>

class Fraction
{
private:
    int m_numerator{ 0 };
    int m_denominator{ 1 };

public:
    // 기본 생성자
    Fraction(int numerator=0, int denominator=1)
        : m_numerator{numerator}, m_denominator{denominator}
    {
    }

    void print() const
    {
        std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";
    }
};

int main()
{
    Fraction f { 5, 3 };  // Fraction(int, int) 생성자를 호출합니다
    Fraction fCopy { f }; // 여기서는 어떤 생성자가 사용될까요?

    f.print();
    fCopy.print();

    return 0;
}

이 코드를 실행하면 에러 없이 아주 잘 작동하고, 다음과 같은 결과를 보여줍니다.

Fraction(5, 3)
Fraction(5, 3)

어떻게 작동하는 건지 자세히 들여다보겠습니다.
처음에 f 라는 변수를 만들 때는 우리가 정의한 Fraction(int, int) 생성자가 사용됩니다. 5와 3이라는 숫자를 넣어줬으니까요.

그렇다면 그다음 줄은 어떨까요? fCopy 를 만드는 것도 분명히 무언가를 생성(초기화)하는 과정입니다. 그런데 숫자가 아니라 아까 만든 f 자체를 집어넣고 있죠? 이때 도대체 어떤 생성자가 호출된 걸까요?

정답은 바로 복사 생성자 입니다.


복사 생성자란 무엇인가요?

복사 생성자란, 쉽게 말해서 '똑같은 복제본을 만들어내는 기계'입니다. 이미 존재하는 객체(여기서는 f)를 재료로 삼아서, 그것과 똑같은 종류의 새로운 객체(fCopy)를 만들 때 사용됩니다. 복사 생성자가 일을 마치고 나면, 새로 만들어진 객체는 원본 객체의 완벽한 복사본이 됩니다.


알아서 챙겨주는 '암시적 복사 생성자'

우리가 클래스 안에 복사 생성자를 직접 만들지 않아도 괜찮습니다.
C++은 아주 친절해서, 우리가 안 만들면 알아서 기본 복사기를 하나 제공해 줍니다.
이것을 암시적 복사 생성자 라고 부릅니다.

방금 본 예제에서 Fraction fCopy { f }; 코드는 C++이 알아서 만들어준 이 기본 복사기를 사용한 것입니다.

이 기본 복사기는 어떻게 작동할까요?
아주 단순합니다. 객체 안에 들어있는 부품(멤버 변수)들을 하나씩 하나씩 그대로 베껴 옵니다.
예를 들어, fCopy 의 분자(m_numerator)는 f 의 분자(값 5)를 그대로 가져와서 설정하고, fCopy 의 분모(m_denominator)는 f 의 분모(값 3)를 그대로 가져와서 설정합니다.

결과적으로 두 객체는 완전히 똑같은 값을 가지게 되므로, 어느 쪽에서 print() 함수를 부르든 똑같은 결과가 나오는 것이죠.


나만의 복사 생성자 직접 만들기

기본으로 제공되는 것 말고, 우리가 직접 복사 생성자를 만들 수도 있습니다.
이번에는 복사가 일어날 때마다 화면에 메시지를 띄우는 나만의 복사 생성자를 만들어 볼게요. 복사 기능이 정말로 실행되는지 눈으로 확인해 보기 위해서죠.

#include <iostream>

class Fraction
{
private:
    int m_numerator{ 0 };
    int m_denominator{ 1 };

public:
    // 기본 생성자
    Fraction(int numerator=0, int denominator=1)
        : m_numerator{numerator}, m_denominator{denominator}
    {
    }

    // 복사 생성자
    Fraction(const Fraction& fraction)
        // 매개변수의 해당 멤버를 사용하여 멤버들을 초기화합니다
        : m_numerator{ fraction.m_numerator }
        , m_denominator{ fraction.m_denominator }
    {
        std::cout << "Copy constructor called\n"; // 작동한다는 것을 증명하기 위해 출력합니다
    }

    void print() const
    {
        std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";
    }
};

int main()
{
    Fraction f { 5, 3 };  // Fraction(int, int) 생성자를 호출합니다
    Fraction fCopy { f }; // Fraction(const Fraction&) 복사 생성자를 호출합니다

    f.print();
    fCopy.print();

    return 0;
}

이 프로그램을 실행하면 다음과 같이 출력됩니다.

Copy constructor called
Fraction(5, 3)
Fraction(5, 3)

우리가 방금 만든 복사 생성자는 C++이 기본으로 만들어주는 것과 똑같은 일을 합니다.
단지 화면에 글씨를 한 줄 더 찍어줄 뿐이죠. 이 메시지를 통해 fCopy 가 만들어질 때 정말로 복사 생성자가 쓰였다는 걸 알 수 있습니다.

기억해 두세요
C++에서 '접근 제어(private, public)'는 객체 하나하나가 아니라 '클래스 종류'를 기준으로 작동합니다. 즉, 같은 Fraction 가족이라면 다른 객체의 숨겨진 데이터(private 멤버)라도 마음대로 열어볼 수 있습니다. 그래서 우리가 만든 복사 생성자 안에서 원본 fraction 객체의 private 멤버에 직접 접근할 수 있었던 것입니다.

복사 생성자 안에서는 '복사'하는 일만 해야 합니다. 복사하면서 다른 엉뚱한 행동을 하게 만들면 안 됩니다. 왜냐하면 C++ 컴파일러(코드를 번역해 주는 프로그램)가 가끔 프로그램을 더 빠르게 만들려고 이 복사 과정을 통째로 건너뛰는 경우가 있기 때문입니다. 만약 복사 생성자에 중요한 다른 기능을 넣어두었다면, 그 기능이 실행되지 않을 수도 있습니다.


기본이 최고! 암시적 복사 생성자를 선호하세요

C++이 기본으로 제공하는 복사 기능은 대부분의 경우 우리가 딱 원하는 방식
(부품 하나씩 똑같이 복사하기)으로 완벽하게 작동합니다.

  • 권장 사항: 굳이 내가 직접 복사 생성자를 만들어야 할 특별한 이유가 없다면, C++이 알아서 만들어주는 암시적 복사 생성자 를 그냥 사용하는 것이 가장 좋습니다.

나중에 '동적 메모리 할당'이라는 조금 더 복잡한 개념을 배우게 되면, 그때는 복사 생성자를 직접 만들어야 하는 경우가 생깁니다 (이는 21.13강에서 배우게 됩니다).


복사 생성자의 매개변수는 반드시 '참조'여야 합니다

복사 생성자를 직접 만들 때, 받아오는 원본 재료(매개변수)는 반드시 원본 그 자체를 가리키는 참조 형태로 받아와야 합니다. 복사하는 과정에서 원본이 망가지면 안 되기 때문에, 원본을 변경할 수 없게 막아주는 const 참조(const Fraction&)를 사용하는 것이 좋습니다.

  • 권장 사항: 복사 생성자를 직접 작성한다면, 매개변수는 반드시 const lvalue 참조 여야 합니다.

함수에 값을 넘겨줄 때와 복사 생성자

우리가 어떤 객체를 함수에 통째로 넘겨줄 때, 원본이 아니라 원본의 '복사본'이 함수 안으로 들어갑니다. 이때 바로 암시적으로 복사 생성자가 사용됩니다!

다음 예제를 볼까요?

#include <iostream>

class Fraction
{
private:
    int m_numerator{ 0 };
    int m_denominator{ 1 };

public:
    // 기본 생성자
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator{ numerator }, m_denominator{ denominator }
    {
    }

    // 복사 생성자
    Fraction(const Fraction& fraction)
        : m_numerator{ fraction.m_numerator }
        , m_denominator{ fraction.m_denominator }
    {
        std::cout << "Copy constructor called\n";
    }

    void print() const
    {
        std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";
    }
};

void printFraction(Fraction f) // f는 값에 의한 전달(pass by value)입니다
{
    f.print();
}

int main()
{
    Fraction f{ 5, 3 };

    printFraction(f); // 복사 생성자를 사용하여 f가 함수 매개변수로 복사됩니다

    return 0;
}

이 예제를 실행하면 화면에 이렇게 뜹니다.

Copy constructor called
Fraction(5, 3)

printFraction(f); 를 부를 때, main 함수에 있던 원래 f 가 복사 생성자를 거쳐 함수의 매개변수인 새로운 f 로 복사되어 들어가는 것을 볼 수 있습니다.


함수에서 값을 반환할 때와 복사 생성자

함수에서 계산이 끝나고 그 결과(객체)를 돌려줄 때도 마찬가지입니다. 결과를 밖으로 내보내기 위해 임시로 복사본을 하나 만드는데, 이때도 복사 생성자 가 쓰입니다.

#include <iostream>

class Fraction
{
private:
    int m_numerator{ 0 };
    int m_denominator{ 1 };

public:
    // 기본 생성자
    Fraction(int numerator = 0, int denominator = 1)
        : m_numerator{ numerator }, m_denominator{ denominator }
    {
    }

    // 복사 생성자
    Fraction(const Fraction& fraction)
        : m_numerator{ fraction.m_numerator }
        , m_denominator{ fraction.m_denominator }
    {
        std::cout << "Copy constructor called\n";
    }

    void print() const
    {
        std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";
    }
};

void printFraction(Fraction f) // f는 값에 의한 전달(pass by value)입니다
{
    f.print();
}

Fraction generateFraction(int n, int d)
{
    Fraction f{ n, d };
    return f;
}

int main()
{
    Fraction f2 { generateFraction(1, 2) }; // 복사 생성자를 사용하여 Fraction이 반환됩니다

    printFraction(f2); // 복사 생성자를 사용하여 f2가 함수 매개변수로 복사됩니다

    return 0;
}

이 과정은 원래대로라면 이렇게 진행됩니다.

  1. generateFraction 함수가 끝날 때 밖으로 내보낼 '임시 복사본'을 만들며 복사 생성자 1번 호출.
  2. 이 '임시 복사본'을 이용해서 main 함수의 f2 를 만들며 복사 생성자 2번 호출.
  3. f2printFraction 함수에 넘겨주면서 복사 생성자 3번 호출.

그래서 화면에 "Copy constructor called"가 세 번 뜰 거라고 예상할 수 있습니다.
하지만 여러분의 컴퓨터에서 직접 실행해 보면 이 메시지가 두 번, 혹은 한 번만 뜰 수도 있습니다!

이는 컴퓨터가 코드를 똑똑하게 최적화해서 불필요한 복사 과정을 스스로 생략해 버렸기 때문입니다. 이것을 복사 생략이라고 부르는데, 이에 대해서는 나중에 14.15강에서 더 자세히 배울 예정이니 지금은 "아, 똑똑하게 줄여주는 기능이 있구나" 정도로만 넘어가셔도 좋습니다.


= default 로 기본 복사 생성자 명시하기

복사 생성자를 직접 만들지 않으면 C++이 알아서 만들어준다고 했죠?
만약 "C++아, 네가 만들어주는 그 기본 복사기 좀 써줄래?"라고 코드에 확실하게 명시하고 싶다면 = default; 를 사용하면 됩니다.

#include <iostream>

class Fraction
{
private:
    int m_numerator{ 0 };
    int m_denominator{ 1 };

public:
    // 기본 생성자
    Fraction(int numerator=0, int denominator=1)
        : m_numerator{numerator}, m_denominator{denominator}
    {
    }

    // 명시적으로 기본 복사 생성자를 요청합니다
    Fraction(const Fraction& fraction) = default;

    void print() const
    {
        std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";
    }
};

int main()
{
    Fraction f { 5, 3 };
    Fraction fCopy { f };

    f.print();
    fCopy.print();

    return 0;
}

참고 사항 (조금 더 깊은 이야기)

C++에는 3의 법칙(Rule of three) 이라는 아주 유명한 규칙이 있습니다.
만약 여러분이 복사 생성자, 소멸자, 복사 대입 연산자 중 하나라도 직접 만들어야 하는 상황이 생겼다면, 높은 확률로 나머지 두 개도 세트로 같이 만들어야 한다는 뜻입니다.

나중에는 이게 5의 법칙 으로 확장되기도 하죠. 지금 당장 외울 필요는 없지만,
나중에 '동적 메모리' 파트를 배울 때 이 법칙이 아주 중요하게 등장할 것입니다.


14.15 — 클래스 초기화와 복사 생략

아주 예전 1.4 레슨 - 변수 대입과 초기화 에서, 우리는 기본 타입의 변수에 값을 처음 넣는 6가지 기본 방법에 대해 배웠습니다. 기억나시나요?

int a;         // 초기값 없음 (기본 초기화)
int b = 5;     // 등호 기호 뒤에 초기값 넣기 (복사 초기화)
int c( 6 );    // 괄호 안에 초기값 넣기 (직접 초기화)

// 리스트 초기화 방법들 (C++11 버전부터 등장)
int d { 7 };   // 중괄호 안에 초기값 넣기 (직접 리스트 초기화)
int e = { 8 }; // 등호 뒤 중괄호 안에 초기값 넣기 (복사 리스트 초기화)
int f {};      // 빈 중괄호로 초기화 (값 초기화)

이 모든 초기화 방법들은 '클래스'로 만든 객체에도 똑같이 사용할 수 있습니다.

#include <iostream>

class Foo
{
public:

    // 기본 생성자 (아무것도 안 넘겨줄 때 불림)
    Foo()
    {
        std::cout << "Foo()\n";
    }

    // 일반 생성자 (숫자를 넘겨줄 때 불림)
    Foo(int x)
    {
        std::cout << "Foo(int) " << x << '\n';
    }

    // 복사 생성자 (다른 Foo를 똑같이 베낄 때 불림)
    Foo(const Foo&)
    {
        std::cout << "Foo(const Foo&)\n";
    }
};

int main()
{
    // Foo() 기본 생성자를 부릅니다
    Foo f1;           // 기본 초기화
    Foo f2{};         // 값 초기화 (이 방식을 추천해요)

    // foo(int) 일반 생성자를 부릅니다
    Foo f3 = 3;       // 복사 초기화 (explicit(명시적) 키워드가 없는 생성자만 가능)
    Foo f4(4);        // 직접 초기화
    Foo f5{ 5 };      // 직접 리스트 초기화 (이 방식을 추천해요)
    Foo f6 = { 6 };   // 복사 리스트 초기화 (explicit(명시적) 키워드가 없는 생성자만 가능)

    // foo(const Foo&) 복사 생성자를 부릅니다
    Foo f7 = f3;      // 복사 초기화
    Foo f8(f3);       // 직접 초기화
    Foo f9{ f3 };     // 직접 리스트 초기화 (이 방식을 추천해요)
    Foo f10 = { f3 }; // 복사 리스트 초기화

    return 0;
}

최신 C++에서는 복사 초기화, 직접 초기화, 리스트 초기화가 기본적으로 다 똑같은 역할을 합니다. 바로 객체를 초기화 하는 것이죠.

어떤 방식으로 초기화하든 다음 과정을 거칩니다.

  • 클래스를 초기화할 때, 컴파일러는 그 클래스 안에 있는 여러 생성자
    (객체를 조립하는 설명서)들을 쫙 훑어보고 가장 상황에 딱 맞는 생성자를 고릅니다.
    이 과정에서 넘겨준 값의 형태가 살짝 바뀔 수도 있습니다(암시적 형변환).
  • 클래스가 아닌 일반 타입(int 등)을 초기화할 때는, 암시적 형변환이 가능한지 규칙을 확인합니다.

핵심 포인트
초기화 방법들 사이에는 3가지 중요한 차이점이 있습니다.
(당장 다 외우지 않으셔도 괜찮습니다!)
1. 리스트 초기화(중괄호 {} 사용) 는 데이터가 손실될 위험이 있는 변환(예: 실수를 정수로 넣는 것)을 허락하지 않습니다.
2. 복사 초기화(= 사용) 는 명시적(explicit)이지 않은 일반적인 생성자나 변환 함수만 사용할 수 있습니다. 이 내용은 나중에 '14.16 레슨'에서 다룰 거예요.
3. 리스트 초기화 는 일반 생성자보다 '리스트 생성자'라는 것을 최우선으로 찾아서 연결하려고 합니다. 이 내용은 '16.2 레슨'에서 다루겠습니다.

참고로, 특정 상황에서는 특정 초기화 방법이 아예 금지되어 있기도 합니다.
(예: 생성자의 멤버 초기화 리스트 안에서는 =를 쓰는 복사 초기화는 안 되고, 직접 초기화 방식만 가능합니다.)


불필요한 복사

다음 간단한 프로그램을 살펴볼까요?

#include <iostream>

class Something
{
    int m_x{};

public:
    Something(int x)
        : m_x{ x }
    {
        std::cout << "일반 생성자\n";
    }

    Something(const Something& s)
        : m_x { s.m_x }
    {
        std::cout << "복사 생성자\n";
    }

    void print() const { std::cout << "Something(" << m_x << ")\n"; }
};

int main()
{
    Something s { Something { 5 } }; // 이 줄을 집중해서 보세요!
    s.print();

    return 0;
}

위 코드에서 변수 s를 만드는 과정을 살펴봅시다.

  1. 먼저 숫자 5를 가지고 '임시' Something 객체를 하나 뚝딱 만듭니다.
    (이때 Something(int) 일반 생성자가 불립니다.)
  2. 그리고 이 '임시 객체'를 바탕으로 진짜 s를 초기화합니다.
    임시 객체도 Something이고 sSomething이니까, 임시 객체의 값을 s로 똑같이 베끼기 위해 Something(const Something&) 복사 생성자가 불립니다.
  3. 결과적으로 s5라는 값을 가지게 되죠.

만약 컴퓨터가 아무런 똑똑한 최적화를 하지 않는다면 화면에는 이렇게 출력될 겁니다.

일반 생성자
복사 생성자
Something(5)

그런데 가만 보면, 이 과정은 쓸데없이 비효율적입니다! 굳이 생성자를 두 번이나 부를 필요가 있었을까요? 사실 방금 쓴 코드는 아래 코드와 결과가 완전히 똑같습니다.

Something s { 5 }; // 일반 생성자 Something(int) 한 번만 부르고, 복사 생성자는 쓰지 않음

이 코드가 결과는 같으면서 복사하는 과정이 없으니 훨씬 효율적이죠.


복사 생략

컴파일러는 프로그램을 더 빠르고 좋게 만들기 위해 코드를 알아서 수정할 권한이 있습니다. 그렇다면 여기서 궁금증이 하나 생깁니다.

"컴파일러가 알아서 쓸데없는 복사 과정을 없애고, Something s { Something{5} }; 라고 쓴 코드를 처음부터 Something s { 5 }; 라고 쓴 것처럼 찰떡같이 알아듣고 처리해 줄 수 있을까요?"

정답은 "네, 그렇습니다." 그리고 이렇게 똑똑하게 처리하는 과정을 복사 생략이라고 부릅니다.

복사 생략 은 컴파일러가 쓸데없는 객체 복사 과정을 통째로 없애버리는 최적화 기술입니다. 원래대로라면 복사 생성자를 불렀을 텐데, 컴파일러가 융통성을 발휘해 복사 자체를 안 하도록 코드를 바꿔버리는 거죠. 이렇게 컴파일러가 복사 생성자를 건너뛰는 것을 '생략되었다'고 표현합니다.

여기서 아주 중요한 특징이 하나 있습니다. 보통 다른 최적화 기술들은 프로그램의 겉보기 결과(화면에 글자가 나오는 것 등)를 바꾸면 안 된다는 "as-if 규칙"을 따라야 합니다.
하지만 복사 생략은 이 규칙의 예외 입니다!

즉, 복사 생성자 안에 화면에 글자를 출력하는 코드가 있더라도, 컴파일러는 그냥 복사를 생략해버릴 수 있습니다. 이 때문에 복사 생성자 안에는 '복사' 이외의 다른 행동(예: 화면 출력)을 넣으면 안 됩니다. 컴파일러가 복사를 생략해버리면 화면에 출력도 안 돼서 여러분이 기대한 것과 프로그램이 다르게 움직일 수 있거든요!

(이전에 배운 'as-if 규칙'이 기억 안 나신다면, 5.4 레슨을 참고해 주세요.)

위의 예제 코드를 C++17 컴파일러에서 실행해 보면 다음과 같은 결과가 나옵니다:

일반 생성자
Something(5)

보이시나요? 컴파일러가 쓸데없는 복사를 피하려고 복사 생성자를 아예 무시해버렸기 때문에 "복사 생성자"라는 글자가 화면에 나오지 않습니다! 복사 생략 덕분에 프로그램의 겉보기 결과가 바뀐 것이죠.


값으로 전달과 값으로 반환에서의 복사 생략

함수에 값을 넘겨주거나 함수에서 값을 돌려받을 때도 보통 복사 생성자가 불립니다.
하지만 이때도 컴파일러가 복사를 생략해버리는 경우가 있습니다. 아래 코드로 확인해 볼까요?

#include <iostream>

class Something
{
public:
	Something() = default;
	Something(const Something&)
	{
		std::cout << "복사 생성자가 불렸습니다\n";
	}
};

Something rvo()
{
	return Something{}; // Something() 기본 생성자를 부르고, 반환할 때 복사 생성자를 부릅니다
}

Something nrvo()
{
	Something s{}; // Something() 기본 생성자를 부릅니다
	return s;      // 반환할 때 복사 생성자를 부릅니다
}

int main()
{
	std::cout << "s1 초기화 중\n";
	Something s1 { rvo() }; // 복사 생성자를 부릅니다

	std::cout << "s2 초기화 중\n";
	Something s2 { nrvo() }; // 복사 생성자를 부릅니다

        return 0;
}

만약 C++14 이하의 아주 옛날 버전에서 최적화를 끈 상태라면, 이 프로그램은 복사 생성자를 무려 4번이나 부릅니다.

  1. rvo 함수가 main으로 값을 반환할 때 한 번.
  2. rvo()에서 받은 반환값으로 s1을 만들 때 한 번.
  3. nrvo 함수가 smain으로 반환할 때 한 번.
  4. nrvo()에서 받은 반환값으로 s2를 만들 때 한 번.

하지만 복사 생략덕분에, 요즘 여러분이 쓰는 컴파일러는 이 4번의 복사 과정을 대부분(또는 전부) 지워버릴 겁니다. (예를 들어 Visual Studio 2022는 3번을 지우고, GCC는 4번 모두 지워버립니다.)

초보자분들은 언제 컴파일러가 복사 생략을 하고 안 하는지 달달 외울 필요가 전혀 없습니다! 그냥 "컴파일러가 할 수만 있다면 알아서 불필요한 복사를 줄여 최적화를 해준다" 라고만 편하게 생각하세요. 복사 생성자가 불릴 줄 알았는데 안 불렸다면, 십중팔구 이 복사 생략 때문입니다.


C++17부터 의무가 된 복사 생략

C++17 버전 이전에는 복사 생략이 컴파일러 마음대로 "해도 되고 안 해도 되는" 선택사항이었습니다. 하지만 C++17부터는 특정 상황에서는 무조건 복사를 생략해야 하는 의무 가 되었습니다. (여러분이 컴파일러에게 최적화하지 말라고 명령해도 알아서 억지로 생략해버립니다.)

위와 똑같은 코드를 C++17 이상 버전에서 실행하면, rvo() 함수가 값을 반환할 때와 그 값으로 s1을 만들 때는 '무조건' 복사가 생략되어야 합니다. 반면 nvro()를 통해 s2를 만드는 과정은 무조건 해야 하는 의무 상황은 아니기 때문에, 여러분이 쓰는 컴파일러의 종류나 설정에 따라 복사를 할 수도 있고 안 할 수도 있습니다.

  • 선택적인 복사 생략 상황: 컴파일러가 복사를 생략해 주더라도, 어쨌든 복사 생성자 자체는 정상적으로 쓸 수 있게 존재해야 합니다. (지워져 있으면 에러가 납니다.)
  • 의무적인 복사 생략 상황: 어차피 100% 무조건 복사를 안 할 것이기 때문에, 복사 생성자가 지워져 있거나 막혀 있어도 아무 상관 없이 잘 넘어갑니다.

(고급 독자를 위한 참고) 선택적 복사 생략이 일어나지 않는 상황이더라도, 복사보다 더 효율적인 '이동(move)'이라는 기술이 대신 쓰일 수도 있습니다. 이 부분은 16.5 레슨에서 배울 테니 지금은 넘어가셔도 좋습니다!


14.16 — 변환 생성자와 explicit 키워드

10.1 레슨에서 우리는 암시적 형 변환 이라는 개념을 배웠어요. 이건 쉽게 말해서, 우리가 넘겨준 데이터의 종류(타입)가 원래 필요한 종류와 다를 때, 컴파일러가 눈치껏 알아서 올바른 타입으로 쓱 바꿔주는 마법을 말해요.

이 마법 덕분에 우리는 아래처럼 코드를 짤 수 있습니다.

#include <iostream>

void printDouble(double d) // double(실수) 매개변수를 가집니다.
{
    std::cout << d;
}

int main()
{
    printDouble(5); // int(정수) 타입의 인자를 넘겨주고 있습니다.

    return 0;
}

위 코드를 보면 printDouble 함수는 double(실수)을 원하는데, 우리는 int(정수)인 5를 주고 있어요. 서로 짝이 안 맞죠? 하지만 컴파일러는 "아, 정수 5를 실수 5.0으로 바꿔서 주면 되겠구나!" 하고 판단해서 알아서 바꿔줍니다.


사용자 정의 변환

이번엔 비슷한 다른 예시를 살펴볼게요.

#include <iostream>

class Foo
{
private:
    int m_x{};
public:
    Foo(int x)
        : m_x{ x }
    {
    }

    int getX() const { return m_x; }
};

void printFoo(Foo f) // Foo 매개변수를 가집니다.
{
    std::cout << f.getX();
}

int main()
{
    printFoo(5); // int 타입의 인자를 넘겨주고 있습니다.

    return 0;
}

이번에 printFoo 함수는 Foo라는 우리가 직접 만든 클래스(객체)를 원하는데, 여전히 int 정수인 5를 주고 있어요. 이번에도 컴파일러는 어떻게든 함수를 실행시키기 위해 정수 5Foo 객체로 몰래 변환하려고 시도합니다.

첫 번째 예시(정수 -> 실수)는 C++ 자체에 이미 규칙이 있어서 쉬웠어요.
하지만 이번엔 우리가 마음대로 만든 Foo라는 타입이잖아요? C++는 정수 5를 어떻게 Foo로 바꿔야 할지 기본 규칙을 가지고 있지 않습니다.

대신, 컴파일러는 우리가 클래스 안에 "정수를 받아서 Foo로 만들어주는 함수"를 만들어 두었는지 쓱 찾아봅니다. 이렇게 우리가 직접 만든 변환 방법을 사용자 정의 변환이라고 불러요.


변환 생성자

방금 예시에서 컴파일러는 정수 5Foo 객체로 바꿔줄 수 있는 함수를 찰떡같이 찾아냅니다. 바로 Foo(int) 생성자죠!

지금까지 우리는 생성자를 객체를 대놓고(명시적으로) 만들 때만 썼을 거예요.

  • Foo x { 5 }; // int 값 5를 명시적으로 Foo로 변환 (직접 만들기)

사실 함수를 부를 때 일어나는 일도 똑같습니다.

  • printFoo(5); // int 값 5를 암시적으로 Foo로 변환 (알아서 만들기)

결국 5라는 값을 주고 Foo 객체를 얻고 싶은 건데, Foo(int) 생성자가 딱 그 역할을 하는 거예요! 그래서 printFoo(5)를 부르면, 파라미터 f5를 재료로 삼아 Foo(int) 생성자를 통해 만들어지게 됩니다.

(참고로, C++17 버전부터는 이 과정이 중간 단계 없이 아주 빠르고 효율적으로 진행되도록 규칙이 개선되었답니다.)

이렇게 알아서 변환(암시적 변환)을 할 때 쓰일 수 있는 생성자를 변환 생성자 라고 부릅니다. 기본적으로 C++의 모든 생성자는 이 변환 생성자 역할을 할 수 있어요.


사용자 정의 변환은 딱 한 번만 가능해요!

자, 이제 아래 코드를 볼게요.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
    std::string m_name{};
public:
    Employee(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
};

void printEmployee(Employee e) // Employee 매개변수를 가집니다.
{
    std::cout << e.getName();
}

int main()
{
    printEmployee("Joe"); // 문자열 리터럴 인자를 넘겨주고 있습니다.

    return 0;
}

이번엔 Foo 대신 Employee 클래스를 썼어요. 함수는 Employee를 원하는데,
우리는 "Joe"라는 글자를 넣었죠. 다행히 우리는 Employee(std::string_view)라는 생성자도 준비해 뒀어요.

그런데 놀랍게도 이 코드는 실행이 안 되고 에러가 납니다! 이유는 간단해요.
컴파일러는 알아서 변환을 해줄 때, 사용자 정의 변환을 딱 한 번만 허용 하기 때문이에요.

위 코드에서는 변환이 두 번이나 필요합니다.

  1. "Joe" (일반 글자) -> std::string_view 로 변환
  2. std::string_view -> Employee 로 변환

이걸 고치는 두 가지 쉬운 방법이 있어요.

방법 1: 처음부터 std::string_view로 주기

int main()
{
    using namespace std::literals;
    printEmployee( "Joe"sv); // 이제 std::string_view 리터럴입니다.

    return 0;
}

이렇게 하면 std::string_view에서 Employee로 딱 한 번만 변환하면 되니까 잘 작동합니다!

방법 2: 알아서 하라고 맡기지 말고, 대놓고(명시적으로) 만들어 주기

int main()
{
    printEmployee(Employee{ "Joe" });

    return 0;
}

우리가 이미 Employee를 직접 뚝딱 만들어서 넘겨줬기 때문에, 함수에 전달할 때는 추가적인 변환이 전혀 필요 없어서 잘 작동합니다.

핵심 포인트
컴파일러가 몰래 하는 알아서 변환(암시적 변환)은, 우리가 중괄호 {}를 써서 대놓고 객체를 만드는 직접 초기화 방식으로 아주 쉽게 바꿀 수 있어요.


변환 생성자가 대형 사고를 칠 때

아래 코드를 볼까요?

#include <iostream>

class Dollars
{
private:
    int m_dollars{};
public:
    Dollars(int d)
        : m_dollars{ d }
    {
    }

    int getDollars() const { return m_dollars; }
};

void print(Dollars d)
{
    std::cout << "$" << d.getDollars();
}

int main()
{
    print(5);

    return 0;
}

print(5)를 부르면 컴파일러가 눈치껏 Dollars(int) 생성자를 써서 5를 달러 객체로 바꾸고 $5를 출력합니다.

그런데... 이게 정말 코드를 짠 사람이 원했던 걸까요?
어쩌면 이 사람은 그냥 화면에 숫자 5가 나오길 바랐는데, 컴파일러가 혼자 착각해서 달러로 바꿔버린 걸지도 몰라요. 프로그램이 엄청 커지면, 이렇게 컴파일러가 마음대로 변환해버리는 탓에 언제 어디서 엉뚱한 결과가 터질지 모릅니다.

안전하게 가려면 print(Dollars) 함수에는 오직 진짜 Dollars 객체만 넣을 수 있게 막아두는 게 좋겠죠?


explicit 키워드

이런 사고를 막기 위해 explicit 이라는 키워드를 사용합니다. 컴파일러에게
"이 생성자는 네 맘대로 알아서 변환할 때 절대 쓰지 마!" 라고 단호하게 선을 긋는 거예요.

생성자 앞에 explicit 을 붙이면 두 가지가 확실하게 금지됩니다.

  1. 알아서 복사해서 만들어주는 것 금지.
  2. 알아서 타입을 쓱 바꿔주는 것 금지.

이제 방금 전 코드의 생성자에 explicit 을 붙여볼게요.

#include <iostream>

class Dollars
{
private:
    int m_dollars{};
public:
    explicit Dollars(int d) // 이제 명시적(explicit)입니다.
        : m_dollars{ d }
    {
    }

    int getDollars() const { return m_dollars; }
};

void print(Dollars d)
{
    std::cout << "$" << d.getDollars();
}

int main()
{
    print(5); // Dollars(int)가 explicit이므로 컴파일 에러가 발생합니다.

    return 0;
}

이제 컴파일러는 숫자 5를 몰래 Dollars로 바꿀 수 없어서,
뭘 어떻게 해야 할지 모르고 쿨하게 에러를 뿜어냅니다!


그럼 explicit 생성자는 어떻게 쓰나요?

알아서(암시적으로) 변환하는 것만 막았을 뿐, 우리가 대놓고(직접) 객체를 만들 때는 여전히 편하게 쓸 수 있어요!

// Dollars(int)가 explicit이라고 가정합니다.
int main()
{
    Dollars d1(5); // 정상 작동 (직접 초기화)
    Dollars d2{5}; // 정상 작동 (직접 리스트 초기화)
}

만약 print 함수에 꼭 5라는 숫자를 써서 부르고 싶다면 어떻게 할까요?
에러가 나던 print(5); 대신, 우리가 직접 달러 객체를 만들어서 넣어주면 됩니다!

  • print(Dollars{5}); // 정상 작동: 대놓고 Dollars 객체를 만들어서 넘김

이렇게 하면 코드도 실행되고, 누가 봐도 "아! 여기선 달러 객체를 만들어서 넘기려고 한 거구나!" 하고 의도를 100% 명확하게 알 수 있습니다.


함수에서 값을 반환할 때와 explicit

함수에서 어떤 값을 반환할 때도, 원래 반환해야 하는 타입과 다르면 컴파일러가 알아서 변환을 시도합니다. 이때도 당연히 explicit 생성자는 얌체같이 끼어들 수 없어요.

#include <iostream>

class Foo
{
public:
    explicit Foo() // 참고: 예시를 위해 explicit으로 설정
    {
    }

    explicit Foo(int x) // 참고: explicit 설정됨
    {
    }
};

Foo getFoo()
{
    // explicit Foo() 케이스
    return Foo{ };   // 정상 작동 (명시적으로 직접 만듦)
    return { };      // 에러: 빈 괄호를 알아서 Foo로 변환할 수 없음

    // explicit Foo(int) 케이스
    return 5;        // 에러: int를 알아서 Foo로 변환할 수 없음
    return Foo{ 5 }; // 정상 작동 (명시적으로 직접 만듦)
    return { 5 };    // 에러: 괄호 안의 5를 알아서 Foo로 변환할 수 없음
}

int main()
{
    return 0;
}

explicit 사용 모범 가이드

요즘 C++ 전문가들은 인자를 딱 한 개만 받는 생성자에는 기본적으로 explicit 을 무조건 붙이는 걸 추천 합니다. 이렇게 하면 컴파일러가 멋대로 변환하다가 버그를 만드는 걸 애초에 차단할 수 있거든요. 정말 변환이 필요하면, 우리가 중괄호 {}를 써서 명확하게 알려주면 되니까요.

explicit 을 절대 붙이면 안 되는 경우
복사(Copy) 및 이동(Move) 생성자 (얘네는 애초에 타입 변환을 하는 목적이 아니에요).

보통은 explicit 을 안 붙이는 경우
인자가 아예 없는 기본 생성자 (특별히 막을 이유가 거의 없습니다).
인자를 무조건 여러 개 받아야 하는 생성자 (애초에 변환 후보로 잘 안 쓰입니다).

explicit 을 붙여야 하는 경우
인자를 딱 한 개만 받는 생성자! (가장 중요)

예외 상황 (안 붙여도 괜찮은 경우)
변환하기 전과 후가 완전히 똑같은 의미를 가지고 있고, 컴퓨터 자원도 적게 먹을 때. (예를 들어 C스타일 글자를 std::string_view로 바꿀 때는 아주 가벼워서 괜찮지만, 무거운 std::string을 만들 때는 explicit 으로 막아둡니다.)

최종 요약
인자를 1개만 받는 생성자에는 일단 explicit 을 붙이는 습관을 들이세요.
컴파일러가 내 코드를 멋대로 해석하는 걸 막아주는 든든한 방패가 되어줄 겁니다.


14.17 — Constexpr 애그리게이트(집합체)와 클래스

지난 F.1 레슨에서 우리는 constexpr 함수에 대해 배웠어요. 이 함수들은 코드를 컴파일할 때(번역할 때) 계산될 수도 있고, 프로그램이 실제로 실행 중일 때(런타임) 계산될 수도 있는 아주 똑똑한 녀석들이죠. 예제를 하나 볼까요?

#include <iostream>

constexpr int greater(int x, int y)
{
    return (x > y ? x : y);
}

int main()
{
    std::cout << greater(5, 6) << '\n'; // greater(5, 6)은 컴파일할 때 계산될 수도 있고, 실행 중(런타임)에 계산될 수도 있어요

    constexpr int g { greater(5, 6) };  // greater(5, 6)은 무조건 컴파일할 때 계산되어야 해요
    std::cout << g << '\n';             // 6을 출력해요

    return 0;
}

이 예제에서 greater()constexpr 함수이고, greater(5, 6) 은 상수 표현식이에요.
즉, 컴파일할 때나 실행할 때 아무 때나 계산될 수 있죠.

std::cout << greater(5, 6) 부분에서는 굳이 컴파일 단계에서 계산을 끝낼 필요가 없기 때문에, 컴파일러(코드를 번역해 주는 프로그램)가 언제 계산할지 자유롭게 결정해요.
하지만 g 라는 constexpr 변수에 값을 넣으려고 할 때는 이야기가 다릅니다!

g 는 무조건 컴파일할 때 값이 정해져야 하는 변수이기 때문에, 이때 greater(5, 6)반드시 컴파일 단계에서 계산 되어야만 한답니다.

이제 조금 다르게 생긴 비슷한 예제를 살펴볼게요.

#include <iostream>

struct Pair
{
    int m_x {};
    int m_y {};

    int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    Pair p { 5, 6 };                  // 입력값들이 모두 constexpr(상수) 값이에요
    std::cout << p.greater() << '\n'; // p.greater()는 실행 중(런타임)에 계산돼요

    constexpr int g { p.greater() };  // 컴파일 에러: greater()가 constexpr 함수가 아니에요
    std::cout << g << '\n';

    return 0;
}

이번 버전에서는 Pair 라는 구조체를 만들었고, greater() 가 그 안의 멤버 함수가 되었네요. 그런데 여기서 문제가 생겨요! 멤버 함수인 greater() 앞에 constexpr 이 안 붙어있죠? 그래서 p.greater() 는 상수 표현식이 될 수 없어요.

따라서 std::cout << p.greater() 를 부를 때는 그냥 프로그램 실행 중에 평범하게 계산됩니다. 하지만 constexpr 변수인 g 에 값을 넣으려고 하면, 컴파일러가
"어? 이거 컴파일할 때 계산 못 하는 함수인데?" 라며 불평하고 컴파일 에러를 뿜어내게 됩니다.

p 에 들어가는 입력값이 5와 6으로 이미 딱 정해져 있는데, 왜 컴파일할 때 계산하지 못하는 걸까요? 어떻게 하면 고칠 수 있는지 알아봅시다!


Constexpr 멤버 함수

일반 함수들처럼, 구조체나 클래스 안에 속한 멤버 함수도 앞에 constexpr 키워드만 붙여주면 constexpr 함수로 만들 수 있어요. 이렇게 하면 컴파일할 때나 실행할 때 언제든 계산할 수 있게 된답니다.

#include <iostream>

struct Pair
{
    int m_x {};
    int m_y {};

    constexpr int greater() const // 컴파일할 때나 실행할 때 모두 계산할 수 있어요
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    Pair p { 5, 6 };
    std::cout << p.greater() << '\n'; // 성공: p.greater()는 실행 중에 계산돼요

    constexpr int g { p.greater() };  // 컴파일 에러: p가 constexpr 변수가 아니에요
    std::cout << g << '\n';

    return 0;
}

이번에는 greater() 함수에 constexpr 을 붙여주었어요. 그래서 이제 컴파일러가 알아서 언제 계산할지 고를 수 있게 되었죠.
첫 번째 출력문에서는 실행 중에 아주 잘 계산됩니다.

하지만! g 변수를 만들려고 시도하면 여전히 에러가 납니다.
왜냐고요? greater() 자체는 constexpr 로 업그레이드되었지만, 그 함수를 부르는 주인공인 p 가 아직 constexpr 변수가 아니기 때문이에요.
주인공이 준비가 안 돼서 전체가 상수가 될 수 없는 상황인 거죠.


Constexpr 집합체

좋아요, 문제가 p 라면 해결책은 간단합니다. pconstexpr 로 만들어주면 되죠!

#include <iostream>

struct Pair // Pair는 애그리게이트(단순 집합체)예요
{
    int m_x {};
    int m_y {};

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    constexpr Pair p { 5, 6 };        // 이제 constexpr이 되었어요
    std::cout << p.greater() << '\n'; // p.greater()는 실행 중이거나 컴파일할 때 계산돼요

    constexpr int g { p.greater() };  // p.greater()는 무조건 컴파일할 때 계산되어야 해요
    std::cout << g << '\n';

    return 0;
}

짠! 완벽하게 작동합니다. Pair 구조체는 데이터만 모아둔 '애그리게이트'이고, 애그리게이트는 기본적으로 constexpr 기능을 지원하거든요. 이제 변수 pconstexpr 타입이고, greater()constexpr 멤버 함수니까, 찰떡궁합으로 p.greater() 는 완벽한 상수 표현식이 되었습니다!


Constexpr 클래스 객체와 Constexpr 생성자

자, 이번에는 우리들의 Pair 를 집합체가 아닌 진짜 '클래스' 형태로 만들어 볼게요.
(데이터를 숨기고 생성자를 추가하면 더 이상 집합체가 아니게 됩니다.)

#include <iostream>

class Pair // Pair는 이제 애그리게이트가 아니에요
{
private:
    int m_x {};
    int m_y {};

public:
    Pair(int x, int y): m_x { x }, m_y { y } {}

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    constexpr Pair p { 5, 6 };       // 컴파일 에러: p는 리터럴 타입(Literal type)이 아니에요
    std::cout << p.greater() << '\n';

    constexpr int g { p.greater() };
    std::cout << g << '\n';

    return 0;
}

방금 전 예제와 거의 똑같지만, Pair 가 더 이상 집합체가 아니라는 점만 다릅니다.
그런데 이 코드를 컴파일하면 "Pair리터럴 타입 이 아니야!" 라며 에러가 발생해요.
대체 무슨 소리일까요?

C++에서 리터럴 타입이란, '컴파일하는 동안(상수 표현식 안에서) 짠! 하고 만들어질 수 있는 자격증을 갖춘 데이터 타입'을 말해요.

다시 말해, 이 자격증이 없는 타입은 constexpr 객체로 만들 수가 없어요.
안타깝게도 방금 우리가 만든 Pair 클래스는 이 자격증이 없네요.

[용어 정리] > 리터럴리터럴 타입은 조금 다릅니다.

  • 리터럴은 소스 코드에 직접 적어 넣은 상수 값(예: 숫자 5)을 말해요.
  • 리터럴 타입은 이런 constexpr 값이 될 수 있는 '데이터의 종류(타입)'를 말합니다.

리터럴 타입의 조건은 복잡하지만, 다음 4가지만 기억해 두시면 좋아요!
1. 일반적인 숫자나 포인터
2. 참조(Reference) 타입
3. 대부분의 애그리게이트
4. constexpr 생성자를 가진 클래스

아하! 이제 이유를 알겠네요. 클래스로 물건(객체)을 만들려면 컴파일러가 '생성자'라는 함수를 불러서 초기 설정을 해줘야 해요. 그런데 우리 Pair 클래스의 생성자 앞에는 constexpr 이 없어서 컴파일할 때 부를 수가 없었던 거예요.

고치는 방법은 너무나 쉽습니다. 생성자 앞에도 constexpr 을 딱! 붙여주기만 하면 끝이에요.

#include <iostream>

class Pair
{
private:
    int m_x {};
    int m_y {};

public:
    constexpr Pair(int x, int y): m_x { x }, m_y { y } {} // 이제 constexpr 생성자예요

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

int main()
{
    constexpr Pair p { 5, 6 };
    std::cout << p.greater() << '\n';

    constexpr int g { p.greater() };
    std::cout << g << '\n';

    return 0;
}

구조체를 썼을 때처럼 이제 아주 잘 작동합니다!

[초보자를 위한 꿀팁]
여러분이 만든 클래스가 컴파일할 때 계산되기를 원한다면, 멤버 함수와 생성자 모두에게 constexpr 을 붙여주세요.

참고로 constexpr 은 클래스 설계의 아주 중요한 부분이라서, 나중에 함부로 빼버리면 이 클래스를 믿고 쓰던 다른 코드들이 펑! 하고 망가질 수 있으니 조심해야 해요.


Constexpr 멤버 함수는 일반 객체에도 필요할 수 있어요

위의 예제에서는 상수 변수인 g 를 만들기 위해 관련된 p, 생성자, greater() 모두가 constexpr 이어야 한다는 걸 보았어요. 하지만 함수로 한번 감싸면 조금 헷갈릴 수 있습니다.

#include <iostream>

class Pair
{
private:
    int m_x {};
    int m_y {};

public:
    constexpr Pair(int x, int y): m_x { x }, m_y { y } {}

    constexpr int greater() const
    {
        return (m_x > m_y  ? m_x : m_y);
    }
};

constexpr int init()
{
    Pair p { 5, 6 };    // 컴파일할 때 계산되려면 생성자가 constexpr이어야 해요
    return p.greater(); // 컴파일할 때 계산되려면 greater() 함수도 constexpr이어야 해요
}

int main()
{
    constexpr int g { init() }; // init() 함수가 컴파일 단계에서 계산돼요
    std::cout << g << '\n';

    return 0;
}

constexpr 함수는 언제든 실행될 수 있다고 했죠? 만약 컴파일할 때 실행된다면, 그 안에서 부르는 다른 함수들도 무조건 컴파일할 때 실행할 수 있는 constexpr 함수여야만 해요.

위 코드에서 g 는 상수이므로 init() 은 컴파일할 때 계산되어야 합니다. 흥미로운 건 init() 안에서 p 를 굳이 constexpr 이나 const 로 만들지 않았다는 거예요. 그래도 괜찮습니다. 하지만 결국 이 모든 과정이 컴파일 중에 이루어져야 하기 때문에, p 를 만드는 생성자와 greater() 함수는 반드시 constexpr 이어야 해요. 둘 중 하나라도 아니면 에러가 발생한답니다.

핵심 요약
constexpr 함수가 컴파일 환경에서 계산되고 있다면, 그 안에서는 오직 constexpr 함수들만 호출할 수 있습니다!


Constexpr 멤버 함수는 const일 수도, 아닐 수도 있어요 (C++14 버전 이상)

아주 옛날 C++11 버전에서는 constexpr 멤버 함수를 만들면 무조건 값이 절대 변하지 않는 const 함수로 취급했어요. 하지만 C++14 버전부터는 규칙이 바뀌었답니다! 이제 constexpr 함수라고 해서 무조건 const 인 건 아니에요. 만약 값을 바꾸지 않는 안전한 함수로 만들고 싶다면 끝에 명시적으로 const 를 적어줘야 합니다.


Constexpr이면서 const가 아닌 멤버 함수는 데이터를 바꿀 수 있어요 (선택 심화 학습)

조금 헷갈릴 수 있지만 재미있는 사실! constexpr 이면서 const 가 아닌 멤버 함수는 컴파일하는 도중에도 클래스 안의 데이터를 쇽쇽 바꿀 수 있어요. (단, 그 객체 자체가 const 로 묶여있지 않을 때만요.)

억지로 만든 예제이긴 하지만 한번 살펴볼까요?

#include <iostream>

class Pair
{
private:
    int m_x {};
    int m_y {};

public:
    constexpr Pair(int x, int y): m_x { x }, m_y { y } {}

    constexpr int greater() const // constexpr이면서 const 함수예요
    {
        return (m_x > m_y  ? m_x : m_y);
    }

    constexpr void reset() // constexpr이지만 const는 아니에요
    {
        m_x = m_y = 0; // const가 아닌 멤버 함수는 내부 데이터를 바꿀 수 있어요
    }

    constexpr const int& getX() const { return m_x; }
};

// 이 함수는 constexpr이에요
constexpr Pair zero()
{
    Pair p { 1, 2 }; // p는 const가 아니에요 (변할 수 있어요)
    p.reset();       // const가 아닌 객체에서 const가 아닌 멤버 함수를 부르는 건 문제 없어요
    return p;
}

int main()
{
    Pair p1 { 3, 4 };
    p1.reset();                     // const가 아닌 객체에서 const가 아닌 멤버 함수를 부르는 건 문제 없어요
    std::cout << p1.getX() << '\n'; // 0을 출력해요

    Pair p2 { zero() };             // zero()는 실행 중(런타임)에 계산돼요
    p2.reset();                     // const가 아닌 객체에서 const가 아닌 멤버 함수를 부르는 건 문제 없어요
    std::cout << p2.getX() << '\n'; // 0을 출력해요

    constexpr Pair p3 { zero() };   // zero()는 컴파일할 때 계산돼요
//  p3.reset();                     // 컴파일 에러: const 객체에서는 const가 아닌 멤버 함수를 부를 수 없어요
    std::cout << p3.getX() << '\n'; // 0을 출력해요

    return 0;
}

복잡해 보이지만 이 두 가지만 기억하세요.

  1. const 가 아닌 함수는 const 가 아닌 객체의 데이터를 자유롭게 바꿀 수 있다.
  2. constexpr 함수는 실행할 때든 컴파일할 때든 다 쓰일 수 있다.

p1p2 는 프로그램이 실행될 때 계산되므로 평범하게 데이터를 바꿀 수 있습니다.
정말 신기한 건 p3 예제예요! p3constexpr 이니까 무조건 컴파일할 때 zero() 함수가 실행되어야 해요. 컴파일하는 중인데도 zero() 안에서 값을 바꾸는 reset() 함수가 아주 잘 작동하고 결과를 돌려줍니다.

[저자의 한마디] > "아니, 컴파일해서 상수를 만드는 중인데, 그 과정에서 상수가 아닌 변수(값이 막 변하는 변수)를 쓸 수 있다고요?" 라며 머리가 띵~ 하실 수 있어요. 지극히 정상입니다!
constexpr 변수라고 해서 꼭 const 값들로만 초기화해야 하는 건 아니에요. 핵심은 "컴파일러가 컴파일하는 타이밍에 그 값을 미리 계산해 낼 수 있는가?" 입니다. 계산만 무사히 끝낼 수 있다면, 그 중간 과정에서 값이 변하는 일반 변수를 쓰든 데이터를 지지고 볶든 컴파일러는 상관하지 않는답니다!

Const 참조(Reference)나 포인터를 반환하는 Constexpr 함수 (선택 심화 학습)

보통은 constexprconst 를 바로 옆에 나란히 붙여서 쓸 일이 거의 없어요. 하지만 함수가 돌려주는(반환하는) 값이 '절대 변하면 안 되는 원본 데이터(const 참조)'일 때는 두 개가 같이 쓰이기도 합니다.

위 코드의 getX() 함수가 바로 그런 녀석이죠.

constexpr const int& getX() const { return m_x; }

세상에, const 가 정말 많죠? 너무 겁먹지 마시고 하나씩 뜯어볼게요!

  • 맨 앞의 constexpr: "나는 컴파일할 때도 실행될 수 있는 함수야!"
  • 중간의 const int&: "내가 돌려줄 값은 절대 수정하면 안 되는 원본 데이터야!"
  • 맨 끝의 const: "나(함수)는 클래스 안의 데이터를 절대 바꾸지 않는 착하고 안전한 녀석이야!"

[여담으로...]
만약 참조 대신 포인터로 돌려준다면 코드가 이렇게 생겼을 거예요.
constexpr const int* const getXPtr() const { return &m_x; }
const 가 4개나 줄줄이 달려있네요! 참 아름답지 않나요? ...아니라고요? 네, 알겠습니다.


C++를 처음 배울 때 클래스 개념은 정말 외계어처럼 느껴지는 게 당연해요! 절대 바보가 아니십니다. 누구나 처음엔 다 헷갈리고 어려워하니까 걱정하지 마세요. 요청하신 대로 원래의 뜻은 그대로 살리면서, 최대한 이해하기 쉽고 친절하게 풀어서 번역해 드렸습니다.

(참고로 제공해주신 원문에는 코드 블록이 포함되어 있지 않아서, 본문 텍스트 번역과 띄어쓰기 규칙에 최대한 집중했습니다!)


장 복습

  • 절차적 프로그래밍(Procedural programming) 은 프로그램의 논리를 실행하는 "절차"(C++에서는 함수라고 부릅니다)를 만드는 데 집중하는 방식입니다. 우리는 이 함수들에게 데이터를 넘겨주고, 함수들은 그 데이터로 작업을 처리한 뒤, 필요에 따라 결과를 돌려줍니다.
  • 객체 지향 프로그래밍(Object-oriented programming, 흔히 OOP) 은 특성(데이터)과 잘 짜인 행동(함수)을 모두 한데 모아 놓은 '나만의 데이터 타입'을 만드는 데 집중하는 방식입니다.
  • 클래스 불변성(Class invariant) 이란, 객체가 살아있는 동안 정상적인 상태를 유지하기 위해 항상 '참(True)'이어야만 하는 규칙이나 조건입니다. 이 규칙이 깨져버린 객체는 유효하지 않은 상태(invalid state) 가 되었다고 말하며, 이 객체를 계속 쓰면 프로그램이 예상치 못하게 튕기거나 이상하게 동작할 수 있습니다.
  • 클래스(Class) 란 데이터와 그 데이터를 가지고 노는 함수들을 하나의 보따리 안에 묶어 놓은 복합 데이터 타입입니다.
  • 클래스 안에 들어 있는 함수들을 멤버 함수(Member functions) 라고 부릅니다. 멤버 함수를 실행할 때 기준이 되는 객체를 보통 암시적 객체(Implicit object) 라고 합니다. 반대로 클래스에 속하지 않은 일반 함수들은 구분하기 쉽게 비멤버 함수(Non-member functions) 라고 부릅니다. 만약 여러분의 클래스 안에 데이터가 하나도 없다면, 클래스 대신 네임스페이스(namespace)를 쓰는 것이 더 좋습니다.
  • const 멤버 함수 는 "나는 객체의 데이터를 절대 바꾸지 않을 것이며, 데이터를 바꿀 위험이 있는 다른 함수도 부르지 않겠다"라고 약속하는 함수입니다. 객체의 상태를 건드릴 일이 전혀 없는 함수라면 무조건 const를 붙여주세요. 그래야 수정 가능한 객체는 물론, 수정 불가능한 const 객체에서도 이 함수를 안심하고 쓸 수 있습니다.
  • 클래스의 각 멤버는 누가 나에게 접근할 수 있는지를 정하는 접근 수준(Access level) 이라는 속성을 가집니다. 이를 흔히 접근 제어(Access controls) 라고도 부릅니다. 이 규칙은 객체 하나하나마다 다르게 적용되는 게 아니라, 클래스 전체를 기준으로 정해집니다.
  • Public(공개) 멤버 는 접근에 아무런 제한이 없는 멤버입니다. (코드의 범위 안에만 있다면) 누구나 자유롭게 가져다 쓸 수 있습니다. 같은 클래스의 다른 멤버는 물론이고, 클래스 바깥에 있는 외부 코드(이를 '대중(public)'이라고 부릅니다)에서도 접근할 수 있습니다. 구조체(struct)는 기본적으로 모든 멤버가 공개(public) 상태입니다.
  • Private(비공개) 멤버 는 오직 같은 클래스 안에 있는 식구들(다른 멤버들)끼리만 접근할 수 있는 꽁꽁 숨겨진 멤버입니다. 클래스(class)는 기본적으로 모든 멤버가 비공개(private) 상태입니다. private 멤버가 하나라도 생기면 그 클래스는 더 이상 단순한 데이터 모음(aggregate)이 아니게 되므로, 중괄호 {}를 이용한 단순 초기화를 쓸 수 없습니다. private 멤버 변수의 이름은 앞에 m_을 붙여서 지어보세요. 그러면 일반 지역 변수나 함수 매개변수와 쉽게 구분할 수 있어서 아주 편합니다.
  • 우리는 접근 지정자(Access specifier) 를 사용해서 멤버를 공개할지 비공개할지 직접 정할 수 있습니다. 하지만 구조체(struct)를 쓸 때는 모든 멤버가 public이 되도록 접근 지정자를 아예 쓰지 않는 것이 좋습니다.
  • 접근 함수(Access function) 는 꽁꽁 숨겨진 private 멤버 변수의 값을 슬쩍 보거나 바꾸기 위해 만들어 놓은 아주 단순한 public 멤버 함수입니다. 여기에는 두 가지 종류가 있습니다. Getter(게터) 는 숨겨진 값을 읽어서 바깥으로 전달해 주는 함수이고, Setter(세터) 는 바깥에서 값을 받아와 숨겨진 변수의 값을 새롭게 바꿔주는 함수입니다.
  • 클래스의 인터페이스(Interface) 는 사용자가 그 클래스의 객체와 어떻게 소통할 수 있는지를 알려주는 '사용 설명서'와 같습니다. 클래스 밖에서는 오직 public 멤버에만 접근할 수 있기 때문에, 이 public 멤버들이 모여서 하나의 인터페이스를 이룹니다. 그래서 이를 퍼블릭 인터페이스(Public interface) 라고도 부릅니다.
  • 클래스의 구현부(Implementation) 는 클래스가 실제로 작동하게 만드는 알맹이 코드들입니다. 데이터를 담아두는 멤버 변수들과, 실제 연산을 수행하는 멤버 함수의 몸체 부분이 모두 여기에 속합니다.
  • 프로그래밍에서 데이터 은닉(Data hiding) 이란, 복잡한 내부 동작 원리(구현부)를 사용자에게서 숨겨버림으로써 '사용법(인터페이스)'과 '내부 원리(구현부)'를 분리하는 기술입니다.
  • 캡슐화(Encapsulation) 라는 용어도 데이터 은닉과 비슷한 뜻으로 자주 쓰입니다. 하지만 캡슐화는 데이터를 숨기든 안 숨기든 단순히 '데이터와 함수를 하나로 묶는다'는 뜻으로도 쓰이기 때문에, 문맥에 따라 의미가 살짝 헷갈릴 수 있습니다.
  • 클래스를 설계할 때는 사용자가 알아야 할 public 멤버를 먼저 위에 적고, 몰라도 되는 private 멤버를 맨 아래에 적는 것이 좋습니다. 이렇게 하면 사용 설명서(퍼블릭 인터페이스)가 돋보이고 복잡한 내부 코드는 숨겨지는 효과가 있습니다.
  • 생성자(Constructor) 는 클래스 객체가 처음 태어날 때(만들어질 때) 초기 설정을 해주는 특별한 멤버 함수입니다. 단순 데이터 묶음이 아닌 일반 클래스 객체를 제대로 만들려면 반드시 상황에 맞는 생성자가 있어야 합니다.
  • 멤버 초기화 목록(Member initializer list) 을 사용하면 생성자 안에서 멤버 변수들을 깔끔하게 초기화할 수 있습니다. 이때 목록의 순서는 클래스 안에서 변수를 선언했던 순서와 똑같이 맞추는 것이 좋습니다. 생성자 몸체 안에서 = 기호로 값을 넣는 것보다, 이 초기화 목록을 사용하는 것이 훨씬 더 좋고 빠른 방법입니다.
  • 아무런 매개변수(입력값)를 받지 않거나, 모든 매개변수에 기본값이 정해져 있는 생성자를 기본 생성자(Default constructor) 라고 부릅니다. 사용자가 특별한 초기값을 주지 않으면 이 기본 생성자가 알아서 출동합니다. 만약 여러분이 생성자를 하나도 만들지 않았다면, 친절한 C++ 컴파일러가 알아서 텅 빈 기본 생성자를 하나 만들어 주는데 이를 암시적 기본 생성자(Implicit default constructor) 라고 합니다.
  • 생성자는 자기 혼자 다 일하지 않고, 같은 클래스 안에 있는 다른 생성자에게 초기화 작업을 떠넘길(위임할) 수 있습니다. 이렇게 생성자끼리 연결되는 것을 생성자 체이닝(Constructor chaining) 이라 하고, 일을 떠넘기는 생성자를 위임 생성자(Delegating constructors) 라고 부릅니다. 참고로 생성자는 일을 떠넘기거나 직접 초기화하거나 둘 중 하나만 할 수 있습니다.
  • 임시 객체(Temporary object) 란 이름도 없이 태어났다가 코드 한 줄(단일 표현식)이 끝나면 곧바로 사라지는 아주 짧은 수명의 객체입니다.
  • 복사 생성자(Copy constructor) 는 이미 존재하는 똑같은 타입의 객체를 마치 복사기처럼 그대로 베껴서 새로운 객체를 만들 때 쓰는 생성자입니다. 여러분이 직접 만들지 않으면, C++이 알아서 변수들을 하나씩 똑같이 복사해 주는 암시적 복사 생성자를 만들어 줍니다.
  • as-if 규칙(As-if rule) 이란, 프로그램의 겉보기 동작만 변하지 않는다면 컴파일러가 코드를 더 빠르게 실행되도록 자기 마음대로 뜯어고칠 수 있다는 규칙입니다. 이 규칙의 예외 중 하나가 복사 생략(Copy elision) 이라는 최적화 기술입니다. 이는 불필요하게 객체를 복사하는 낭비를 컴파일러가 알아서 없애버리는 마법 같은 기능입니다. 이 기능 때문에 복사 생성자가 호출될 일 자체가 사라지는 것을 두고, 생성자가 생략되었다(elided) 고 표현합니다.
  • 어떤 값을 우리가 만든 클래스 타입으로 바꾸거나, 그 반대로 바꾸기 위해 직접 만든 함수를 사용자 정의 변환(User-defined conversion) 이라고 합니다. 그중에서도 자동으로 타입을 슬쩍 바꿔주는(암시적 변환) 역할을 할 수 있는 생성자를 변환 생성자(Converting constructor) 라고 부릅니다. 원칙적으로 모든 생성자는 이 변환 생성자 역할을 할 수 있습니다.
  • 생성자가 자기 마음대로 타입을 변환하는 데 쓰이는 게 싫다면 explicit (명시적인) 이라는 키워드를 붙여주면 됩니다. 이 키워드가 붙은 생성자는 알아서 값을 복사해서 넣어주거나, 암시적으로 형태를 바꾸는 일에 쓰일 수 없습니다.
  • 입력값(인수)을 딱 하나만 받는 생성자 앞에는 무조건 explicit을 붙이는 것을 습관으로 들이세요. 다만, 타입이 달라도 의미상 완전히 똑같고 성능도 빠르다면(std::stringstd::string_view로 바꾸는 등) 예외적으로 안 붙여도 괜찮습니다. 단, 복사 생성자나 이동 생성자 앞에는 절대 explicit을 붙이면 안 됩니다. 애초에 걔네들은 형태를 변환하려고 있는 애들이 아니기 때문입니다.
  • 생성자를 포함한 멤버 함수 앞에는 constexpr (상수 표현식)을 붙일 수 있습니다. C++14 버전부터는 constexpr을 붙였다고 해서 그 함수가 자동으로 const 멤버 함수가 되지는 않습니다.
profile
안녕하세요.

0개의 댓글