메모리의 구조는 1B 크기로 나뉨

CPU는 메모리에 있는 내용을 가져오거나 작업 결과를 메모리에 저장하기 위해 메모리 주소 레지스터(MAR)를 사용

메모리는 유일한 작업 공간이며 운영체제를 포함한 모든 프로그램은 메모리에 올라와야 실행이 가능하기 때문에 메모리 관리가 복잡

 

메모리 관리 이중성 : 프로세스는 메모리를 독차지하려 하고, 메모리 관리자 입장에서는 메모리 관리를 효율적으로 하고 싶어 하는 것

 

소스코드의 번역과 실행

언어 번역 프로그램은 고급 언어(C, 자바 등)으로 작성한 소스코드를 컴퓨터가 실행할 수 있는 기계어로 번역하는 프로그램

  • 컴파일러 : 소스코드를 컴퓨터가 실행할 수 있는 기계어로 번역한 후 한꺼번에 실행, C/자바 등
  • 인터프리터 : 소스코드를 한 행씩 번역하여 실행, 자바스크립트/베이직 등


컴파일 과정

  1. 컴파일 : 컴파일러가 소스 코드를 오류 점검, 최적화 등을 수행하여 object 코드(기계어)로 번역됨
  2. 링크 : object 코드에 라이브러리 코드를 삽입하여 최종 실행 파일을 만듦
    • 라이브러리의 내부 구현을 동적 라이브러리에서 가져와 실행하여 라이브러리가 변경돼도 컴파일하지 않고 라이브러리를 공유하여 메모리 절약

 

메모리 관리자(MMU, Memory Manage Unit)의 역할

프로세스와 데이터를 메모리로 가져오는 작업(fetch)

가져온 프로세스와 데이터를 메모리의 어떤 부분에 올려놓을지 결정하는 작업(placement)

꽉 차 있는 메모리에 새로운 프로세스를 가져오기 위해 오래된 프로세스를 내보내는 작업(replacement)

 

CPU의 비트는 한 번에 다룰 수 있는 데이터의 최대 크기를 의미하여, 내부 부품이 모두 이 비트를 기준으로 제작됨

메모리의 주소를 지정하는 메모리 주소 레지스터(MAR)의 크기가 CPU의 비트와 같으므로 32비트 CPU의 경우 2^32개(4GB), 64비트 CPU의 경우 2^64개(1천 6백만TB)

 

설치된 메모리의 주소 공간을 물리 주소 공간(절대 주소)이라고 하며, 프로세스마다 부여되는 0번지부터 시작하는 주소 공간은 논리 주소 공간(상대 주소)
CPU와 프로세스가 사용하는 주소 체계는 논리 주소이므로 중복 물리 주소는 없지만 중복 논리 주소는 가능

 

운영체제는 시스템을 관리하는 중요한 역할을 하기 때문에 사용자가 운영체제를 침범하지 못하도록 메모리 관리자는 분리하여 관리

상위 메모리부터 운영체제 메모리 방향으로 사용자 영역을 할당하는 방법은 운영체제의 크기에 상관없이 사용자 영역의 시작점을 결정할 수 있으나 메모리를 거꾸로 사용하기 위해 주소를 변경하는 것이 복잡하여 잘 쓰이지 않음

경계 레지스터는 운영체제 영역과 사용자 영역 경계 지점의 주소를 가진 레지스터로, 사용자 영역이 운영체제 영역으로 침범하는 것을 막을 때 사용

 

상대 주소는 사용자 영역이 시작되는 번지를 0번지로 변경하여 사용하는 주소 지정 방식으로, 운영체제가 업그레이드되거나 운영체제 영역의 주소가 사용자에게 노출될 가능성을 방지

 

MMU가 상대 주소를 절대 주소로 매우 빠르게 변환하여 메모리에 접근

상대 주소를 절대 주소로 변환하는 과정

  1. 사용자 프로세스가 상대 주소 40번지에 있는 데이터 요청
  2. CPU는 메모리 관리자에게 40번지에 있는 내용을 가져오라고 명령
  3. 메모리 관리자는 재배치 레지스터를 사용하여 상대 주소 40번지를 절대 주소 400번지로 변환하고 메모리 400번지에 저장된 데이터를 가져옴

