스토리텔링 개발자

[More Effective C++] 17. 지연 평가 본문

개발/More Effective C++

[More Effective C++] 17. 지연 평가

김디트 2024. 8. 22. 11:28
728x90

항목 17. 효율 향상에 있어서 지연 평가는 충분히 고려해 볼 만하다

 

 

 

지연(to be lazy)
  • 효율의 측면에서 볼 때, 최선의 계산은 아무것도 하지 않는 것이다.
  • 그렇다면 정말 필요할 때가 되기 전까지 코드 실행을 지연시키는 것이 효율적일 것이다.
  • 일반적으로 자주 보게 될만한 일반적인 지연 평가 네 가지를 아래에서 살펴본다.

 

 

 

참조 카운팅(Reference Counting)
class String { ... };

String s1 = "Hello";
String s2 = s1; // String의 복사 생성자를 호출한다.
// String 복사 생성자가 직관적인 구현이라면,
// s2는 s1과 "Hello" 사본을 동시에 들고 있을테지만.. 이는 비용이 크다.
  • 직관적인 구현 (즉시 평가(eager evaluation))
    • 복사 생성자가 호출되지마자 메모리 할당, 데이터 복사 등을 바로 처리한다.
  • 하지만, 복사 생성자는 실행 비용이 있다.
    1. new 연산자를 통해 힙 메모리를 할당
    2. strcpy로 s1의 데이터를 s2의 힙 메모리로 복사
  • 효율을 고려한 구현 (지연 평가(lazy evaluation))
    • s2는 아직 어디에서도 사용되지 않았으므로 아직 자신의 메모리를 가질 필요가 없다.
    • 즉, 필요할 때 메모리를 가지도록 한다.
    • 위의 사례의 경우 문자열 값이 수정될 때가 바로 더 이상 지연할 수 없을 때라고 할 수 있다.
    • 아래와 같이 처리하여 지연 평가한다.
      1. s2에 s1의 값을 공유하도록 한다.
      2. 문자열 값이 수정될 때, 수정 대상에 대한 문자열 사본을 할당하도록 한다.
// 이 경우 아직 할당이 필요하지 않다.
cout << s1;
cout << s1 + s2;

// 이 경우 할당이 필요하다.
// s2에 새로운 문자열 할당. 허나 s1은 유지.
s2.convertToUpperCase();

 

 

 

데이터 읽기, 쓰기 구분
String s = "Homer's Iliad"; // s는 참조 카운팅으로 운영되는 문자열이라고 가정.
...
cout << s[3]; // s[3]을 읽기 위해 operator[]를 호출한다.
s[3] = 'x'; // s[3]에 쓰기 위해 operator[]를 호출한다.
  • 참조 카운팅으로 유지되는 문자열은 읽기는 효율적이지만, 쓰기에는 불편하다.
  • 그렇다면, operator[]가 읽기일 때, 쓰기일 때 다른 동작이 수행되도록 하자.
    • 허나, operator[]에서는 읽기인지 쓰기인지 동작을 구별할 방법이 없다.
  • 지연 평가 + 프록시 클래스를 사용해서 해결할 수 있다.(항목 30 참조)

 

 

 

데이터 가져오기 지연(Lazy Fetching)
// 계속 지속되어야 하는 '아주 큰' 객체
class LargeObject
{
public:
    LargeObject(ObjectID id); // 각각은 유일 식별자를 가진다.
    
    // 다양한 필드값들
    const string& field1() const;
    int field2() const;
    double field3() const;
    const string& field4() const;
    const string& field5() const;
    ...
};

void restoreAndProcessObject(ObjectID id)
{
    LargeObject object(id); // 객체를 가져온다.
    // 헌데... 인스턴스는 매우 크기 때문에 가져오는 데 꽤나 시간이 걸린다.
    
    // 하지만 아래의 경우 데이터를 모두 일일이 읽어오지 않아도 상관이 없다!
    // field2 값만 필요하기 때문이다.
    if(object.field2() == 0)
    {
        cout << "Object " << id << ": null field2.\n";
    }
}
  • 위의 경우 LargeObject 객체를 생성할 때 모든 데이터를 읽지 않도록 지연시킬 수 있다.
    • 객체의 인터페이스만 구현하고, 필요한 데이터는 필요할 때마다 데이터베이스에서 뽑아오도록 한다.
    • 요구기반(demand paged) 방식의 객체 초기화 방법
class LargeObject
{
public:
    LargeObject(ObjectID id);
    
    const string& field1() const;
    int field2() const;
    double field3() const;
    const string& field4() const;
    ...
private:
    ObjectID oid;
    
