개발자가 저지르는 가장 흔한 C++ 실수 10가지
게시 됨: 2022-03-11C++ 개발자가 겪을 수 있는 많은 함정이 있습니다. 이것은 양질의 프로그래밍을 매우 어렵게 만들고 유지보수 비용을 매우 비싸게 만들 수 있습니다. C# 및 Java와 같은 유사한 언어로 언어 구문을 배우고 우수한 프로그래밍 기술을 보유하는 것만으로는 C++의 잠재력을 최대한 활용하기에 충분하지 않습니다. C++에서 오류를 피하려면 수년간의 경험과 훌륭한 훈련이 필요합니다. 이 기사에서는 모든 수준의 개발자가 C++ 개발에 충분히 주의하지 않을 경우 저지르는 일반적인 실수를 살펴보겠습니다.
일반적인 실수 #1: "new" 및 "delete" 쌍을 잘못 사용
아무리 노력해도 동적으로 할당된 모든 메모리를 해제하는 것은 매우 어렵습니다. 그렇게 할 수 있다 하더라도 예외로부터 안전하지 않은 경우가 많습니다. 간단한 예를 살펴보겠습니다.
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }예외가 발생하면 "a" 개체가 삭제되지 않습니다. 다음 예는 더 안전하고 짧은 방법을 보여줍니다. C++11에서 더 이상 사용되지 않는 auto_ptr을 사용하지만 이전 표준이 여전히 널리 사용됩니다. 가능한 경우 Boost의 C++11 unique_ptr 또는 scoped_ptr로 대체할 수 있습니다.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }무슨 일이 있어도 "a" 개체를 만든 후에는 프로그램 실행이 범위를 벗어나는 즉시 삭제됩니다.
그러나 이것은 이 C++ 문제의 가장 간단한 예일 뿐입니다. 삭제가 다른 위치, 아마도 외부 함수나 다른 스레드에서 수행되어야 하는 경우가 많이 있습니다. 그렇기 때문에 new/delete를 쌍으로 사용하는 것을 완전히 피하고 적절한 스마트 포인터를 대신 사용해야 합니다.
일반적인 실수 #2: 잊혀진 가상 소멸자
이것은 파생 클래스 내부에 동적 메모리가 할당된 경우 파생 클래스 내부에서 메모리 누수를 일으키는 가장 일반적인 오류 중 하나입니다. 가상 소멸자가 바람직하지 않은 경우가 있습니다. 즉, 클래스가 상속을 위한 것이 아니고 크기와 성능이 중요한 경우입니다. 가상 소멸자 또는 기타 가상 함수는 클래스 구조 내부에 추가 데이터를 도입합니다. 즉, 클래스 인스턴스의 크기를 더 크게 만드는 가상 테이블에 대한 포인터입니다.
그러나 대부분의 경우 클래스는 원래 의도되지 않은 경우에도 상속될 수 있습니다. 따라서 클래스가 선언될 때 가상 소멸자를 추가하는 것은 매우 좋은 방법입니다. 그렇지 않고 성능상의 이유로 클래스에 가상 함수가 포함되지 않아야 하는 경우 클래스가 상속되지 않아야 함을 나타내는 클래스 선언 파일 내부에 주석을 넣는 것이 좋습니다. 이 문제를 방지하는 가장 좋은 방법 중 하나는 클래스 생성 중에 가상 소멸자 생성을 지원하는 IDE를 사용하는 것입니다.
주제에 대한 한 가지 추가 사항은 표준 라이브러리의 클래스/템플릿입니다. 상속을 위한 것이 아니며 가상 소멸자가 없습니다. 예를 들어, std::string에서 공개적으로 상속되는 새로운 향상된 문자열 클래스를 생성하는 경우 누군가가 포인터 또는 std::string에 대한 참조와 함께 이를 잘못 사용하여 메모리 누수를 일으킬 가능성이 있습니다.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }이러한 C++ 문제를 피하기 위해 표준 라이브러리의 클래스/템플릿을 보다 안전하게 재사용하는 방법은 개인 상속 또는 합성을 사용하는 것입니다.
일반적인 실수 #3: "delete" 또는 스마트 포인터 사용으로 어레이 삭제
동적 크기의 임시 배열을 만들어야 하는 경우가 많습니다. 더 이상 필요하지 않으면 할당된 메모리를 해제하는 것이 중요합니다. 여기서 큰 문제는 C++에서 [] 대괄호가 있는 특별한 삭제 연산자가 필요하다는 것입니다. 이 연산자는 매우 쉽게 잊혀집니다. delete[] 연산자는 배열에 할당된 메모리를 삭제할 뿐만 아니라 먼저 배열에서 모든 객체의 소멸자를 호출합니다. 이러한 유형에 대한 소멸자가 없음에도 불구하고 기본 유형에 대해 [] 대괄호 없이 삭제 연산자를 사용하는 것도 올바르지 않습니다. 모든 컴파일러에 대해 배열에 대한 포인터가 배열의 첫 번째 요소를 가리킬 것이라는 보장은 없으므로 [] 대괄호 없이 삭제를 사용하면 정의되지 않은 동작도 발생할 수 있습니다.
auto_ptr, unique_ptr<T>, shared_ptr과 같은 스마트 포인터를 배열과 함께 사용하는 것도 올바르지 않습니다. 이러한 스마트 포인터가 범위에서 종료되면 [] 대괄호 없이 삭제 연산자를 호출하여 위에서 설명한 것과 동일한 문제가 발생합니다. 어레이에 스마트 포인터를 사용해야 하는 경우 Boost 또는 unique_ptr<T[]> 전문화에서 scoped_array 또는 shared_array를 사용할 수 있습니다.
참조 카운팅 기능이 필요하지 않은 경우(대부분 배열의 경우), 가장 우아한 방법은 대신 STL 벡터를 사용하는 것입니다. 메모리 해제만 처리하는 것이 아니라 추가 기능도 제공합니다.
일반적인 실수 #4: 참조로 로컬 객체 반환하기
이것은 대부분 초보자의 실수이지만 이 문제로 고통받는 레거시 코드가 많기 때문에 언급할 가치가 있습니다. 프로그래머가 불필요한 복사를 방지하여 일종의 최적화를 수행하려는 다음 코드를 살펴보겠습니다.
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);"합계" 개체는 이제 로컬 개체 "결과"를 가리킵니다. 그러나 SumComplex 함수가 실행된 후 개체 "결과"는 어디에 있습니까? 아무데도. 스택에 위치했지만 함수가 반환된 후 스택이 풀렸고 함수의 모든 로컬 개체가 소멸되었습니다. 이것은 결국 기본 유형의 경우에도 정의되지 않은 동작을 초래합니다. 성능 문제를 피하기 위해 때때로 반환 값 최적화를 사용할 수 있습니다.
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);오늘날 대부분의 컴파일러에서 반환 라인에 개체의 생성자가 포함되어 있으면 코드는 불필요한 복사를 모두 방지하도록 최적화됩니다. 생성자는 "합" 개체에서 직접 실행됩니다.
일반적인 실수 #5: 삭제된 리소스에 대한 참조 사용
이러한 C++ 문제는 생각보다 자주 발생하며 일반적으로 다중 스레드 응용 프로그램에서 볼 수 있습니다. 다음 코드를 고려해 보겠습니다.
스레드 1:
Connection& connection= connections.GetConnection(connectionId); // ...스레드 2:
connections.DeleteConnection(connectionId); // …스레드 1:
connection.send(data);이 예에서 두 스레드가 동일한 연결 ID를 사용하면 정의되지 않은 동작이 발생합니다. 액세스 위반 오류는 종종 찾기가 매우 어렵습니다.
이러한 경우 둘 이상의 스레드가 동일한 리소스에 액세스할 때 리소스에 대한 포인터 또는 참조를 유지하는 것은 매우 위험합니다. 다른 스레드가 이를 삭제할 수 있기 때문입니다. 예를 들어 Boost의 shared_ptr과 같이 참조 카운팅과 함께 스마트 포인터를 사용하는 것이 훨씬 안전합니다. 참조 카운터를 늘리거나 줄이기 위해 원자 연산을 사용하므로 스레드로부터 안전합니다.
일반적인 실수 #6: 예외가 소멸자를 떠나도록 허용하기
소멸자에서 예외를 throw할 필요가 자주 있는 것은 아닙니다. 그럼에도 불구하고 더 나은 방법이 있습니다. 그러나 예외는 대부분 소멸자에서 명시적으로 throw되지 않습니다. 객체의 파괴를 기록하는 간단한 명령으로 인해 예외가 발생할 수 있습니다. 다음 코드를 고려해 보겠습니다.
class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }위의 코드에서 두 개체가 모두 소멸되는 경우와 같이 예외가 두 번 발생하면 catch 문은 실행되지 않습니다. 병렬로 두 개의 예외가 있기 때문에 동일한 유형이든 다른 유형이든 상관없이 C++ 런타임 환경은 이를 처리하는 방법을 모르고 종료 함수를 호출하여 프로그램 실행을 종료합니다.

