C++ 효율 향상 시키기 (feat. 임시 객체)

본 포스트는 More Effective C++ item 19 ~ 22 를 참고하여 작성함.

임시 객체의 origin을 이해하자


임시 객체 (Temporaries) ?

template<class T>
void swap(T& object1, T& object2)
{
	T temp = object1;
	object1 = object2;
	object2 = temp;
}

여기에 있는 이 temp 말하는거 아님!!

HON MONO 임시 객체는 코드에도 없고, 눈에 보이지 않음
heap이 아닌 곳에 생기는 un-named object 인데.... 언제 생기냐?

1. When implicit type conversions (암시적 타입 변환) are applied to make function calls succeed.

size_t countChar(const string& str, char ch);

char buffer[MAX_STRING_LEN];
countChar(buffer, c)

bufferchar* -> string 으로 타입 변환이 일어날 때, compiler가 친히 string 임시 객체를 만들어 줌.

그러나.....

void uppercasify(string& str); // str을 전부 대문자로 바꿈

char subtleBookPlug[] = “Effective C++”;
uppercasify(subtleBookPlug); // ERROR!

subtleBookPlugchar* -> string 가 안되고 컴파일 에러가 남.
왜냐하면, 생성된 임시객체에 수정을 가하는 것이 원 객체에 영향을 미쳐야 하기 때문!

reference-to-non-const는 안됨!!

2. When functions return (함수 반환) objects.

const Number operator+(const Number& lhs,
		           const Number& rhs);

a+b 결과물이 임시객체로 생성되어서 c로 복사됨.
반환값이 이름이 없으니 어쩔수 없음

어쨋든 문제는 계속 임시객체가 생기면서 비용이 증가함.
어디서 생성되는지 통찰하고, 어떻게 최소한으로 할 것인지 이제 소개함!

이게 책의 내용인데..... 실제로는 이러한 임시객체가 생기지 않는 것으로 보임.
진짜로 생기는지 의문이 들어서 직접 테스트 해본 결과는 맨 아래(테스트 결과)에 있음.

Return Value Optimization 활용하기

const Rational operator*(const Rational& lhs,
		          const Rational& rhs);

이렇게 객체를 값으로 반환하는 것은 효율을 생각하면 개똥임. 맨날 임시객체가 생기니까...
그럼 어케 최적화하지...

흐음....

ㅠㅠㅠㅠㅠ

포인터?!

const Rational* operator*(const Rational& lhs,
		          const Rational& rhs);

포인터로 반환하면 임시객체가 안생기지~

그러나,

이걸 써서 코딩하면???

Rational a = 10;
Rational b (1,2);
Rational c = * (a * b); 

부자연스럽네 ㅠㅠ

시무룩....

그렇다면,

레퍼런스?!

const Rational& operator*(const Rational& lhs,
		          const Rational& rhs);

레퍼런스로 반환해도 임시객체가 안생기지~

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
	Rational result(lhs.numerator() * rhs.numerator(),
	lhs.denominator() * rhs.denominator());
	return result;
}

문제는 result가 저 함수 끝나고 스택에서 지워져서 못써먹음 ㅠㅠㅠ

value를 리턴하는거는 어쩔수 없구만! 흑흑

그러면, 그 cost라도 줄여야겟어!

const Rational operator*(const Rational& lhs,
                                const Rational& rhs)
{
	return Rational(lhs.numerator() * rhs.numerator(),
		         lhs.denominator() * rhs.denominator());
}

이게 정답이라는데 왜 일까요?

Rational c = a * b; 실행시,

컴파일러operator* 안에서 만드는 임시객체와 반환하는 임시객체를 전부 없애고 return expression을 Rational c에 할당된 메모리 안에 직접 초기화 해줌.

컴파일러 짱짱맨!

inline const Rational operator*(const Rational& lhs,
                                const Rational& rhs);

이렇게 inline 붙이면 call하는 overhead 도 줄일 수 있음!

결론: values return 때에는 constructor를 사용하자

오버로딩으로 암시적 타입변환을 막아보자

class UPInt { // 무한을 품은 인트
public:
	UPInt();
	UPInt(int value);
	...
};

const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
UPInt upi1, upi2;

...

UPInt upi3 = upi1 + upi2;
upi3 = upi1 + 10;
upi3 = 10 + upi2;

upi3 = 10 + upi2; 요런데서 10 을 UPInt로 암시적 타입변환을 함.
const UPInt operator+(const UPInt& lhs, const UPInt& rhs); 이거를 쓰려고, 임시객체가 자꾸자꾸 생기는 현상이 발생 ㅠㅠ

const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
const UPInt operator+(const UPInt& lhs, int rhs);
const UPInt operator+(int lhs, const UPInt& rhs);
const UPInt operator+(int lhs, int rhs);

이렇게 다 만들어버리면 임시객체 안생기지롱~~

신나게 오버로딩하다보니 주화입마에 빠져버렷다ㅠㅠ

저기에는 x맨이 끼어있음.

누굴까요??

잡앗다 요놈!

const UPInt operator+(int lhs, int rhs);

컴파일에러임.

왜냐면, '오버로딩되는 연산자 함수는 반드시 최소한 한 개의 사용자 정의 타입을 매개변수로 가져야한다' 라는 규칙이 있음.