    mutable string* field1Value;
    mutable int* field2Value;
    mutable double* field3Value;
    mutable string* field4Value;
    ...
};

LargeObject::LargeObject(ObjectID id) : oid(id), field1Value(nullptr) ....
{}

const string& LargeObject::field1() const
{
    if(field1Value == nullptr)
    {
        DB에서 필드 1의 데이터를 읽고, field1Value가 DB의 값을 포인터로 가리키게 한다.
    }
    
    return *field1Value;
}
  • 위 코드에서 주의할 점
    • 필드 포인터는 멤버 함수 내에서 실제 데이터를 가리키게 수정되어야 한다.
    • 하지만.. const 멤버 함수는 '보통의' 클래스 데이터 멤버를 수정할 수 없다.
  • 해결 방법
    1. 변수를 mutable로 선언
      • 이 데이터는 어떤 멤버 함수에서도 수정 가능하다는 뜻이다.
    2. this 흉내내기(fake this)
      • 상수성이 제거된 this를 사용하여 해결한다.
      • class LargeObject
        {
        public:
            const string& field1() const; // 인터페이스는 그대로이다.
            ...
        private:
            string* field1Value;
            ...
        };
        
        const string& LargeObject::field1() const
        {
            // 상수성을 제거한 fake this 생성
            LargeObject* const fakeThis = const_cast<LargeObject* const>(this);
            
            if(field1Value == nullptr)
            {
                // fakeThis가 가리키는 객체는 상수 객체가 아니므로 문제가 발생하지 않는다.
                fakeThis->field1Value = DB에서 가져온 포인터
            }
            
            return *field1Value;
        }
    3. 멤버 변수를 스마트 포인터로 대체 (항목 28 참조)
      • 포인터를 일일이 널로 초기화하고, 사용 전에 널 체크하고.. 등의 번거로운 작업을 제거할 수 있다.

 

 

 

표현식 평가 지연(Lazy Expression Evaluation)
template<typename T>
class Matrix { ... }; // 균등 행렬(homogeneous matrices)

Matrix<int> m1(1000, 1000); // 1000 x 1000
Matrix<int> m2(1000, 1000); // 1000 x 1000
...
Matrix<int> m3 = m1 + m2; // m1과 m2를 더한다.
// 여기서 operator+의 동작이 쟁점.
// 1,000,000번의 덧셈이 발생한다!!
  • "m3의 값은 m1, m2의 합이다" 라는 표시만 해두고, 필요할 때까지 덧셈을 지연시키자.
Matrix<int> m4(1000, 1000);
... // m4에 몇 개의 값을 준다.
m3 = m4 * m1; // m3는 덧셈해서 할당할 필요조차 없는 코드였다!
// m4 * m1 역시 플래그 처리만 해두고 계산을 지연시켜둔다.
  • 계산이 필요한 순간이 오긴 온다.
    • 가장 빈번한 시나리오는 일부분만 계산하는 것.
cout << m3[4]; // m3의 네 번째 행을 콘솔에 출력한다.
// m3의 네 번째 행은 계산되어야 한다.
// 나머지는 여전히 계산을 지연시킬 수 있다.
  • 물론 모두 사용된다면, 더 이상 할인 혜택은 없다.
cout << m3; // 이제는 미룰 수 없다. 모두 계산되어야 한다.
  • 또한, 예외 상황들을 잘 고려해야 한다.
m3 = m1 + m2;
m1 = m4; // 이제 m3는 (이전 m1) + m2 가 되어야 한다!!
  • 위 아이디어는 APL이라는 언어에 그 뿌리를 두고 있다.
    • 행렬 기반 계산을 대화식으로 할 수 있도록 개발된 1960년 언어
    • 그 당시 컴퓨터 성능은 매우 구닥다리였는데, 아주 큰 행렬의 덧셈, 곱셈, 나눗셈까지도 바로 처리했다.
  • 수치 계산에 있어서는 지연 평가 구현에 적지 않은 품이 들어간다는 점을 생각하고 트레이드 오프를 잘 계산해야 한다.

 

 

 

결론
  • 모든 계산 경과를 즉시 사용해야 하는 경우가 잦다면 지연 평가는 그다지 유용하지 않다.
    • 심지어 더 많은 자원을 사용하는 꼴이 된다.
  • 프로파일링 중 병목현상을 발견하면, 즉시 평가에서 지연 평가로 수정하기 용이하다.
    • 인터페이스에는 즉시 평가인지 지연 평가인지 표가 나지 않기 때문이다.
728x90
Comments