CS(ComputerScience)/프로그래밍언어

Bjarne Stroustrup(C++ 창시자)의 C++ Q & A(1)

susong 2023. 10. 28. 21:41
728x90
해당 글은 C++의 창시자인 Bjarne Stroustrup의 블로그 글 중 C++ Q&A를 번역할 글이며, 아래 글의 모든 내용은 링크에서 확인할 수 있습니다. 글을 읽으며 흥미로운 질의응답만을 번역해봤습니다. 

코딩 표준을 추천해줄 수 있나요?

추천해주는 코딩 표준

추천하는 코딩 표준은 C++ 코어 가이드라인입니다. 이는 사람들을 효과적인 최신 C++ 스타일로 안내하고 그 규칙을 지원하는 도구를 제공하기 위한 야심찬 프로젝트입니다. 이 프로젝트는 성능 저하나 장황함을 추가하지 않으면서도 완전히 유형 및 리소스 안전성이 보장된 언어로 C++를 사용하도록 권장합니다. 가이드라인 프로젝트를 설명하는 동영상이 있습니다.

(현재 404표시됨, 원래 주소 )


C++ 코딩 표준의 요점은 특정 환경에서 특정 목적을 위해 C++를 사용하기 위한 일련의 규칙을 제공하는 것입니다. 따라서 모든 용도와 모든 사용자를 위한 하나의 코딩 표준은 존재할 수 없습니다. 특정 애플리케이션(또는 회사, 애플리케이션 영역 등)에 대해 좋은 코딩 표준은 코딩 표준이 없는 것보다 낫습니다. 반면에 나쁜 코딩 표준이 코딩 표준이 없는 것보다 나쁘다는 것을 보여주는 많은 사례를 보았습니다.

C++용으로 약간 수정되었더라도 C 코딩 표준을 사용하지 말고, 당시에는 좋았다고 해도 10년 전의 C++ 코딩 표준을 사용하지 마세요. C++는 C가 아니며 표준 C++는 표준 이전의 C++가 아닙니다.

 

왜 빈 클래스의 사이즈가 0이 아닌가요?

서로 다른 두 객체의 주소가 달라지도록 하기 위해서입니다. 같은 이유로 "new"는 항상 서로 다른 객체에 대한 포인터를 반환합니다. 

class Empty { };

void f()
{
    Empty a, b;
    if (&a == &b) cout << "impossible: report error to compiler supplier";
    Empty* p1 = new Empty;
    Empty* p2 = new Empty;
    if (p1 == p2) cout << "impossible: report error to compiler supplier";
}

 

멤버 함수가 기본적으로 가상이 아닌 이유는 무엇인가요?

많은 클래스가 기본 클래스로 사용하도록 설계되지 않았기 때문입니다. 예를 들어 클래스 복합체를 참조하세요.


또한 가상 함수가 있는 클래스의 객체는 가상 함수 호출 메커니즘에 필요한 공간(일반적으로 객체당 한 단어)을 필요로 합니다. 이러한 오버헤드는 상당히 클 수 있으며, 다른 언어(예: C 및 Fortran)의 데이터와의 레이아웃 호환성을 방해할 수 있습니다.

더 많은 설계 근거는 C++의 설계와 진화를 참조하세요.

 

소멸자가 기본적으로 가상함수가 아닌 이유는 무엇인가요?

많은 클래스가 베이스 클래스로 사용하도록 설계되지 않았기 때문입니다. 가상 함수는 파생 클래스의 객체에 대한 인터페이스 역할을 하는 클래스(일반적으로 힙에 할당되고 포인터나 참조를 통해 액세스됨)에서만 의미가 있습니다.


그렇다면 언제 소멸자를 가상으로 선언해야 할까요? 클래스에 가상 함수가 하나 이상 있을 때마다. 가상 함수가 있다는 것은 클래스가 파생 클래스에 대한 인터페이스로 작동하도록 되어 있으며, 이 경우 파생 클래스의 객체는 베이스에 대한 포인터를 통해 소멸될 수 있음을 나타냅니다.

class Base {
    // ...
    virtual ~Base();
};

class Derived : public Base {
    // ...
    ~Derived();
};

void f()
{
    Base* p = new Derived;
    delete p;	// virtual destructor used to ensure that ~Derived is called
}

Base의 소멸자가 가상이 아니었다면 Derived의 소멸자가 호출되지 않았을 것이며, Derived이 소유한 자원이 해제되지 않는 등 나쁜 영향을 미쳤을 가능성이 높습니다.

 

왜 가상 생성자가 존재하지 않나요?