재배치 레지스터는 메모리에서 사용자 영역의 시작 주소값을 저장

 

메모리 오버레이

메모리 오버레이는 프로그램의 크기가 메모리보다 클 때 전체 프로그램을 적당한 크기로 잘라서 가져오는 기법

프로그램을 몇 개의 모듈로 나누고 중요한 모듈만 메모리에 적재하고, 나머지는 필요할 때마다 모듈을 메모리에 가져와 사용

어떤 모듈을 가져오거나 내보낼지는 프로그램 카운터가 결정

 

메모리보다 큰 프로그램의 실행이 가능

프로그램 전체가 아니라 일부만 메모리에 올라와도 실행이 가능

 

스왑

모듈을 메모리에서 제거할 때 다시 사용할 지도 모르고 작업이 끝나지 않았기 때문에 저장장치의 특별한 공간인 스왑 영역에 저장
메모리에 적재된 프로세스 중 실행되고 있지 않는 프로세스를 스왑 영역으로 보내고 다른 프로세스를 적재하여 실행하는 메모리 관리 방식

스왑 영역에서 메모리로 데이터를 가져오는 작업인 swap in, 메모리에서 스왑 영억으로 데이터를 내보내는 swap out

스왑 영역은 메모리 관리자가 관리

다시 swap in될 때는 swap-out되기 전의 물리 주소와 다를 수 있음

사용자는 메모리의 크기와 스왑 영역의 크기를 합쳐서 전체 메모리로 인식하고 사용할 수 있음

 

메모리 분할 방식

