Inheritance vs Composition

|

개발을 하다보면 남발하는 것 중 하나가 ‘상속(Inheritance)’입니다. 여러 클래스가 있을 때 공통되는 부분을 부모 클래스로 만들어서 상속으로 구현하면 보기에도 좋아보이고 코드양도 줄어들고 뿌듯할 때가 있습니다.

하지만, 상속보다는 이양을 사용하는 편이 설계 측면에서는 훨씬 더 유리합니다. ‘이양’이란 부모/자식간의 관계처럼 서로 상속하는 관계가 아니라 인스턴스안에 다른 인스턴스를 품어서 그 역할을 전달하는 방법입니다.

상속과 이양은 ‘Inheritance vs Composition’으로 표현하기도 하며, ‘is-A vs has-A’로 표현하기도 합니다.

상속 관계는 부모와 자식간의 관계가 아주 밀접하게 결합합니다. 부모 클래스가 수정이 되면 자식 클래스에도 영향을 미칩니다. 상속의 예는 다음과 같습니다.


Inheritance 예제 코드

class Robot {
 public:
  void update() {
    move();
  }
  // ...
  
 private:
  virtual void move() = 0;
  // ...
};

class CleanerRobot : public Robot {
 private:
  virtual void move() override {
    clean();
    moveForward();
    // ...
  }
};

class CombatRobot : public Robot {
 private:
  virtual void move() override {
    attack();
    rotateLeft();
    // ...
  }
};


Composition 예제 코드

위 코드를 이양 관계로 바꾸면 아래와 같습니다.

class RobotBehavior {
 public:
  virtual ~RobotBehavior() {}

  virtual void move() = 0;
};

class Robot {
 public:
  Robot(RobotBehavior* behavior) {
    mBehavior(behavior);
  }

  ~Robot() {
    delete mBehavior;
  }

  void update() {
    mBehavior->move();
  }

 private:
  RobotBehavior* mBehavior;
};

class CleanerRobot : public RobotBehavior {
 public:
  virtual void move() override {
    clean();
    moveForward();
    // ...
  }
};

class CombatRobot : public RobotBehavior {
 private:
  virtual void move() override {
    attack();
    rotateLeft();
    // ...
  }
};

코드가 길어지고 조금 더 복잡해졌습니다. 하지만 RobotBehavior이라는 행동을 하는 추상 클래스가 생기면서 Robot 클래스와 나머지 다른 클래스들인 CleanerRobot, CompatRobot 클래스와의 결합이 끊어졌습니다. 따라서 Robot 클래스의 내용이 변경되더라도 다른 로봇 클래스들에게는 영향을 주지 않습니다.

물론, CleanerRobot, CompatRobot 클래스들은 RobotBehavior 클래스를 상속받기 때문에 RobotBehavior 클래스가 수정이 되면 상속받은 클래스들은 모두 변경이 됩니다. 하지만, RobotBehavior 클래스는 아주 추상적인 레벨의 행동 인터페이스만 정의되어 있기 때문에 향후 변경될 가능성은 많이 낮습니다. 만약 변수나 구현부가 포함이 된 경우에는 향후 변경될 가능성이 아주 높습니다. 따라서 RobotBehavior 클래스에는 최소한의 인터페이스만 정의되어 있어야 합니다.

또한, 상속은 컴파일시 생성이 됩니다. 하지만 이양의 경우는 실행중에도 동적으로 변경이 가능한 장점도 있습니다.

따라서 상속과 이양이 둘 다 가능한 경우에는 가급적 상속보다는 이양을 우선하는게 좋습니다.

함수의 기본 원칙

|

함수화를 잘 하는 것은 유지 보수성이 높은 코드를 만드는 첫 번째 단계라고 볼 수 있습니다. 경험이 중요하지만 기본적인 원칙을 따르며 함수를 작성해가면 새로운 언어 등에서도 유연하게 개발할 수 있습니다.


함수의 기본 원칙

  • 하나의 함수는 하나의 역할만 담당한다.
  • 함수를 두 종류로 분류한다.