가상 호출은 부분적인 정보가 주어졌을 때 작업을 수행하는 메커니즘입니다. 특히 '가상'을 사용하면 객체의 정확한 유형이 아닌 인터페이스만 알고 함수를 호출할 수 있습니다. 객체를 생성하려면 완전한 정보가 필요합니다. 특히 생성하려는 객체의 정확한 유형을 알아야 합니다. 따라서 "생성자에 대한 호출"은 가상이 될 수 없습니다.


객체를 생성하도록 요청할 때 인다이렉션을 사용하는 기술을 흔히 "가상 생성자"라고 합니다. 이를 위해서는 TC++PL3 15.6.2를 참조하세요.

 

순수 가상함수란 무엇인가요?

순수 가상 함수는 파생 클래스에서 재정의해야 하며 정의할 필요가 없는 함수입니다. 가상 함수는 "=0" 구문을 사용하여 "순수" 함수로 선언됩니다.

class Base {
public:
    void f1();		// 가상함수 아님
    virtual void f2();	// 가상함수이나 순수하지 않음
    virtual void f3() = 0;	// 순수 가상함수
};

Base b;	// error: pure virtual f3 not overridden

여기서 Base는 추상 클래스이므로(순수 가상 함수를 가지고 있기 때문에) Base 클래스의 객체를 직접 생성할 수 없습니다: Base는 (명시적으로) 베이스 클래스를 의미합니다.

class Derived : public Base {
    // no f1: fine
    // no f2: fine, we inherit Base::f2
    void f3();
};

Derived d;	// ok: Derived::f3 overrides Base::f3

추상 클래스는 인터페이스를 정의하는 데 매우 유용합니다. 실제로 순수한 가상 함수만 있는 클래스를 흔히 인터페이스라고 부릅니다.

// 순수가상함수 만드는 방법
Base::f3() { /* ... */ }

이것은 때때로 매우 유용하지만(파생 클래스에 대한 간단한 공통 구현 세부 사항을 제공하기 위해), 일부 파생 클래스에서는 여전히 Base::f3()을 재정의해야 합니다.


파생 클래스에서 순수 가상 함수를 재정의하지 않으면 해당 파생 클래스는 추상화됩니다:

class D2 : public Base {
    // no f1: fine
    // no f2: fine, we inherit Base::f2
    // no f3: fine, but D2 is therefore still abstract
};

D2 d;	// error: pure virtual Base::f3 not overridden

 

JAVA에서처럼 "new"를 사용해도 되나요?

하지만 무턱대고 그렇게 하지 마세요. 더 나은 대안이 있는 경우가 많습니다.

void compute(cmplx z, double d)
{
	cmplx z2 = z+d;	// c++ style
	z2 = f(z2);		// use z2

	cmplx& z3 = *new cmplx(z+d);	// Java style (assuming Java could overload +)
	z3 = f(z3);
	delete &z3;	
}

z3에 "new"를 서투르게 사용하는 것은 지역 변수(z2)를 관용적으로 사용하는 것과 비교하면 불필요하고 느립니다. 같은 범위에서 해당 객체를 "삭제"하는 경우 객체를 생성할 때 "new"를 사용할 필요가 없으며, 이러한 객체는 지역 변수여야 합니다.

 

생성자에서 가상 함수를 호출할 수 있나요?

예, 하지만 주의하세요. 예상한 대로 작동하지 않을 수 있습니다. 생성자에서는 파생 클래스에서 재정의가 아직 일어나지 않았기 때문에 가상 호출 메커니즘이 비활성화됩니다. 객체는 "파생되기 전 베이스"에서 베이스 위로 구성됩니다.

 

사람들이 내 클래스로부터 파생하는 것을 막을 수 있나요?

네, 하지만 왜 그러고 싶으신가요? 일반적인 대답은 두 가지입니다
- 효율성을 위해: 내 함수 호출이 가상이 되지 않도록 하기 위해
- 안전성을 위해: 내 클래스가 베이스 클래스로 사용되지 않도록 하기 위해

 

제 경험에 비추어 볼 때, 효율성의 이유는 대개 잘못된 두려움입니다. C++에서 가상 함수 호출은 매우 빠르기 때문에 가상 함수로 설계된 클래스를 실제로 사용해도 일반 함수 호출을 사용하는 대체 솔루션에 비해 측정 가능한 런타임 오버헤드를 발생시키지 않습니다. 가상 함수 호출 메커니즘은 일반적으로 포인터나 참조를 통해 호출할 때만 사용된다는 점에 유의하세요. 명명된 객체에 대해 함수를 직접 호출할 때는 가상 함수 클래스 오버헤드를 쉽게 최적화할 수 있습니다.