메모리에 여러 개의 프로세스를 배치하는 방법은 가변 분할 방식과 고정 분할 방식으로 나뉨

  • 가변 분할 방식(세그멘테이션 기법)  : 프로세스의 크기에 따라 메모리를 나눔
    프로세스의 크기에 따라 메모리를 나누므로 메모리의 영역이 각각 다름
    한 프로세스가 연속된 공간에 배치되기 때문에 연속 메모리 할당이라고 함

    프로세스를 한 덩어리로 처리하여 하나의 프로세스가 연속된 공간에 배치됨
    프로세스 작업을 마쳤을 때 빈 공간이 떨어져 있기 때문에 통합하는 작업 등이 필요하므로 메모리 관리가 복잡

    외부 단편화(fragmentation) 발생 가능 : 빈 영역이 있어도 서로 떨어져 있으면 연속된 크기의 메모리가 없어 프로세스를 할당하지 못하는 상태
    외부 단편화를 해결하기 위해 작은 조각이 발생하지 않도록 프로세스를 배치하거나, 단편화가 발생했을 때 작은 메모리를 합쳐 큰 메모리로 만드는 조각 모음을 하는 방법이 있음
    • 최초 배치(first fit) : 단편화를 고려하지 않는 것으로, 프로세스를 메모리에 적재 가능한 공간을 순서대로 찾다가 첫 번째로 발견한 공간에 프로세스를 배치하는 방법
      빈 공간을 찾을 필요가 없음
    • 최적 배치(best fit) : 메모리의 빈 공간을 모두 확인한 후 가장 작은 공간에 프로세스를 배치하는 방법
      딱 맞는 공간이 있을 때는 단편화가 일어나지 않지만 딱 맞는 공간이 없을 때는 아주 작은 조각을 만드는 단점이 있음
    • 최악 배치(worst fit) : 메모리의 빈 공간을 모두 확인한 후 가장 큰 공간에 프로세스를 배치하는 방법
      빈 공간의 크기가 클 때는 효과적이지만 빈 공간의 크기가 점점 줄어들면 최적 배치처럼 작은 조각을 만드는 단점이 있음

    • 조각 모음 : 조각 모음을 하기 위해 프로세스의 동작을 멈추고 프로세스를 이동한 후 프로세스의 상대 주소값을 변경하고 프로세스를 다시 시작
      위와 같은 작업을 하기 때문에 많은 시간이 걸리고, 부가적인 작업이 필요하므로 메모리 관리가 복잡

    • 버디 시스템 : 외부 단편화를 완화하는 방법
      프로세스의 크기에 맞게 메모리를 반으로 자르면서 프로세스를 메모리에 배치
      나뉜 메모리의 각 구역에는 프로세스가 1개만 들어감
      프로세스가 종료되면 주변의 빈 조각과 합쳐서 하나의 큰 메모리를 만듦
      가변 분할 방식처럼 메모리가 프로세스 크기대로 나뉘며, 고정 분할 방식처럼 내부 단편화가 발생
      가변 분할 방식보다 효과적으로 공간을 관리할 수 있는 이유는 비슷한 크기의 메모리가 서로 모여 있어 통합하기가 쉽기 때문
      공간 관리 측면에서는 고정 분할 방식과 비슷하지만 공간을 반으로 나누고 통합하는 과정보다 똑같은 크기로 나누는 고정 분할 방식이 더 단순
    • 메모리 풀 : 사전에 큰 메모리 블록을 할당하고 이를 필요한 크기로 나누어 사용하는 방법
  • 고정 분할 방식(페이징) : 프로세스의 크기와 상관없이 메모리를 같은 크기로 나누는 것
    한 프로세스가 분산되어 배치되기 때문에 비연속 메모리 할당이라고 함

    일정한 크기로 나누어 관리하기 때문에 부가적인 작업을 할 필요가 없어 메모리 관리가 수월
    일정하게 나누어진 공간보다 작은 프로세스가 올라올 경우 메모리 낭비 발생
    프로세스가 메모리의 여러 조각에 저장되는 것이 문제

    가변 분할 방식보다 공간을 효율적으로 관리
    조각 모음을 할 필요가 없어 관리가 수월하므로 기본으로 사용되고 있음

    내부 단편화 발생 가능 : 각 메모리 조각에 프로세스를 배치하고 공간이 남는 현상
    분할되는 공간의 크기를 조절하여 내부 단편화를 최소화할 수 있음
    분할되는 공간의 크기를 줄이면 내부 단편화가 줄어들지만 프로세스가 여러 개로 분할되어 관리하기 어려움
    분할되는 공간의 크기를 늘리면 내부 단편화가 늘어나 메모리가 낭비됨

스마트 포인터를 사용하는 이유

  • 스마트 포인터가 생성되고 소멸되는 시기를 결정할 수 있음
    스마트 포인터는 생성될 때 기본값 nullptr으로 초기화 되기 때문에 직접 초기화해야 하는 포인터와는 다름
    객체를 가리키는 최후의 스마트 포인터가 소멸될 때 자동으로 객체를 소멸하여 리소스 누수를 막음
  • 스마트 포인터가 복사되거나 대입될 때 깊은 복사를 수행하거나 얕은 복사를 수행하도록 결정할 수 있음
  • 역참조 동작을 조절할 수 있음

C++ 기본 제공 포인터처럼 가리킬 타입이 정확하게 지정되어야 하므로 스마트 포인터는 템플릿 기반으로 만들어짐

따라서 템플릿 매개변수를 통해 스마트 포인터로 가리킬 객체의 타입을 지정

 

복사와 대입을 모두 허용하여 복사 생성자와 대입 연산자 모두 public으로 선언됨

역참조 연산자가 const로 선언한 이유는 포인터를 역참조한다고 내부 데이터가 변경되지 않음

 

스마트 포인터가 자신이 가리키는 객체를 소유한 경우에는 스마트 포인터가 소멸될 때 객체도 삭제해야 하는데, 객체가 동적 할당된 경우를 가정한 것

 

unique_ptr은 자신이 소멸될 때까지 힙 기반 객체를 가리키는 스마트 포인터

unique_ptr은 자신이 소멸될 때 소멸자가 호출되면서 자신이 가리키는 객체를 삭제