따라서 일반적인 규칙은 예외가 소멸자를 떠나도록 허용하지 않는 것입니다. 추악하더라도 잠재적인 예외는 다음과 같이 보호되어야 합니다.
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}일반적인 실수 #7: "auto_ptr" 사용(잘못된)
auto_ptr 템플릿은 여러 가지 이유로 C++11에서 더 이상 사용되지 않습니다. 대부분의 프로젝트가 여전히 C++98에서 개발되고 있기 때문에 여전히 널리 사용됩니다. 그것은 아마도 모든 C++ 개발자에게 익숙하지 않은 특정 특성을 가지고 있으며 주의하지 않는 사람에게는 심각한 문제를 일으킬 수 있습니다. auto_ptr 객체를 복사하면 소유권이 한 객체에서 다른 객체로 이전됩니다. 예를 들어 다음 코드:
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error... 액세스 위반 오류가 발생합니다. 객체 "b"만 클래스 A의 객체에 대한 포인터를 포함하고 "a"는 비어 있습니다. 객체 "a"의 클래스 멤버에 액세스하려고 하면 액세스 위반 오류가 발생합니다. auto_ptr을 잘못 사용하는 방법에는 여러 가지가 있습니다. 그들에 대해 기억해야 할 매우 중요한 4가지 사항은 다음과 같습니다.
STL 컨테이너 내에서 auto_ptr을 사용하지 마십시오. 컨테이너를 복사하면 원본 컨테이너에 잘못된 데이터가 남게 됩니다. 일부 STL 알고리즘은 "auto_ptr"의 무효화로 이어질 수도 있습니다.
auto_ptr을 함수 인수로 사용하면 복사가 발생하므로 함수 호출 후 인수에 전달된 값을 유효하지 않은 상태로 두십시오.
auto_ptr이 클래스의 데이터 멤버에 사용되는 경우 복사 생성자 및 할당 연산자 내부에 적절한 복사본을 만들거나 이러한 작업을 비공개로 만들어 이러한 작업을 허용하지 않도록 하세요.
가능하면 auto_ptr 대신 다른 최신 스마트 포인터를 사용하세요.
일반적인 실수 #8: 무효화된 반복자와 참조 사용
이 주제에 대한 전체 책을 쓰는 것이 가능할 것입니다. 모든 STL 컨테이너에는 반복자와 참조를 무효화하는 몇 가지 특정 조건이 있습니다. 모든 작업을 사용하는 동안 이러한 세부 정보를 알고 있는 것이 중요합니다. 이전 C++ 문제와 마찬가지로 이 문제도 다중 스레드 환경에서 매우 자주 발생할 수 있으므로 이를 방지하기 위해 동기화 메커니즘을 사용해야 합니다. 다음 순차 코드를 예로 들어 보겠습니다.
vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element논리적인 관점에서 코드는 완전히 괜찮아 보입니다. 그러나 벡터에 두 번째 요소를 추가하면 벡터의 메모리가 재할당되어 반복자와 참조가 모두 유효하지 않게 되고 마지막 두 줄에서 액세스를 시도할 때 액세스 위반 오류가 발생할 수 있습니다.
일반적인 실수 #9: 값으로 객체 전달하기
성능 영향으로 인해 값으로 개체를 전달하는 것은 좋지 않다는 것을 알고 있을 것입니다. 많은 사람들이 추가 문자를 입력하는 것을 피하기 위해 그대로 두거나 나중에 최적화를 위해 다시 돌아올 생각을 합니다. 일반적으로 완료되지 않으며 결과적으로 성능이 저하된 코드와 예기치 않은 동작이 발생하기 쉬운 코드가 생성됩니다.
class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);이 코드는 컴파일됩니다. "func1" 함수를 호출하면 객체 "b"의 부분 복사본이 생성됩니다. 즉, 클래스 "A"의 객체 "b" 부분만 객체 "a"에 복사됩니다("슬라이싱 문제"). 따라서 함수 내부에서는 함수를 호출하는 누군가가 예상하지 못하는 클래스 "B"의 메서드 대신 "A" 클래스의 메서드도 호출합니다.
예외를 잡으려고 할 때 유사한 문제가 발생합니다. 예를 들어:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }"func2" 함수에서 ExceptionB 유형의 예외가 throw되면 catch 블록에서 catch되지만 슬라이싱 문제로 인해 ExceptionA 클래스의 일부만 복사되고 잘못된 메서드가 호출되고 다시 throw됩니다. 외부 try-catch 블록에 잘못된 예외가 발생합니다.
요약하면 항상 값이 아닌 참조로 개체를 전달하십시오.
일반적인 실수 #10: 생성자와 변환 연산자에 의한 사용자 정의 변환 사용
사용자 정의 변환도 때때로 매우 유용하지만 찾기가 매우 어려운 예측하지 못한 변환으로 이어질 수 있습니다. 누군가 문자열 클래스가 있는 라이브러리를 만들었다고 가정해 보겠습니다.
class String { public: String(int n); String(const char *s); …. }첫 번째 방법은 길이가 n인 문자열을 만드는 것이고 두 번째 방법은 주어진 문자를 포함하는 문자열을 만드는 것입니다. 그러나 문제는 다음과 같은 경우부터 시작됩니다.
String s1 = 123; String s2 = 'abc';위의 예에서 s1은 "123" 문자를 포함하는 문자열이 아니라 크기 123의 문자열이 됩니다. 두 번째 예에는 큰 따옴표 대신 작은 따옴표가 포함되어 있습니다(우연히 발생할 수 있음). 이는 첫 번째 생성자를 호출하고 매우 큰 크기의 문자열을 생성하는 결과를 낳습니다. 이것들은 정말 간단한 예이며 찾기가 매우 어려운 혼란과 예상치 못한 전환으로 이어지는 훨씬 더 복잡한 경우가 있습니다. 이러한 문제를 피하는 방법에는 2가지 일반적인 규칙이 있습니다.
암시적 변환을 허용하지 않으려면 명시적 키워드로 생성자를 정의하십시오.
변환 연산자를 사용하는 대신 명시적 대화 방법을 사용하세요. 조금 더 입력해야 하지만 읽기가 훨씬 더 깨끗하고 예측할 수 없는 결과를 방지하는 데 도움이 될 수 있습니다.
결론
C++는 강력한 언어입니다. 사실, 컴퓨터에서 매일 사용하고 사랑하게 된 많은 응용 프로그램은 아마도 C++를 사용하여 구축되었을 것입니다. 언어로서 C++는 객체 지향 프로그래밍 언어에서 볼 수 있는 가장 정교한 기능 중 일부를 통해 개발자에게 엄청난 유연성을 제공합니다. 그러나 이러한 정교한 기능이나 유연성은 책임감 있게 사용하지 않으면 많은 개발자에게 종종 혼란과 좌절의 원인이 될 수 있습니다. 이 목록이 이러한 일반적인 실수 중 일부가 C++로 달성할 수 있는 것에 어떻게 영향을 미치는지 이해하는 데 도움이 되기를 바랍니다.
Toptal 엔지니어링 블로그에 대한 추가 정보:
- C 및 C++ 언어를 배우는 방법: 궁극적인 목록
- C# 대 C++: 핵심은 무엇입니까?