어쨌든, 80-20 룰에 따라 많이 불려야 하는 놈이면 이런걸 하는 것도 좋은 방법이다!

"=" 말고 "+=" 를 고려하자

x = x + y;
x = x - y;

이게 된다면!

x += y;
x -= y;

이것도 되는게 프지상정!!
(책에 있는 표현임. 내가 만든거 아님.)

구현을 해보자.

class Rational {
public:
	...
	Rational& operator+=(const Rational& rhs);
};

// operator+ 는 operator+=를 이용하여 구현
const Rational operator+(const Rational& lhs,
		          const Rational& rhs)
{
	return Rational(lhs) += rhs;
}

이 때,
Rational& operator+=(const Rational& rhs); 는 레퍼런스로 반환이니까, 임시객체가 안생기고 (!!),
const Rational operator+(const Rational& lhs, const Rational& rhs) 는 값을 반환하니까 임시객체가 생김.

그럼, += 가 좋은거네!

하지만...

result = a + b + c + d; 이렇게 쓰면 될 놈이,
result = a; result += b; result += c; result += d; 이렇게 써야함 ㅠㅠㅠㅠ

장단이 있음.

그리고, 구현을 할 때는

const Rational operator+(const Rational& lhs,
		          const Rational& rhs)
{
	T result(lhs); // lhs를 result에 copy
	return result += rhs;
}

이렇게 하지 말고,

const Rational operator+(const Rational& lhs,
		          const Rational& rhs)
{
	return Rational(lhs) += rhs;
}

이렇게 해야 compiler optimization이 잘 된다! 아까 배운거임.

  1. 일반적으로 op= 형태가 op 보다 효율적
  2. 라이브러리 설계자는 두 형태 모두 제공해야 하겠다
  3. 효율이 우선인 개발자는 op 형태가 아닌 op= 형태를 쓰는 게 좋다

끝낼라고 했는데, 실제 테스트 해보니 위의 내용이 틀렸다고 생각 됨. 아래 단락 참조할 것!

테스트 결과

책에서는 value로 return 할 때, 임시객체가 만들어 진다고 하였다. 스택에 있던게 옮겨가니 그런가 보다 했는데, 또 return을 생성자로 하면 optimization 된덴다. 흐음... 이게 좀 이상한거 같아서 테스트를 해보았다. 요즘 컴파일러는 알아서 optimization이 잘 되는 거 같다.

테스트 환경

gcc version 7.3.1 20180303 (Red Hat 7.3.1-5) (GCC)

테스트 코드

  • class Number 정의
class Number{
public:
  Number(){
    n_ = 0;
  };
  Number(int n){
    n_ = n;
  };
  ~Number(){
  };
  Number(const Number& other){
    n_ = other.n_;
  };
  int get() const {
    return n_;
  };
  Number& operator=(const Number& other){
    n_ = other.n_;
  }
private:
  int n_;
};
  • main 코드.
int main(){

  Number a(1);
  Number b(2);
  Number c;

  c = a*b;

  cout<<c.get()<<endl;

  return 0;
}
  1. 최적화가 안될 operator. 책에서 말하는 안좋은 예시.
const Number operator*(const Number& lhs, const Number& rhs){
  Number result(lhs.get()*rhs.get());
  return result;
};
  1. 최적화가 된 operator. 책에서 좋은 예시.
const Number operator*(const Number& lhs, const Number& rhs){
  return Number(lhs.get()*rhs.get());
};

1번의 경우에 c = a*b; 수행시 a*b의 결과가 c로 복사될때 임시객체가 생겼다가 없어져야 하고, 2번의 경우에는 그렇지 않아야 한다. 실제로 그런지 한번 보자.

아래 asm 창에서 보다 시피, operator * 이후에 operator =, ~Number()가 불린다. operator * 결과 반환을 위해 임시 객체는 생성되지 않는다.

opeator* 내부에서도 result 객체 생성자 이외에 다른 생성자가 불리지 않는다.
그렇다면, stack 에 생성된 result 는 어떻게 없어지지 않고 위로 올라갓다가 다시 operator=으로 전달이 되는가??

main의 stack. operator*를 call하는 시점.

$rbp = 0x7fffffffdbe0, $rsp = 0x7fffffffdbc0 이다.

다음은 operator*의 stack. result를 반환하는 시점.

$rbp = 0x7fffffffdbb0, $rsp = 0x7fffffffdb80 이다.
result 객체의 주소는 0x7fffffffdbcc. 이 스택에 생긴다.

다음은 다시 main 스택. operator= 을 부르는 시점.

$rbp = 0x7fffffffdbe0, $rsp = 0x7fffffffdbc0 이다.
여기서, result가 아직 지워지지 않았음. 분명히 스택을 빠져 나왔는데!!

다음은 operator=의 stack. cresult를 대입하는 시점.

$rbp = 0x7fffffffdbb0, $rsp = 0x7fffffffdba0 이다.
result.n_$edx 에 로드해서, ($rax) = c.n_ 에 대입함.

그리고 나서, result의 destructor 가 호출됨.

  • 참고: 그림 잘 못 그렷네 ㅠㅠ 저 result 는 main stack 안에 잡혀있음. 위치가 틀림.

이와 같은 상황으로, 중간에 stack을 나가는데도, result를 지우지 않음.

결론은, 1번의 경우에 임시객체가 생성 되지 않음.

신기하구만!