unique_ptr은 복사 혹은 대입될 때 소유 관계를 옮겨 객체를 두 개 이상의 unique_ptr이 가리키지 못하도록 구현

  • 따라서 unique_ptr을 매개변수에 값으로 전달할 경우 매개변수에 소유권이 옮겨지고 함수가 종료되어 매개변수가 소멸되면서 객체를 삭제하고, 기존 unique_ptr은 아무것도 가리키지 않게 됨

 

void temp(unique_ptr<const int>& ci);

unique_ptr<int> i;
unique_ptr<const int> ci;

temp(i); // 에러
temp(ci);

temp(i)가 에러인 이유는 템플릿 매개변수가 다르기 때문에 unique_ptr<int>와 unique_ptr<const int>는 아예 다른 타입이고, 암시적 변환이 안되기 때문

기본 접근법 : 2차원 DP

전통적인 배낭 문제의 해결 방법은 2차원 DP 배열을 사용하는 것

for (int i = 1; i <= N; i++)
{
	for (int j = 1; j <= K; j++)
	{
		if (j >= Weight[i])
		{
			DP[i][j] = Max(DP[i - 1][j], DP[i - 1][j - Weight[i]] + Value[i]);
		}
		else
		{
			DP[i][j] = DP[i - 1][j];
		}
	}
}
  • DP[i][j]는 i번째 물건까지 고려했을 때, 배낭 용량이 j일 때의 최대 가치
  • 각 물건마다 두 가지 선택지는 물건을 넣거나, 넣지 않거나
  • j >= Weight[i]일 경우, 물건을 넣을 수 있으므로 두 선택지 중 더 나은 것을 선택
  • 그렇지 않은 경우, 물건을 넣을 수 없으므로 이전 상태를 유지

최적화된 접근법 : 1차원 DP

공간 복잡도를 최적화한 1차원 DP 배열을 사용

vector<int> dp(maxWeight + 1, 0);
for (int i = 0; i < N; ++i)
{
    int W = 0, V = 0;
    cin >> W >> V;
    for (int weight = K; weight >= W; --weight)
    {
        dp[weight] = max(dp[weight], dp[weight - W] + V);
        answer = max(answer, dp[weight]);
    }
}
  • 현재 아이템을 넣을 수 있는 무게들에 아이템의 가치를 갱신
  • 같은 물건을 여러 번 선택하는 것을 방지하기 위해 무게를 K부터 현재 물건의 무게까지 역방향으로 반복

두 접근법의 차이

  1. 메모리 사용량
    • 2차원 DP: O(N×K) 메모리 사용
    • 1차원 DP: O(K) 메모리 사용
  2. 코드 간결성
    • 1차원 DP가 더 간결하고 배열 초기화 및 구현이 단순

'알고리즘 > 문제' 카테고리의 다른 글

백준 2448 별찍기 - 11 C++  (0) 2025.04.08

클래스에 지나지게 집착하면 경직되고 유연하지 못한 설계가 될 확률이 높아짐

객체지향에서 가장 중요한 것은 메시지인데 책임이 메시지의 기반이 되므로 클래스가 아니라 객체가 수행하는 책임에 초점을 맞춰야 함

 

객체가 다른 객체에게 접근할 수 있는 유일한 방법은 메시지를 전송하는 것 뿐이고, 메시지를 수신한 객체는 메시지를 적절히 처리한 후 응답하여 협력을 구성

협력의 관점에서 객체는 수신하는 메시지의 집합과 외부의 객체에게 전송하는 메시지의 집합으로 구성되는데, 수신하는 메시지의 집합 뿐만 아니라 외부에 전송하는 메시지의 집합도 함께 고려해야 협력에 적합한 객체를 설계할 수 있음

 

메시지와 메서드

메서드는 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저

 

메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입(동적 바인딩 타입)에 따라 다름

따라서 코드 상에서 동일한 이름의 변수에게 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라짐

 

메시지와 메서드의 구분은 전송자와 수신자가 느슨하게 결합되어 유연하고 확장 가능한 코드를 작성할 수 있게 만듦