하나의 함수가 하나의 역할만 담당하는 것은 상당히 쉽지만 많은 사람들이 실수하는 부분입니다. 함수가 여러 가지 기능을 담당하게되면, 함수의 이름도 복잡해지고 매개변수의 처리나 코드의 복잡성도 높아지게 됩니다.

함수는 크게 두 종류로 분류할 수 있습니다. 각종 연산이나 알고리즘 수행을 행하는 함수와 이런 함수들을 조합해서 관리하는 함수들로 나눌 수 있습니다.

예를 들어 다음의 함수는 게임 등에서 자주 쓰이는 update 함수 예제입니다.

void update(long msec) {
  for (iter i = actor.begint(); i != actor.end(); i++) {
    (*i)->move(msec);
  }

  collide();
}

사물의 이동과 충돌 판정 계산이 같이 포함되어 있는데, 어딘가 부자연스럽습니다. 사물의 이동은 실제 연산 부분이 구현되어 있고, 충돌 판정 부분은 함수 호출로 되어 있어 서로 코드의 레벨이 다르기 때문입니다.

따라서 다음과 같이 코드를 수정할 수 있습니다.

void move(long msec) {
  for (iter i = actors.begint(); i != actors.end(); i++) {
    (*i)->move(msec);
  }
}

void update(long msec) {
  move(msec);
  
  collide();
}


함수화 패턴

함수화를 하는데에는 보통 다음과 같은 패턴이 있습니다. 일일이 따를 필요는 없지만 대략 다음과 같은 패턴으로 함수화를 하면 유지 보수성이 높은 코드를 쉽게 작성할 수 있습니다.

  • 조건식 함수화
  • 계산식 함수화
  • 분기문의 블록 내부 함수화
  • 반복문 함수화
  • 데이터 변환 함수화
  • 데이터 확인 함수화
  • 배열 접근 함수화


조건식 함수화

if 조건문의 조건식을 함수화하는 방법입니다.

if((speed > 100) && (posY > 250)) {
  // ...
}

위 코드는 다음과 같은 형태로 바꿀 수 있습니다.

if(isJump()) {
  // ...
}


계산식 함수화

계산식을 함수화하면 그 함수에 이름을 붙이기 쉽습니다. 그 의미를 명확하게 할 뿐 아니라 그 함수를 호출하는 쪽의 코드 분량이 줄어들어 가독성또한 좋아집니다.

double getDistance(double x1, double y1, double x2, double y2) {
  return std::sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}

예를 들어, 위와 같은 함수를 작성하면 호출하는 쪽은 단지

double distance = getDistance(x1, y1, x2, y2);

와 같은 간단한 호출만으로 원하는 값을 얻을 수 있습니다.


반복문 함수화

다음 코드는 2개의 반복문을 갖고 있습니다.

void update(long msec) {
  for (iter i = actors.begin(); i != actors.end(); i++) {
    (*i)->update(msec);
  }

  for (iter i = actors.begin(); i != actors.end(); i++) {
    for (iter j = std::next(i); j != actors.end(); j++) {
      (*i)->collide(*j);
    }
  }
}

각각의 반복문들을 다음과 같이 함수화합니다.

void move(long msec) {
  for (iter i = actors.begin(); i != actors.end(); i++) {
    (*i)->update(msec);
  }
}

void collide() {
  for (iter i = actors.begin(); i != actors.end(); i++) {
    for (iter j = std::next(i); j != actors.end(); j++) {
      (*i)->collide(*j);
    }
  }
}

void update(long msec) {
  move(msec);

  collide();
}

이렇게 각각을 함수로 분리함으로써 각 함수는 역할이 분명해지고 가독성도 훨씬 더 좋아졌습니다.

이렇게 분리한 반복문들은 STL을 이용해 더 간략화할 수 있습니다.

void move(long msec) {
  std::for_each(actors.begin(), actors.end(),
                [&](Actor* actor) { actor->update(msec); });
}


작은 함수의 필요성

