1.2.3. Lazy Fetching ( 늦은 가져오기) ¶
lazy evaluation에서 다룰 세번째의 주제로, 당신이 많은 필드로 이루어진 큰 객체들을 사용하는 프로그램을 가지고 있다고 상상해 봐라. 그런 객체들은 반드시 프로그램이 실행때 유지되며, 나중에는 데이터 베이스 안에 저장된어진다. 각각의 객체는 각 객체를 알아볼수 있고, 유일성을 보장하는 데이터 베이스로 부터 객체를 불러올때 종류를 알아 볼수 있는, 식별자(identifier)를 가지고 있다.(OODB 인가.) :
~cpp
class LargeObject { // 크고, 계속 유지되는 객체들
public:
LargeObject(ObjectID id); // 디스크에서 객체의 복구(부르기)
cosnt string& field1() const; // 필드상의 값1
int field2() const; // 필드상의 값2
double field3() const; // ...
const string& field4() const;
const string& field5() const;
...
};
자 그럼 디스크에서 복구(자료를 부르기)되어지는
LargeObject의 비용을 생각해 보자:
~cpp
void restoreAndProcessObject(ObjectID id) // 객체 복구
{
LargeObject object(id);
...
}
LargeObject 인스턴스들이 크기 때문에 그런 객체에서 모든 데이터를 얻는 것은, 만약에 특별히 데이터 베이스가 외부에 네크워크 상에서 자료 로드가 있다면 데이터 베이스의 작업은 비쌀것이다. 몇몇 상황에서 모든 데이터 베이스를 읽어들이는 비용은 필요가 없다. 예를 들어서 다음의 어플리케이션의 종류에 관하여 생각해 보자.
~cpp
void restoreAndProcessObject(ObjectID id)
{
LargeObject object(id);
if (object.field2() == 0) {
cout << "Object " << id << ": null field2.\n";
}
}
이런 경우에서는 오직 field2의 값만을 요구한다. 따라서 다른 필드를 로드하는 작업은 필요없는 작업이 되어 진다.
lazy 로의 접근에서 이런 문제는
LargeObject가 만들어 질때 디스크에서 아무런 데이터를 읽어 들이지 않는 것이다. 대신에 오직 객체의 "껍데기"(shell)만 만들어 주고, 데이터는 객체 내부에서 특정 데이터를 필요로 할때만 데이터 베이스에서 데이터를 복구하는 것이다. 여기 그런 관점에서 "damand-paged" 방식으로 객체 초기화를 적용한 방법이 있다.
~cpp
class LargeObjectP
pulic:
LargeObject(ObjectID id);
const string& field1() const;
int field2() const;
double field3() const;
const string& field4() const;
...
private:
ObjectID oid;
mutable string *field1Value; // 앞으로의 "mutable"에 관한 토론을 보라
mutable int *field2Value;
mutable double *field3Value;
mutable string *field4Value;
...
};
LargeObject::LargeObject(ObjectID id):oid(id), field1Value(0), field2Value(0), field3Value(0), ...
{}
const string& LargeObject::field() const
{
if (field1Value == 0){
field1의 데이터를 읽기 위하여 데이터 베이스에서 해당 데이터를 가지고 와서
field1Value 가 그것을 가리키게 한다.
}
return *field1Value;
}
객체의 각 필드는 필요한 데이터의 포인터로 표현되어 있고,
LargeObject의 생성자는 null로 초기화 된다. 그런 null 포인터는 아직 데이터 베이스에서 해당 필드의 정보를 안읽었다는 걸 의미한다. 데이터를 접근하기 전에
LargeObject의 각 멤버 함수는 반드시 이 필드의 포인터를 검사한다. 만약 포인터가 null이라면 데이터를 사용하기 전에 반드시 데이터 베이스에서 읽어 온다.
lazy fetching을 적용 하면, 당신은 반드시 field1과 같은 const멤버 함수를 포함하는 어떠한 멤버 함수에서 실제 데이터 포인터를 초기화하는 과정이 필요한 문제가 발생한다.(const를 다시 재할당?) 하지만 컴파일러는 당신이 const 멤버 함수의 내부에서 데이터 멤버를 수정하려고 시도하면 까다로운(cranky) 반응을 가진다. 그래서 당신은 "좋와, 나는 내가 해야 할것을 알고있어" 말하는 방법을 가지고 있어야만 한다. 가장 좋은 방법은 포인터의 필드를 mutable로 선언해 버리는 것이다. 이것의 의미는 어떠한 멤버 함수에서도 해당 변수를 고칠수 있다는 의미로, 이렇게 어떠한 멤버 함수내에서도 수행할수 있다. 이것이
LargeObject안에 있는 필드들에 mutable이 모두 선언된 이유이다.
mutable 키워드는 최근에 C++에 추가되어서, 당신의 벤더들이 아직 지원 못할 가능성도 있다. 지원하지 못한다면, 당신은 또 다른 방법으로 컴파일러에게 const 멤버 함수 하에서 데이터 멤버들을 고치는 방안이 필요하다. 한가지 가능할 법인 방법이 "fake this"의 접근인다. "fake this"는 this가 하는 역할처럼 같은 객체를 가리키는 포인터로 pointer-to-non-const(const가 아닌 포인터)를 만들어 내는 것이다. (
DeleteMe 약간 이상) 당신이 데이터 멤버를 수정하기를 원하면, 당신은 이 "fake this" 포인터를 통해서 수정할수 있다.:
~cpp
class LargeObject {
public:
const string& field1() const; // 바뀌지 않음
...
private:
string *field1Value; // mutable로 선언되지 않았다.
... // 그래서 과거 컴파일러는 이걸 허용한다.
};
const string& LargeObject::field1() const
{
// 자 이것이 fake This 인데, 저 포인터로 this에 대한 접근에서 const를 풀어 버리는 역할을 한다.
// LargeObject* 하고 const가 뒤에 붙어 있기 때문에 LargeObject* 자체는 const가 아닌 셈이다.
LargeObject * const fakeThis = const_cast<LargeObject* const>(this);
if( field1Value == 0){
fakeThis->field1Value = // fakeThis 가 const가 아니기 때문에
데이터베이스에 접근해서 // 이러한 시도는 합당하다.
포인터를 넘기는 부분
}
return * field1Value;
}
이 함수는 *this의 constness성질을 부여하기 위하여 const_cast(Item 2참고)를 사용했다.만약 당신이 const_cast마져 지원 안하면 다음과 같이 해야 컴파일러가 알아 먹는다.
~cpp
const string& LargeObject::field1() const
{
LargeObject * const fakeThis = (LargeObject* const)(this);
...
}
자, 그럼 다시 한번
LargeObject내의 포인터들에 관하여 생각해 보자. 사용하기전에 각각의 포인터들을 검사하는 것에 비해서, 모든 포인터들이 null로 초기화 되어 있는것은 에러의 가능성을 가지고 있다. 다행히도, 이런 우려는 Item28의
smart pointers의 이용으로 편이성을 제시한다. 만약
LargeObject내부에서 smart pointer를 사용한다면 당신은 아마도 더이상 포인터를 mutable하게 선언할 필요가 없을것이다. 당신이 mutable을 필요로 하는 상황이, smart pointer클래스들의 적용으로 가기 때문에 위의 내용은 좀 임시적인것이다. 이런 문제에 관해 한번 생각해 봐라
- Lazy Expression Evaluation ( 표현을 위한 게으른 연산 )
lazy evaluation이 가지고 오는 마지막 예제로는 바로 숫치 연산 어플리케이션들이 문제를 가지고 왔다. 다음을 코드를 보자
~cpp
template<class T>
class Matrix { ... };
Matrix<int> m1(1000, 1000); // 굉장히 큰 int형 1000x1000배열을 선언한거다.
Matrix<int> m2(1000, 1000);
...
Matrix<int> m3 = m1 + m2; // 그리고 그 둘을 더한다 굉장한 연산이 필요해진다.
보통 operator+에 대한 구현은 아마
eager evaluation(즉시 연산) 이 될것이다.;이런 경우에 그것은 아마 m1과 m2의 리턴 값을 대상으로 한다. 이 계산(1,000,000 더하기)에 적당한 게산양과, 메모리 할당에 비용 이 모드것이 수행되어져야 함을 말한다.
lazy evaluaion 방법에서는 저건 너무 엄청난 수행을 하는 방법이라 하고, 그래서 그것을 수행하지 않는다. 대신에 m3내부에 m1과 m2의 합을 했다는 것만을 기럭해 둔다. 그런 자료 구조는 아마도 m1과 m2나 그이상의 더하기를 하기 위한 포인터 외에는 아무런 정보를 유지할 필요가 없을 것이다. 명백히 이건 m1,m2에 대한 실제 더하기보다 훨씬 빠르고 말할것도 없이 훨씬 적은 메모리를 사용할 것이다.
프로그램이 m3이후에 다음과 같은 짓(이건 예제가 열받게해서 이렇게 쓴다.)을 저지른다.
~cpp
Matrix<int> m4(1000, 1000);
... // 아까 위에서 했던 m4에 어떠한 값을 넣는 코드들이다.
m3 = m4 * m1;
우리는 이제 아까 m1, m2의 합인 m3를 잃어버렸다.( 그리고 이건 합의 계산 비용을 줄인다는 의미도 된다.) 그것에는 m4,m1의 곱이 새로운 값으로 기억되어 진다. 말할 필요도 없이 이제 곱은 수행안하는 거다. 왜냐? 우리는 lazy 하니까. ~
사실 이건 멍청한 프로그래머가 두 행렬의 합을 계산하고, 그것을 사용하지 않아서 얻은 이점을 노린 억지로 만들어낸 예제 같이 보인다. 멍청한 프로그래머는 필요도 하지 않은 계산을 수행한 것이다. 하지만 유지보수 중에 보면, 이런 필요없는 계산을 이행하는 수행코드는 그리 희귀하지는 않다.
하지만, lazy evaluation이 치룬 시간이 오직 저런 상태일 뿐이라면, "엄청난 계산을 요구한다"라는 문제가 더 커질것이라고 생각하기는 어렵다.의 필요성이 좀더 일반적인 시나리오는 우리가 오직 계산에서의
일부가 필요한 경우이다. 예를 들자면 우리가 m3를 m1과 m2의 합으로 초기화 했다고 가정하고 다음과 같은 코드가 있다면
~cpp
cout << m3[4]; // m3의 4번째 열만을 요구한다.
확실히 우리는 이제 lazy 상태를 벗어나야 함을 알수 있다.-우리는 m3의 4번째 열에 대하여 계산된 값을 가지고 있어야 한다. 그러나, 역시나 너무나 지나친 것이다. 우리는 m3의 4번재 열을 계산해야만 하는건 말할 필요도 없다.:m3는 그것이 필요해지기 전까지는 계산할필요가 없다. 하지만 행운으로 그렇게 할 필요가 없을것이다.
DeleteMe ) 내용이 이상하다. 보강 필요
어떻게 행운이냐구? 행렬 계산의 분야에 대한 경험이 우리의 이러한 코드에 대한 노력에 가능성을 준다. 사실 lazy evaluation은 APL이라는 것에 기초하고 있다. APL은 1960년대에 상호 작용의(interactive) 쓰임을 위하여 행렬 계산이 필요한 사람들에 의하여 개발된 것이다. 현재보다 떨어진 수행능력을 가진 컴퓨터에서 APL은 더하고, 곱하고, 심지어 커다란 행렬을 직접 나눈는 것처럼 보이게 하였다. 그것에는 lazy evaluation이라는 방법이었다. 그 방법은 일반적으로 보통 효율적이었다. 왜냐하면 APL 사용자가 보통 더하고, 곱하고 나누는 것을 그것의 행렬의 조각들을 필요로 하고, 전체의 결과가 필요하기 전까지 수행하지 않는다. APL 은 lazy evaluation을 사용해서 행렬상의 결과를 정확히 알 필요가 있을때까지 게산을 지연시킨다. 그런 다음 오직 필요한 부분만을 계산한다. 실제로 이것은 과거 열악한 컴퓨터의 능력하에서 사용자들이 계산 집약적인(많은 행렬 계산을 요하는) 문제에 관하여 상호적으로(결과값과 수행 식간에 필요 값을 위해서 최대한 실제 연산을 줄여나가게) 수행된다.현재의 기계도 빨라졌지만, 데이터들이 커지고, 사용자들은 참을성이 줄어들기 때문에 요즘에도 이런 lazy evaluation의 장점을 이용한 행렬 연산 라이브러리를 사용한다.
laziness(게으름)은 때로 시간을 아끼는대 실패한다. m3가 이런식으로 쓰이면:
~cpp
cout << m3; // m3의 모든것을 찍는다.
뭐 끝났다. m3를 위한 모든 값을 가지고 있어야 한다. 비슷하게 m3가 의존하는 행렬들중에 수정되는것 이 있어도, 즉시 계산을 필요로 한다.
~cpp
m3 = m1 + m2; // m3가 m1,m2의 합인걸 기억하라
m1 = m4; // 이제 m3는 m2와 과거 m1의 합이 된다.
그러므로 몇가지의 m1에 대한 할당이 m3를 변화시키지 않는다는 확신을 가지고 있어야 한다. Matrix<int>의 내부에 할당된 operator 내부에 m3의 값이 m1의 계산 이전에 계산되어 있거나, m1의 과거 값에 대한 복사본을 가지고 있고 m3는 그것에 의존해야 한다. 다른 함수들도 이러한 행렬의 변경을 위하여 다른 형식의 함수들도 이런 비슷한 것을 감안해야 할것이다.
각 값 간의 의존성과,;데이터 구조의 유지를 위하여, 값들, 의존성이나 두가지의 결합 방법을 저장해야 한다.; 그리고 많은 수치 계산이 필요한 분야에서 복사, 더하기 할당, 같은 operator의 overload 것이 필요하다. 반면에 lazy evaluation의 적용은 프로그램 실행중에서 정말 많은 시간들과 많은 자원들을 아낄수 있다. 그래서 lazy evaluation의 존재를 정당화 시켜 줄것이다.
이런 네가지의 예제는 lazy evaluation이 다양한 영역에서 활용될수 있음을 시사한다.:필요없는 객체의 복제 피하기, operator[]에 읽기와 쓰기를 구분하기, 데이터 베이스 상에서의 필요없는 자료 읽기를 피하기, 필요없는 수치 계산을 피하기. 그럼에도 불구하고 그것은 정말 훌륭한 생각만은 아니다. 단지 해치워야 할일을 미루어서 처리하는 것이기 때문에 만약에 당신의 부모가 계속 감시를 한다면 당신은 계속 그런 일들을 해주어야 한다. 마찬가지로 프로그램 상에서도 모든 연산들이 필요하다면 lazy evaluation은 어떠한 자원의 절약도 되지 않는다. 거기도 만약 당신의 모든 계산이 반드시 필요한 중요한 것들이라면, lazy evaluation은 아마 처음에 허상 데이터들과 의존 관계같은 것의 처리를 위하여, 실제보다 더 많은 연산을 하게되어 수행 속도를 느리게 할것이다. lazy evaluation은 오직 당신의 소프트웨어 상에서 피할수 있는 계산이 존재할 때만 유용히 쓰일수 있다.
C++에 알맞는 lazy evaluation은 없다. 그러한 기술은 어떠한 프로그래밍 언어에도 적용 될수 있다. 그리고 몇몇 언어들-APL, 몇몇 특성화된 Lisp, 가상적으로 데이터 흐름을 나타내는 모든 언어들-는 언어의 한 중요한 부분이다. 그렇지만 주요 프로그래밍, C++같은 언어들은 eager evaluation를 기본으로 채용한다. C++에서는 사용자가 lazy evaluation의 적용을 위해 매우 적합하다. 왜냐하면 캡슐화는 클라이언트들을 꼭 알지 못해도 lazy evaluation의 적용을 가능하게 만들어 주기 때문이다.
이제까지 언급했던 예제 코드들을 다시 한번 봐라 당신은 클래스 인터페이스만이 주어진다면 그것이 eager, lazy인지 알수는 없을 것이다. 그것의 의미는 eager evaluation도 역시 곧바로 적용 가능하고, 반대도 가능하다는 의미이다. 만약, 연구를 통해서 클래스의 구현에서 병목 현상을 보이는 부분이 보인다면, 당신은 lazy evaluation의 전략에 근거한 코드들을 적용 할수 있을 것이다. 당신의 클라이언트들은 이러한 변화가 성능의 향상으로 밖에 보이지 않는다. 고객(클라이언트들)들이 좋와하는 소프트웨어 향상의 방법, 당신이 자랑스로워하는 lazy가 될수 있다. (
DeleteMe 모호)