전송자는 어떤 메시지를 전송해야 하는지만 알면 되고, 수신자는 메시지를 처리하기 위해 필요한 메서드를 스스로 결정할 수 있는 자율권이 있으면 됨

 

퍼블릭 인터페이스와 오퍼레이션

퍼블릭 인터페이스는 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합

오퍼레이션은 프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지

  • 오퍼레이션은 메시지와 관련된 시그니처와 관련이 있고, 메서드는 메시지를 수신했을 때 실제로 실행되는 코드

 

인터페이스와 설계 품질

좋은 인터페이스는 최소한의 인터페이스 추상적인 인터페이스를 만족해야 함

최소한의 인터페이스는 꼭 필요한 오퍼레이션만 인터페이스에 포함하는 것을 의미

추상적인 인터페이스는 어떻게 수행하는지가 아니라 무엇을 하는지를 표현

 

최소와 추상적인 인터페이스를 설계할 수 있는 가장 좋은 방법은 책임 주도 설계 방법

책임 주도 설계 방법은 메시지를 먼저 선택함으로써 협력과 무관한 오퍼레이션이 인터페이스에 있는 것을 방지하고, 객체가 메시지를 선택하는 것이 아닌 메시지가 객체를 선택함으로써 송신자의 의도를 메시지에 표현하여 추상적이게 됨

 

퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법

  • 디미터 법칙
  • 묻지 말고 시켜라
  • 의도를 드러내는 인터페이스
  • 명령-쿼리 분리

 

디미터 법칙

협력하는 객체의 내부 구조에 대한 결함으로 인해 발생하는 설계 문제를 해결하기 위해 제안된 원칙

객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하는 것

특정 조건을 만족하는 대상에게만 메시지를 전송하도록 프로그래밍해야 함

  • this 객체, 메서드의 매개변수, this의 프로퍼티, this의 프로퍼티인 컬렉션의 요소, 메서드 내에서 생성된 지역 객체

 

디미터 법칙을 따르면 shy code를 작성할 수 있는데, shy code는 불필요한 어떤 것도 다른 객체에게 보여주지 않으며, 다른 객체에게 구현을 의존하지 않는 코드

디미터 법칙은 클래스를 캡슐화하기 위해 따라야 하는 구체적인 지침을 제공

screening.calculateFee(audienceCount);

디미터 법칙은 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조하고 정보를 처리하는 데 필요한 책임을 정보를 알고 있는 객체에게 할당하기 때문에 응집도가 높은 객체가 만들어짐

 

screening.getMove().getDiscountConditions();

위 코드는 디미터 법칙을 위반하는데, 메시지 전송자가 수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메시지를 전송하는 위와 같은 코드를 기차 충돌이라고 부름

기차 충돌은 클래스의 내부 구현이 외부로 노출됐을 때 나타나는 전형적인 형태로 캡슐화는 무너지고, 메시지 전송자가 수신자의 내부 구현에 강하게 결합됨

 

묻지 말고 시켜라

훌륭한 메시지는 객체의 상태에 묻지 말고 원하는 것을 시켜야 함

객체의 외부에서 객체의 상태를 기반으로 결정을 내리는 것은 객체의 캡슐화를 위반

묻지 말고 시켜라 원칙을 따르면 객체의 정보를 이용하는 행동을 객체의 내부에 위치시키기 때문에 자연스럽게 정보와 행동을 동일한 클래스 안에 두게 되어 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아짐

 

묻지 않고 시킨다고 해서 모든 문제가 해결되는 것은 아니라 인터페이스에는 객체가 어떻게 작업을 수행하는지를 노출해서는 안되고 무엇을 하는지를 서술해야 함

 

의도를 드러내는 인터페이스

메서드를 명명하는 첫 번째 방법은 작업을 어떻게 수행하는지를 나타내도록 이름 짓는 것인데, 이는 내부의 구현 방법을 드러냄

class PeriodCondition
{
public:
    bool isSatisfiedByPeriod(Screening screening);
};

class SequenceCondition
{
public:
    bool isSatisfiedBySequence(Screening screening);
};