함수화 패턴에 따라 코드를 함수화해나가면 함수 단위가 아주 작아집니다. 작은 함수는 다음과 같은 점에서 유리한 점을 가집니다.

  • 함수의 이름만으로 설명이 쉽게 된다.
  • 함수 개별 테스트(Unit Test)가 쉬워진다.
  • 함수 재활용성이 강화된다.

함수를 많이 쪼갤수록 성능 저하가 발생할 수 있습니다. 실제로도 성능 저하가 발생할 수는 있지만, 요즘의 대부분의 컴파일러들은 최적화 기능을 통해 이러한 문제점들을 많이 극복하고 있습니다.

예를 들어 Release 모드에서는 아주 작은 함수들은 인라인(inline) 함수로 대체해버려서 함수 호출로 인한 오버헤드(Overhead)가 전혀 없어지기도 합니다. 비주얼 스튜디오 같은 경우는 ‘링크시 코드 생성(LTCG)’ 기능이 있어서 컴파일(Compile) 단계가 아닌 링크(Link) 단계에서 함수를 인라인화하는 옵션도 있습니다. 이는 프로그램 전체의 함수가 최적화되기 때문에 다른 파일에 있는 함수들까지 인라인화되는 옵션입니다.

assert 활용하기

|

assert를 잘 쓰면 개발에 꽤 유용할 때가 많습니다.

assert는 디버그 모드(Debug Mode)에서만 작동하고 릴리즈 모드(Release Mode)에서는 컴파일 단계에서 무효화되어 걸러지기 때문에 디버깅 용도로 쓰기에 참 좋습니다. (보통 디버깅 환경에서는 로그 메세지를 남발하며 뿌리다가 릴리즈 모드에서는 그 로그 메세지들을 일일이 막는 것도 번거로운 작업들입니다. 그래서 Log 함수들을 별도의 클래스로 Wrapper하는 경우도 많이 있습니다.)

assert는 괄호안에 들어가는 조건식이 false인 경우 오류 메세지를 출력하면서 프로그램을 강제 종료시킵니다. 프로그램을 강제 종료함으로 인해, 나중에 발생할지도 모르는 논리적인 오류들을 사전에 막아주는 역할을 해서 디버깅 환경에 큰 도움이 됩니다.

assert 활용 예제는 다음과 같습니다.


assert 활용 예제

static const char* weeks[] = {
    "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday",
    "Friday", "Saturday",
};
void displayWeek(int day) {
  assert((day >= 0) && (day <=6));
  cout<<weeks[day];
}

위 코드에서 displayWeek 함수의 인자로 0보다 작거나 6보다 큰 값을 입력하게 되면 다음과 같은 오류 메세지를 출력합니다.

assertion "(day >= 0) && (day <=6)" failed: file "/cygdrive/c/Workspace/Clion/SnowDeer/main.cpp", line 11, function: void displayWeek(int)


주의할 점

assert의 매개변수에는 조건식만 들어가야 합니다. 만약 조건식이 아닌 실행 가능한 구문이 들어가게 되면 큰 오류를 범할 수 있습니다. assert는 디버그 모드에서만 활성화되며, 릴리즈 모드에서는 활성화되지 않기 때문에 다음과 같은 코드는 문제가 발생할 수 있습니다.

void openFile() {
  FILE* fp;
  assert((fp = fopen(file_name, "r")) != nullptr));
}