가상 함수 호출을 피하기 위해 클래스 계층 구조를 "제한"할 필요가 정말로 있다면, 애초에 왜 해당 함수를 가상으로 만들었는지 의문을 가질 수 있습니다. 저는 성능에 중요한 함수를 "우리가 보통 그렇게 하는 방식"이라는 이유만으로 아무런 이유 없이 가상으로 만든 사례를 본 적이 있습니다.

 

왜 C++는 이종 컨테이너를 지원하지 않나요?

C++ 표준 라이브러리는 유용하고 정적으로 형식이 안전하며 효율적인 컨테이너 세트를 제공합니다. 벡터, 리스트, 맵(map)이 그 예입니다

vector<int> vi(10);
vector<Shape*> vs;
list<string> lst;
list<double> l2
map<string,Record*> tbl;
map< Key,vector<Record*> > t2;

이러한 컨테이너는 모든 좋은 C++ 교과서에 설명되어 있으며, 특별한 이유가 없는 한 배열 및 "홈 쿠킹" 컨테이너보다 선호되어야 합니다.
이러한 컨테이너는 동질적입니다. 즉, 동일한 유형의 요소를 담습니다. 컨테이너에 여러 다른 유형의 요소를 담으려면 이를 합집합으로 표현하거나 (일반적으로 훨씬 더 나은) 다형성 유형에 대한 포인터의 컨테이너로 표현해야 합니다. 고전적인 예는 다음과 같습니다

vector<Shape*> vi;	// vector of pointers to Shapes

여기서 vi는 Shape에서 파생된 모든 유형의 요소를 담을 수 있습니다. 즉, vi는 모든 요소가 Shape(정확히 말하면 Shape에 대한 포인터)라는 점에서 동질적이고, 원, 삼각형 등과 같은 다양한 Shape의 요소를 포함할 수 있다는 점에서 이질적입니다.


따라서 컨테이너를 사용하려면 사용자가 의존할 수 있는 모든 요소에 대한 공통 인터페이스가 있어야 하므로 어떤 의미에서 모든 컨테이너(모든 언어)는 동질적입니다. 이질적인 것으로 간주되는 컨테이너를 제공하는 언어는 모두 표준 인터페이스를 제공하는 요소의 컨테이너를 제공할 뿐입니다. 예를 들어 Java 컬렉션은 객체(참조)의 컨테이너를 제공하며, 사용자는 (공통) 객체 인터페이스를 사용하여 요소의 실제 유형을 검색할 수 있습니다.

 

C++ 표준 라이브러리는 동종 컨테이너를 제공하는데, 이는 대부분의 경우 사용하기 가장 쉽고, 컴파일 시 오류 메시지를 가장 잘 제공하며, 불필요한 런타임 오버헤드가 발생하지 않기 때문입니다.

C++에서 이기종 컨테이너가 필요한 경우 모든 요소에 대한 공통 인터페이스를 정의하고 해당 요소로 컨테이너를 만드세요

class Io_obj { /* ... */ };	// the interface needed to take part in object I/O

vector<Io_obj*> vio;		// if you want to manage the pointers directly
vector< Handle<Io_obj> > v2;	// if you want a "smart pointer" to handle the objects

 

표준 컨테이너들은 왜 이렇게 느린가요?

그렇지 않습니다. 아마도 "무엇과 비교해서?"라는 질문이 더 유용한 대답일 것입니다. 사람들이 표준 라이브러리 컨테이너 성능에 대해 불평할 때, 저는 보통 세 가지 진짜 문제(또는 많은 오해) 중 하나를 발견합니다

- 복사 오버헤드가 발생한다.
- 조회 테이블의 속도가 느립니다.
- 수작업으로 코딩한 list이 std::list보다 훨씬 빠릅니다.

 

최적화를 시도하기 전에 성능에 진짜 문제가 있는지 고려하세요. 저에게 보내온 대부분의 경우 성능 문제는 이론적이거나 가상의 문제입니다: 먼저 측정한 다음 필요한 경우에만 최적화합니다.

 

이러한 문제를 차례로 살펴봅시다. 종종 벡터<X>가 누군가의 특수한 My_container<X>보다 느린 경우가 있는데, 그 이유는 My_container<X>가 X에 대한 포인터의 컨테이너로 구현되기 때문입니다. 표준 컨테이너는 값의 복사본을 보관하고 컨테이너에 값을 넣으면 값을 복사하기 때문입니다. 이는 기본적으로 작은 값에는 문제가 없지만 거대한 객체에는 상당히 부적합할 수 있습니다:

vector<int> vi;
vector<Image> vim;
// ...
int i = 7;
Image im("portrait.jpg");	// initialize image from file
// ...
vi.push_back(i);	// put (a copy of) i into vi
vim.push_back(im);	// put (a copy of) im into vim

이제 portrait.jpg가 몇 메가바이트이고 이미지에 값 의미론(즉, 복사 할당 및 복사 구조가 복사본을 만드는 것)이 있는 경우 vim.push_back(im)은 실제로 비용이 많이 듭니다. 하지만 속담처럼 너무 아프면 그냥 하지 마세요. 대신 핸들 컨테이너나 포인터 컨테이너를 사용하세요.

 

예를 들어 Image에 참조 시맨틱이 있다면 위의 코드는 복사 생성자 호출 비용만 발생하며, 이는 대부분의 이미지 조작 연산자에 비하면 사소한 비용일 것입니다. Image와 같은 일부 클래스에 정당한 이유로 복사 시맨틱이 있는 경우 포인터 컨테이너가 합리적인 해결책이 될 수 있습니다

vector<int> vi;
vector<Image*> vim;
// ...
Image im("portrait.jpg");	// initialize image from file
// ...
vi.push_back(7);	// put (a copy of) 7 into vi
vim.push_back(&im);	// put (a copy of) &im into vim

당연히 포인터를 사용하는 경우 리소스 관리에 대해 생각해야 하지만, 포인터 컨테이너는 그 자체로 효과적이고 저렴한 리소스 핸들이 될 수 있습니다(종종 "소유된" 객체를 삭제하기 위해 소멸자가 있는 컨테이너가 필요합니다).


두 번째로 자주 발생하는 실제 성능 문제는 많은 수의 (문자열,X) 쌍에 map<string,X>(이하 맵)을 사용하는 경우입니다. 맵은 비교적 작은 컨테이너(예: 수백 개 또는 수천 개의 요소 - 10000개의 요소로 구성된 맵의 요소에 액세스하려면 약 9번의 비교가 필요함)에 적합하며, 그보다 작으면 비용이 적게 들고 좋은 해시 함수를 구성할 수 없는 경우에 적합합니다. 문자열이 많고 해시 함수가 좋은 경우 해시 테이블을 사용하세요. 표준 위원회의 기술 보고서에서 나온 unordered_map은 현재 널리 사용 가능하며 대부분의 사람들이 직접 만든 것보다 훨씬 낫습니다.

때로는 (문자열,X) 쌍이 아닌 (const char*,X) 쌍을 사용하여 속도를 높일 수 있지만, <는 C 스타일 문자열에 대해 사전 비교를 수행하지 않는다는 점을 기억하세요. 또한 X가 크면 복사 문제도 발생할 수 있습니다(일반적인 방법 중 하나로 해결).

Intrusive lists는 정말 빠를 수 있습니다. 그러나 목록이 꼭 필요한지 생각해 보세요. 벡터는 더 콤팩트하므로 삽입과 지우기를 할 때에도 많은 경우 더 작고 빠릅니다. 예를 들어, 논리적으로 몇 개의 정수 요소로 구성된 목록이 있는 경우 벡터가 목록(모든 리스트)보다 훨씬 빠릅니다. 또한 내장형 리스트는 내장형을 직접 보유할 수 없습니다.

 

따라서 정말로 목록이 필요하고 모든 요소 유형에 대해 링크 필드를 제공할 수 있다고 가정해 보겠습니다. 기본적으로 표준 라이브러리 목록은 할당 후 요소를 삽입하는 각 연산에 대한 복사(그리고 요소를 제거하는 각 연산에 대한 할당 해제)를 수행합니다. 기본 할당자를 사용하는 std::list의 경우 이 작업이 중요할 수 있습니다. 복사 오버헤드가 크지 않은 작은 요소의 경우 최적화된 얼로케이터를 사용하는 것이 좋습니다. 목록과 마지막 1온스의 성능이 필요한 경우에만 수작업으로 만든 침입형 목록을 사용하세요.

사람들은 때때로 std::벡터의 비용이 점진적으로 증가하는 것에 대해 걱정합니다. 저도 그런 걱정을 하곤 했고, reserve()를 사용해 증가를 최적화했습니다. 코드를 측정하고 실제 프로그램에서 reserve()의 성능 이점을 찾는 데 반복적으로 어려움을 겪은 후, 반복자 무효화(제 코드에서는 드문 경우)를 피하기 위해 필요한 경우를 제외하고는 사용을 중단했습니다. 다시 한 번 강조하지만 최적화하기 전에 측정하세요.

 