첫 번째 방법의 단점

  • 메서드에 대해 제대로 커뮤니케이션 하지 못함
    클라이언트의 관점에서 내부 구현을 정확하게 이해하지 못한다면 두 메서드가 동일한 작업을 수행한다는 사실을 알아채기 어려움
  • 메서드 수준에서 캡슐화를 위반
    클라이언트가 협력하는 객체의 종류를 알도록 강요하므로 할인 여부를 판단하는 방법이 변경된다면 참조하는 객체 뿐만 아니라 메서드의 이름도 변경해야 함
    메서드의 이름이 변경되면 클라이언트 코드도 함께 변경해야 하므로 변경에 취약

메서드를 명명하는 두 번째 방법은 무엇을 하는지 드러내는 것

메서드의 구현이 한 가지인 경우에는 무엇을 하는지를 드러내는 이름을 짓는 것이 어려울 수도 있음

하지만 무엇을 하는지를 드러내는 이름은 메서드의 이름을 짓기 위해 객체가 협력 안에서 수행해야 하는 책임과 관련하여 이름을 짓기 때문에 이해하기 쉽고 유연한 코드가 될 수 있음

class PeriodCondition
{
public:
    bool isSatisfiedBy(Screening screening);
};

class SequenceCondition
{
public:
    bool isSatisfiedBy(Screening screening);
};

 

변경된 메서드 이름은 동일한 목적을 가진다는 것을 명확하게 표현하고, 동일한 메시지를 서로 다른 방법으로 처리하기 때문에 서로 대체 가능

클라이언트가 두 객체를 동일한 타입으로 간주할 수 있도록 인터페이스로 정의

class DiscountCondition
{
    virtual bool isSatisfiedBy(Screening screening) = 0;
};

class PeriodCondition : public DiscountCondition
{
public:
    virtual bool isSatisfiedBy(Screening screening) override final;
};

class SequenceCondition : public DiscountCondition
{
public:
    virtual bool isSatisfiedBy(Screening screening) override final;
};

 

명령-쿼리 분리 원칙

루틴 : 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈

프로시저 : 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 종류

함수 : 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 종류

명령 : 객체의 상태를 수정하는 오퍼레이션, 프로시저와 동일

쿼리 : 객체와 관련된 정보를 반환하는 오퍼레이션, 함수와 동일

 

명령-쿼리 분리 원칙의 요지는 오퍼레이션은 부수 효과를 발생시키는 명령이거나 부수 효과를 발생시키지 않는 쿼리 중 하나여야 하고, 어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안됨

  • 객체의 상태를 변경하는 명령은 반환값을 가질 수 없음
  • 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없음

객체의 캡슐화와 다양한 문맥에서의 재사용을 보장

 

class Event
{
public:
    bool isSatisfied(RecurringSchedule schedule)
    {
        if(from.getDayOfWeek() != schedule.getDayOfWeek() ||
            !from.toLocalTime().equals(schedule.getFrom()))
        {
            reschedule(schedule);
            return false;
        }
    
        return true;
    }
}

기능을 추가하는 과정에서 누군가 기존에 있던 isSatisfied 메서드에 reschedule 메서드를 호출하는 코드를 추가하여 isSatisfied 메서드는 명령과 쿼리 두 가지 역할을 동시에 수행하여 이해하기 어렵고, 잘못 사용하기 쉽고, 버그를 양산할 수 있음

쿼리를 호출하더라도 다른 부분에 영향을 미치지 않지만 명령을 호출할 때는 부수 효과에 주의해야 함

 

책임에 초점을 맞춰라