`assert’는 잘 쓰면 좋은 효과를 가져올 수 있지만, 입력해야 하는 매개변수 또한 주의하지 않으면 또다른 문제를 발생하기도 합니다. 따라서 매개변수에 들어가는 조건문을 최대한 간결하게해서 필요한 부분에만 적절히 사용한다면 개발에 큰 도움이 될 수 있을 것 같습니다.

Fragment의 라이프사이클

|

Fragment의 라이프사이클

Fragment의 라이프사이클은 Activity의 라이프사이클과 비슷하지만 약간 더 복잡합니다. 다음 이미지와 같은 모습의 라이프사이클을 갖고 있습니다.

image -fullwidth

onAttach()onDetach() 이벤트가 Fragment가 실제로 Activity와 연결되거나 끊어지는 부분입니다.

단계 설명
onAttach Activity와 연결될 때 호출. 이 시점에서 getActivity()null 리턴함
onCreate Fragment 생성시 호출
onCreateView View 생성
onActivityCreated 뷰 생성(setContentView 호출 등)
onStart Fragment 시작될 때 호출(화면에 보이기 직전)
onResume 화면에 표시될 때 호출
onPause 화면에서 가려질 때 호출
onStop Fragment가 종료될 때 호출
onDestoryView View가 해제될 때 호출
onDestory Fragment가 해제될 때 호출
onDetach Activity와 연결이 해제될 때 호출. 가장 마지막 단계

Binary Indexed Tree

|

Binary Indexed Tree는 이진수 값을 인덱스로 활용해서 구간별 최소/최대/합 등을 쉽게 구할 수 있게 해주는 트리 형태의 데이터 구조입니다.

Segment Tree vs Binary Indexed Tree

Segment Tree로부터 좀 더 발전된 트리이며, 개념도 비슷합니다. Segment Tree 보다 더 적은 데이터 공간을 필요로 하며 코드 구현이 더 쉽습니다.

다만 처음 이해하는 과정이 조금 복잡하여 Segment Tree의 개념부터 이해를 하고 도전을 하는 것이 좋을 것 같습니다.

BIT

위의 그림은 어디서 많이 본 트리입니다. 바로 Segment Tree입니다. 여기서 잘 생각해보면 부모 노드는 어차피 자식 노드들의 정보로 이루어져 있기 때문에 중복된 데이터들이 존재하는 것을 알 수 있습니다.

예를 들어, 아래 예시와 같은 데이터가 있다면

왼쪽 자식 노드의 값 100, 오른쪽 자식 노드의 값 200
- 부모 노드는 자식 노드들의 값의 합이라고 하면 100 + 200 = 300
- 오른쪽 자식 노드가 없더라도 부모 노드의 값과 왼쪽 자식 노드의 값만 알면, 오른쪽 자식 노드의 값을 유추할 수 있음


즉, Segment Tree에서

BIT 회색 부분의 공간은 불필요한 공간이 되며,

BIT 와 같은 형태로 데이터 구조를 가져갈 수 있습니다.


Binary Indexed Tree 구조

실제로 값을 넣어보도록 하겠습니다.

BIT 실제 데이터는 대략 이런 식으로 저장이 된다고 가정 하면, Binary Indexed Tree는 다음과 같은 형태로 구성이 됩니다. (해당 구간의 데이터의 합을 구하는 Binary Indexed Tree라고 가정했습니다.)


BIT 다시 1차원 배열로 표현해보면 Binary Tree는

BIT 이 됩니다.


Binary Indexed Tree를 이용한 구간 합 구하기

Binary Indexed Tree를 이용하면 구간 합을 아주 빠르게 구할 수 있습니다. 예를 들어, 1 번째 노드부터 7 번째 노드까지의 구간 합은

BIT 와 같이 3개의 노드만 탐색하면 구할 수 있습니다.

Binary Indexed Tree의 노드 인덱스

Binary Indexed Tree의 각 노드별 인덱스를 구할 수 있으면, Binary Indexed Tree를 사용할 수 있는 준비는 거의 끝났다고 볼 수 있습니다.

각 노드마다 인덱스를 붙여보도록 하겠습니다.

BIT 이름 그대로 이진(Binary)법을 각 인덱스에 적용하면 다음과 같은 값이 되는데, 노드간 값을 잘 보면 규칙성이 있습니다.

BIT 예를 들어서, 노드 3번의 인덱스는 ‘0011’ 입니다. 노드 3번의 부모는 4번 노드로 인덱스는 ‘0100’ 입니다. 4번 노드의 부모는 8번 노드로 인덱스는 ‘1000’ 입니다.

예를 하나만 더 들어보도록 하겠습니다. 노드 5번의 인덱스는 ‘0101’ 입니다. 노드 5의 부모는 6번 노드이고, 인덱스는 ‘0110’ 입니다. 노드 6의 부모는 노드 8이며 인덱스는 ‘1000’ 입니다.

부모 노드와 자식 노드간의 규칙은 다음과 같습니다.

부모 노드의 인덱스는 자식 노드의 인덱스의 가장 마지막 '1' 값에 1을 더한 값


현재 이진 인덱스 값에서 가장 마지막에 위치한 ‘1’의 위치는 ‘index & (-index)’의 bit 연산을 통해서 얻을 수 있습니다. 즉, 현재 인덱스 값에 위의 ‘index & (-index)’ 값 을 더하면 부모 노드의 인덱스 값이 됩니다.

Binary Indexed Tree 값 업데이트 및 구간 합 구하기

Binary Indexed Tree에 값을 업데이트하거나 구간 합을 구하는 방법은 Segment Tree에서의 방법과 비슷합니다. Segment Tree와 마찬가지로 자식 노드의 값이 변경이 되면 부모 노드의 값들을 전부 업데이트해야 합니다.

그리고 자식과 부모 노드간의 관계는 앞에서 알아본 자식 노드 인덱스 가장 마지막 ‘1’ 값에 1을 더하는 방법으로 구할 수 있습니다.

값을 업데이트 할 때는 자식 노드부터 시작해서 가장 마지막 1 bit에 1을 더하면서 부모 노드를 찾아가서 값을 계속 갱신해주면 됩니다.

거꾸로 1부터 N 노드까지의 합을 구할 때는 N 노드부터 시작해서 가장 마지막 1 bit에 1을 빼면서 0이 될때까지 각 노드들의 합을 구하면 됩니다.

직접 코드로 확인해보도록 하겠습니다.


코드 (정의)

static const int MAX_TREE_SIZE = 100000;
static const int INFINITE = 9999999;
int data[] = { 0, 2, 4, 1, 7, 3, 6, 2, 5, };
int N = 8;
int bit[MAX_TREE_SIZE];

void initialize() {
    int size = 2 * N - 1;
    for (int i = 1; i <= size; i++) {
        bit[i] = 0;
    }
}

void debug() {
    for (int i = 1; i <= N; i++) {
        printf("%d ", bit[i]);
    }

    printf("\n");
}


코드 (데이터 갱신)

void update(int index, int value) {
    while (index <= N) {
        bit[index] = bit[index] + value;
        index = index + (index & (-index));
    }
}


코드 (1부터 N까지 구간 합)

int sum(int index) {
    int sum = 0;
    while (index > 0) {
        sum = sum + bit[index];
        index = index - (index & (-index));
    }

    return sum;
}

코드 (전체 소스 코드)

#include <stdio.h>

static const int MAX_TREE_SIZE = 100000;
static const int INFINITE = 9999999;
int data[] = { 0, 2, 4, 1, 7, 3, 6, 2, 5, };
int N = 8;
int bit[MAX_TREE_SIZE];

void initialize() {
    int size = 2 * N - 1;
    for (int i = 1; i <= size; i++) {
        bit[i] = 0;
    }
}

void debug() {
    for (int i = 1; i <= N; i++) {
        printf("%d ", bit[i]);
    }

    printf("\n");
}

void update(int index, int value) {
    while (index <= N) {
        bit[index] = bit[index] + value;
        index = index + (index & (-index));
    }
}

int sum(int index) {
    int sum = 0;
    while (index > 0) {
        sum = sum + bit[index];
        index = index - (index & (-index));
    }

    return sum;
}

int main(int argc, char** argv) {
    initialize();

    for (int i = 1; i <= N; i++) {
        update(i, data[i]);
    }

    // Binary Indexed Tree 출력
    debug();

    printf("Sum (1 ~ %d) : %d\n", 3, sum(3));
    printf("Sum (1 ~ %d) : %d\n", 5, sum(5));
    printf("Sum (1 ~ %d) : %d\n", 7, sum(7));

    return 0;
}