"friend" 는 캡슐화에 위반되나요??

아니요, 그렇지 않습니다. "친구"는 멤버십과 마찬가지로 접근 권한을 부여하기 위한 명시적인 메커니즘입니다. 표준 준수 프로그램에서는 소스를 수정하지 않고 클래스에 대한 액세스 권한을 직접 부여할 수 없습니다.

class X {
    int i;
public:
    void m();		// grant X::m() access
    friend void f(X&);	// grant f(X&) access
    // ...
};

void X::m() { i++; /* X::m() can access X::i */ }

void f(X& x) { x.i++; /* f(X&) can access X::i */ }

 

왜  C++은 포인터와 레퍼런스 둘 다 가지고 있나요?

C++는 C에서 포인터를 상속받았기 때문에 심각한 호환성 문제를 일으키지 않고는 포인터를 제거할 수 없었습니다. 참조는 여러 가지 용도로 유용하지만, C++에서 참조를 도입한 직접적인 이유는 연산자 오버로딩을 지원하기 위해서였습니다. 예를 들어

void f1(const complex* x, const complex* y)	// without references
{
    complex z = *x+*y;	// ugly
    // ...
}

void f2(const complex& x, const complex& y)	// with references
{
    complex z = x+y;	// better
    // ...
}

보다 일반적으로 포인터의 기능과 참조의 기능을 모두 갖고 싶으면 C++에서처럼 두 개의 다른 유형이 필요하거나 단일 유형에 대해 두 개의 다른 연산 집합이 필요합니다. 예를 들어 단일 유형에서는 참조된 객체에 할당하는 연산과 참조/포인터에 할당하는 연산이 모두 필요합니다. 이 작업은 별도의 연산자를 사용하여 수행할 수 있습니다(Simula에서와 같이). 예를 들어

Ref<My_type> r :- new My_type;
r := 7;			// assign to object
r :- new My_type;	// assign to reference

또는 타입 검사(오버로딩)에 의존할 수도 있습니다:

Ref<My_type> r = new My_type;
r = 7;			// assign to object
r = new My_type;	// assign to reference

 

 

Call-by-value를 써야하나요? 아니면 Call-by-reference를 써야하나요?

이는 달성하려는 목적에 따라 다릅니다
- 전달된 객체를 변경하려면 참조로 호출하거나 포인터를 사용하세요(예: void f(X&) 또는 void f(X*));
- 전달된 객체를 변경하고 싶지 않고 크기가 큰 경우, 상수 참조로 호출합니다(예: void f(const X&));
- 그렇지 않으면, 값으로 호출합니다; 예: void f(X);

 

"크다"는 게 무슨 뜻일까요? 두 단어보다 큰 모든 것을 의미합니다.

인자를 변경해야 하는 이유는 무엇인가요? 그래야 하는 경우도 있지만, 새로운 값을 생성하는 대안이 있는 경우도 많습니다. 생각해봅시다

void incr1(int& x);	// increment
int incr2(int x);	// increment

int v = 2;
incr1(v);	// v becomes 3
v = incr2(v);	// v becomes 4

독자의 입장에서는 incr2()가 더 이해하기 쉽다고 생각합니다. 즉, incr1()은 실수와 오류로 이어질 가능성이 더 높습니다. 따라서 새 값을 생성하고 복사하는 데 비용이 많이 들지 않는 한 값을 수정하는 스타일보다 새 값을 반환하는 스타일을 선호합니다.


인수를 변경하고 싶은데 포인터를 사용해야 하나요, 아니면 참조를 사용해야 하나요? 확실한 논리적 이유를 모르겠습니다. '객체가 아닌 것'(예: 널 포인터)을 전달하는 것이 허용되는 경우 포인터를 사용하는 것이 합리적입니다. 제 개인적인 스타일은 객체를 수정하고 싶을 때 포인터를 사용하는 것인데, 어떤 상황에서는 포인터를 사용하면 수정이 가능하다는 것을 더 쉽게 알아차릴 수 있기 때문입니다.

또한 멤버 함수의 호출은 본질적으로 객체에 대한 참조 호출이므로 객체의 값/상태를 수정하려는 경우 멤버 함수를 사용하는 경우가 많다는 점에 유의하세요.

 

C++에서 "this"가 참조가 아닌 이유는 무엇인가요?

"this"는 참조가 추가되기 전에 C++에 도입되었기 때문입니다(실제로는 클래스가 있는 C에 도입되었습니다, 최초의 C++ 원형). 또한 (나중에) Smalltalk에서 "self"를 사용하는 대신 Simula 사용법을 따르기 위해 "this"를 선택했습니다.

728x90