메시지를 먼저 선택하고 메시지를 처리할 객체를 선택하는 것이 미치는 긍정적인 영향

  • 디미터 법칙 : 두 객체 사이의 결합도를 낮출 수 있음
    수신할 객체를 알지 못한 상태에서 메시지를 먼저 선택하기 때문에 메시지가 객체를 선택하게 함으로써 내부 구조에 대해 고민할 필요가 없어짐
    따라서 메시지가 객체를 선택하게 함으로써 디미터 법칙을 위반할 위험을 최소화할 수 있음
  • 묻지 말고 시켜라 : 협력을 구조화하게 됨
    클라이언트 관점에서 메시지의 이름을 정하여 의도가 분명하게 드러날 수 밖에 없음
  • 명령-쿼리 분리 원칙 : 객체가 단순히 어떤 일을 해야 하는지뿐만 아니라 협력 속에서 객체의 상태를 예측하고 이해하기 쉽게 만들기 위한 방법에 관해 고민하게 됨
    따라서 예측 가능한 협력을 만들기 위해 명령과 쿼리를 분리하게 됨

 

원칙의 함정

원칙이 현재 상황에 부적합하다고 판단되면 과감하게 원칙을 무시해야 함

원칙을 아는 것보다 중요한 것은 언제 원칙이 유용하고 언제 유용하지 않은지를 판단할 수 있는 능력을 길러야 함

 

디미터 법칙은 하나의 도트(.)를 강제하는 규칙이 아님

하나 이상의 도트를 사용하는 모든 케이스가 디미터 법칙 위반인 것은 아님

기차 충돌처럼 보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 디미터 법칙을 준수한 것

 

결합도와 응집도의 충돌

묻지 말고 시켜라와 디미터 법칙을 맹목적으로 준수하여 모든 상황에서 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않은 메서드들이 공존하게 되므로 응집도가 낮아짐

따라서 상관없는 책임들이 함께 있는 클래스는 응집도가 낮고 변경 이유가 많아짐

 

class PeriodCondition : public DiscountCondition
{
public:
    bool isSatisfiedBy(Screening screening)
    {
        return screening.getStartTime().getDayOfWeek().eqauls(dayOfWeek) &&
            startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
            endTime.compareTo(screening.getEndTime().toLocalTime()) >= 0;
    }
};

위 코드는 얼핏 보면 Screening의 내부 상태를 가져와 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있어서 아래 코드로 수정해보자

class PeriodCondition : public DiscountCondition
{
public:
    bool isSatisfiedBy(Screening screening)
    {
        return screening.isDiscountable(dayOfWeek, startTime, endTime);
    }
};

class Screening
{
public:
    bool isDiscountable(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime)
    {
        return whenScreened.getDayOfWeek().eqauls(dayOfWeek) &&
            startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
            endTime.compareTo(screening.getEndTime().toLocalTime()) >= 0;
    }
};

Screening이 기간에 따른 할인 조건을 판단하는 책임을 갖게 되는데, Screening의 본질적인 책임은 영화를 예매하는 것이기 때문에 객체의 응집도가 낮아짐

 

객체는 내부 구조를 숨겨야 하므로 디미터 법칙을 따르는 것이 좋지만, 자료 구조(데이터 객체)라면 당연히 내부를 노출해야 하므로 디미터 법칙을 적용할 필요가 없음

 

 

객체를 한 개만 생성하는 방법

클래스의 정적 멤버로 선언된 객체는 그것이 사용되든 사용되지 않든 상관없이 생성됨

함수 안에서 정적 변수로 선언된 객체는 함수가 최소한 한 번 호출되어야 생성됨

 

클래스의 정적 멤버로 선언된 객체는 초기화 시점이 정확하게 정의되어 있지 않음

  • C++는 하나의 컴파일 단위(오브젝트 파일) 안에 선언된 정적 객체들의 초기화 순서는 보장하고 있지만 서로 다른 컴파일 단위에 있는 정적 객체의 초기화 순서는 정의하지 않음

함수 안에서 정적 변수로 선언된 객체는 초기화 시점이 함수가 처음 호출되는 시점으로 명확함

 

정적 객체를 선언한 비멤버 함수를 inline으로 선언하면 호출하는 곳마다 정적 객체가 선언되므로 inline으로 선언하면 안됨

Printer& thePrinter()
{
    static Printer p;
    return p;
}

 

인스턴스 카운팅 기능을 가진 기본 클래스

template <class BeingCounted>
class Counted
{
public:
    class TooManyObjects
    {};

    static size_t objectCount() { return numObjects; }

protected:
    Counted();
    Counted(const Counted&);
    virtual ~Counted() { --numObjects; }

private:
    void init();

    static size_t numObjects;
    static const size_t maxObjects;
};

template <class BeingCounted>
Counted<BeingCounted>::Counted()
{
    init();
}

template <class BeingCounted>
Counted<BeingCounted>::Counted(const Counted<BeingCounted>&)
{
    init();
}

template <class BeingCounted>
void Counted<BeingCounted>::init()
{
    if (numObjects >= maxObjects)
    {
        throw TooManyObjects();
    }

    ++numObjects;
}

각 클래스마다 별도의 카운터를 두어야하기 때문에 클래스 템플릿으로 구현

기본 클래스로만 사용되도록 설계됐기 때문에 생성자와 소멸자가 모두 protected로 선언됨

예외 대신에 다른 알림으로도 사용할 수 있음

가상 함수가 호출될 때 그 함수에 대해 실행되는 코드는 함수가 호출되는 객체의 동적 타입에 맞는 것이어야 함

컴파일러는 가상 테이블(vtbl)과 가상 테이블 포인터(vptr)를 사용하여 동적 타입을 판단

 

vtbl

vtbl의 각 요소는 클래스에 정의한 가상 함수 포인터

vtbl의 크기는 클래스에 선언된 가상 함수의 수에 비례

vtbl은 클래스에 한 개만 생성되기 때문에 크기는 미미하지만 소프트웨어 전체에 가상 클래스나 가상 함수가 많을 때에는 무시 못할 메모리 부담이 됨

vtbl은 일반적으로 컴파일러에 의해 가상 함수의 정의가 있는 오브젝트 파일에 위치

 

vptr

vptr은 가상 테이블을 가리키는 포인터

가상 함수를 선언한 클래스에는 숨겨진 데이터 멤버 vptr이 있어서 객체의 크기에 포인터의 크기만큼 추가됨

 

class C1;
class C2 : public C1;

void makeACall(C1* pC1)
{
    pC1->f1();
}

가상 함수가 호출되는 과정

pC1이 가리키는 객체의 vptr을 가리키는 vtbl을 참조하여 호출해야 하는 함수의 포인터를 찾아옴

찾아온 함수 포인터가 가리키는 함수를 호출

 

가상 함수 호출에 드는 비용은 함수 포인터를 통해 함수를 호출하는 비용과 같기 때문에 가상 함수만 놓고 볼 때는 수행 성능의 발목을 잡는 요인은 아님

인라인은 컴파일 도중에 호출 위치에 호출되는 함수의 본문을 끼워 넣는다는 의미인데, 가상 함수는 호출할 함수를 런타임까지 기다려 결정한다는 의미이므로 가상 함수는 인라인 효과를 포기해야 함

 

다중 상속 중 다이아몬드 상속일 경우 기본 클래스의 데이터 멤버가 중복 생성되므로 가상 상속을 통해 중복 생성을 방지할 수 있음

하지만 가상 상속도 비용이 발생하므로 아래 글을 참고

 

Effective C++ 항목 40 다중 상속은 심사숙고해서 사용하자

이것만은 잊지 말자!- 다중 상속은 단일 상속보다 확실히 복잡함 모호성 문제 뿐만 아니라 가상 상속이 필요해질 수 있음 - 가상 상속을 쓰면 크기 / 속도 비용이 늘어나며 초기화 및 대입 연산의

eovywjr1.tistory.com

 

RTTI(RunTime Type Identification)

런타임에 객체와 클래스의 정보를 알아낼 수 있게 하는 정보

type_info 타입의 객체에 저장하고, type_info 객체는 typeid 연산자를 써서 접근할 수 있음

클래스마다 RTTI 정보를 하나씩만 유지하고 vbtl의 0번째 인덱스에 type_info 객체에 대한 포인터가 위치

객체의 동적 타입을 정확히 알아내려면 클래스에 가상 함수가 최소한 하나는 있어야 함

+ Recent posts

목차