리펙토링 2강


Refactoring

지난 이야기?

많은 사람들이 C++로 C스러운 코드를 짠다.
먼저 캐스팅에 대해 알아보자.

캐스팅 이야기

C / C++의 차이

1
2
3
4
5
6
7
// casting.c
#include <stdlib.h>

int main()
{
char* p = malloc(1);
}

C에선 흔한 코드

1
2
3
4
5
6
7
8
9
// casting.cpp
#include <iostream>
#include <stdlib.h>
using namespace std;

int main()
{
char* p = malloc(1);
}

C++에선 컴파일 타임에서 에러가 난다.

왜 그럴까?

C언어의 설계 철학

C언어는 사용자를 믿고 실행시킨다. 즉 많은 책임이 사용자에게 있다. 그렇기 때문에 컴파일 타임에서 에러가 나지 않는다. 반면에 C++에선 컴파일러가 타입 체크를 해주므로 컴파일 타임에서 에러가 나는 것. 이건 개발자에 축복이다!

C++에서의 형 변환

원래 컴파일러는 포인터간의 캐스팅은 이성적이지 않다고 판단한다. 그래서 사용자가 형 변환을 시키지 않으면 암시적 형 변환은 일어나지 않는다. 하지만 void형 포인터에 한해서는 다르다. void 타입을 구체적인 타입으로 캐스팅을 하는 건 이성적인 코드라고 판단한다. void형 포인터는 아무런 연산 (역참조, 덧셈, 뺄셈)을 할 수 없지만 char형 포인터로는 연산이 가능하므로 의미가 있다고 판단하는 것이다.

근데 그렇다고 암시적 형 변환을 해주는 것은 아니다. C++에는 여러가지 형 변환이 있는데…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;

int main()
{
// 0. 명시적 형변환: 컴파일러의 타입 체크 기능을 끔
// 이렇게 짜지말것! 포인터에 대해 컴파일러가 보장해주지 않는 것이기 때문이다.
char* p1 = (char*) malloc(1);

// 1. 이성적 형 변환: static_cast
// 형 변환이 이성적이라면 캐스팅이 된다.
char* p2 = static_cast<char*>(malloc(1));

int x = 0x1;

char* p = &x; // ERROR
char* p = static_cast<char*>(&x); // ERROR

// 2. 비이성적 형 변환: char*로 재해석 해달라는 의미.
// 컴파일러의 타입 체크 기능을 끄지 않고 형 변환을 강행.
// C언어의 대부분의 형 변환을 지원한다.
char* p = reinterpret_cast<char*>(&x);


const double PI = 3.14; // * 심볼릭 상수: 이름이 있는 상수

double* p = &PI // C언어에서는 에러가 나지 않는다. 즉 런타임에서 상수성을 보장하지 않는다.
double* p = reinterpret_cast<double*>(&PI); // ERROR
// C언어의 대부분의 형 변환을 지원하지만 이것만은 예외로 지원하지 않는다.
// 이러한 캐스팅을 C언어에서 지원하는건 언어의 스펙 때문이지
// 이성적인 판단에 의한 것은 아니다.

// 3. 비상수 형 변환: const_cast
// 문법적으로 지원을 한다. 필요한 경우가 있으닌까.
// 하지만 대부분의 개발자들은 const_cast를 쓴다는 것은 설계가 잘못된 것이라고 생각한다.
double *p = const_cast<double*>(&PI);
}

Up Casting / Down Casting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
using namespace std;

class Animal {
public:
virtual ~Animal() {}
};

class Dog : public Animal {
public:
virtual void foo() {}
};

void main()
{
Animal* p1 = new Animal;

Animal* p2 = static_cast<Animal*>(new Dog); // 이성적 형 변환이니 이렇게 해야...
Animal* p2 = new Dog; // 이건 왜 되는거야?: 상속 관계이므로!
// upcasting: 형 변환 연산자를 생략할 수 있다.
// 컴파일러가 컴파일 타임에 두 클래스 간의 관계를 알고있기 때문.


Dog* pDog = p2; // ERROR: 컴파일 타임에 p2가 어떤 형일지 알 수가 없다.
// downcasting을 위해 명시적 형 변환을 하면 컴파일러 기능을 꺼버리므로 문제가 됨.
// RTTI를 사용해야 한다.


// RTTI(Runtime Type Information)
// : C언어 표준이 아니라 컴파일러가 제공해 주는 기능
// 컴파일러 옵션에 켜는게 있다.
// 이 기능을 사용하려면 class 안에 가상 함수가 하나라도 있어야함.
// Lookup Table 위에 RTTI 정보가 저장이 된다. (밑에 참조)
// 자바는 기본으로 제공이 된다.


// downcasting: dynamic_cast
// 만약 캐스팅에 실패하면 return type이 null이 나옴.
// 형 변환에 대한 안정성이 보장이 안되기 때문에 값을 확인하고 사용해야한다.
Dog* pDog = dynamic_cast<Dog*>(p2);
if(p2 == 0)
// ..
else
// ..
}

[참고] Virtual 함수에 대하여

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
using namespace std;

// * 번호 순서대로 볼 것

class Clazz {
public:
/*

void foo() {}
// 2. 여전히 크기는 1이 나온다.
// 즉 Class안에 함수가 포함되지 않는 걸 알 수 있다.
// 멤버함수는 텍스트 영역에 저장되기 때문!

*/

virtual void foo2() {}
// 3. 이건 크기가 4가 나온다!
// Dynamic binding
// 변수 목록 위에 포인터를 하나 더 만듦
// Lookup Table...

// 정리하기
};

int main()
{
Clazz obj;
cout << sizeof(obj) << endl;
// 1. 사이즈가 0이 나올꺼 같지만 1이 나온다.
// 왜일까?
// 접근을 위해선 최소한의 공간으로 메모리에 할당이 되야하기 때문.
// 그래서 C++는 클래스가 비어있더라도 1크기 만큼을 할당해서 메모리에 올린다.
}

Replace Magic number with Symbolic Constant

수동으로 처리하는 상수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;

class Window
{
public:
void set_language(int lang)
{
switch (lang)
{
case 0: cout << "KOR" << endl; break;
case 1: cout << "ENG" << endl; break;
case 2: cout << "JPN" << endl; break;
}
}
};

int main()
{
Window w;
w.set_language(0); // KOR - 여기서 사용된 숫자가 Magic Number라고 한다 (?)
}

이런식의 처리는…

  1. 사용된 상수의 의미가 모호 -> 가독성이 떨어짐
  2. 외부에 대한 유연성이 떨어짐 -> 확장성이 떨어짐

매크로 상수 도입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
using namespace std;

// 매크로 상수 사용
#define KOR (0)
#define ENG (1)
#define JPN (2)

class Window
{
public:
void set_language(int lang)
{
switch (lang)
{
case KOR: cout << "KOR" << endl; break;
case ENG: cout << "ENG" << endl; break;
case JPN: cout << "JPN" << endl; break;
}
}
};

int main()
{
Window w;
w.set_language(KOR);
}
  1. 가독성이 높아지지만 유지보수가 어렵다.
  2. 컴파일러의 고급 기능(디버깅)을 지원 받을 수가 없다. (인터프리터가 처리하는 것이기 때문이다)

컴파일러 상수 (컴파일 타임 상수)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;

const int KOR = 0;
const int ENG = 1;
const int JPN = 2;

class Window
{
public:
void set_language(int lang)
{
switch (lang)
{
case KOR: cout << "KOR" << endl;
case ENG: cout << "ENG" << endl;
case JPN: cout << "JPN" << endl;
}
}
};

int main()
{
Window w;
w.set_language(KOR);
}
  1. 디버깅시 watch 상수 값을 확인할 수 있다.
  2. 대신 런타임에서 메모리 사용량이 증가하긴함.

enum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
using namespace std;

/*
// enumeration
enum { KOR = 0, ENG, JPN };

// C언어에서는 enum은 완전한 타입이 아니라 int의 호환형이다.
// 그래서 int가 인수인 자리에도 enum이 들어갈 수 있다.
// 하지만 C++에선 하나의 타입으로 인정받는다.
// 대신 태그를 붙여서 선언해야함

enum LANG { KOR = 0, ENG, JPN };

// 하지만 전역에 이렇게 선언해 버리면 네임 스페이스가 오염되므로
// 클래스 안으로 넣어버리는게 더 좋다.
*/

class Window
{
public:
enum LANG { KOR = 0, ENG, JPN };

void set_language(LANG lang)
{
switch (lang)
{
case KOR: cout << "KOR" << endl; break;
case ENG: cout << "ENG" << endl; break;
case JPN: cout << "JPN" << endl; break;
}
}
};

int main()
{
Window w;
// w.set_language(KOR); // 전역 변수에 선언한 경우 사용법
w.set_language(Window::KOR); // 클래스 안에 선언한 경우 사용법
}
  1. 타입으로 인정 받을 수 있다.
  2. 사용이 편함

Null Object

필요성에 대하여

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <string>
using namespace std;

// 각 객체를 하나의 타입으로 처리 하기 위해 부모 클래스를 설계한다.
// write 함수가 반드시 구현될 수 있도록 인터페이스를 도입
struct ILog
{
virtual void write(string msg) = 0;
};

class ConsoleLog : public ILog
{
public:
void write(string msg) { cout << "Console: " << msg << endl; }
};

class FileLog : public ILog
{
public:
void write(string msg) { cout << "Console:" << msg << endl; }
};

class LogService
{
public:
ILog* pLog;

LogService(ILog* p) { pLog = p; }

void run()
{
//...
if (pLog != 0) pLog->write("url error");

//...
if (pLog != 0) pLog->write(("file error");
}
};

LogService에서 보면 Null 포인터를 항상 체크해줘야한다.
굳이 해야할까?

싱글톤

싱글톤을 위한 문법 규칙 3가지

  1. 객체의 임의 생성을 막기 위해 생성자를 private 영역에 정의
  2. 유일한 객체를 반환하기 위한 정적 인터페이스 도입
  3. 대입과 복사를 금지하기 위해 대입 연산자 함수와 복사 생성자 함수를 private 영역에 정의

[참고] Exception

1
2
3
4
int add(int a, int b)
{
return a + b;
}

C에선 리턴 값이 함수의 성공과 실패를 반환하기도 하고 함수의 출력을 반환하기도함. 즉 모호함.
C++에선 이를 분리해놨다. 예외를 통해 성공과 실패를 알리고 리턴 값이 함수의 출력을 의미하게 함.

Cursor 구현하면서 싱글톤 들여다보기

싱글톤을 만들려니 thread safety 하지 않아서 쓰레드를 도입하니
exception safety 하지 않더라. (데드락 발생)
그래서 AutoLock 기법을 도입.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#include <iostream>
using namespace std;

// 인스턴스를 최초 생성할 때 thread safety 하지 못하므로
// Mutex 도입

class Mutex
{
public:
void lock() { cout << "LOCK" << endl; }
void unlock() { cout << "UNLOCK" << endl; }
};

template<typename T> class AutoLock
{
T& obj;

public:
AutoLock(T& o) : obj(o) { obj.lock(); }
AutoLock(T* p) : obj(*p) { obj.lock(); }
~AutoLock() { obj.unlock(); }
};

class Cursor
{
// 1. 객체의 임의 생성을 막기 위해
// 생성자를 private 영역에 정의
Cursor() {}

// 3. 대입과 복사를 금지하기 위해 대입 연산자 함수와
// 복사 생성자 함수를 private 영역에 정의

/*
// 이렇게 막아줄 수 있지만...
Cursor(const Cursor& c) {}
Cursor& operator=(const Cursor& c) {}
// 클래스 내부에서의 호출은 못막음 (아래 foo 함수)
*/

/*
// 따라서
Cursor(const Cursor& c);
Cursor& operator=(const Cursor& c);
// 이렇게 해서 링킹 타임에 에러를 나게 만든다!
*/

// 근데 이건 가독성이 떨어짐.
// 그래서 새 표준에선 다음과 같이 쓴다.
Cursor(const Cursor& c) = delete;
Cursor& operator=(const Cursor& c) = delete;


static Mutex mutex;
// 고프 싱글톤
static Cursor* pInstance; // 이건 단순한 선언에 불과하다.
// 초기화 해줘야함!

public:
// private에 정의만 하면 내부에서 복사 생성자를 호출 할 수 있다.
// 어떻게 막아야 할까?
// 컴파일 타임에선 함수가 있고 없고의 문제 보단 제대로 타입이 잘 들어 간건지 타입 체크만 한다.
// 링킹 타임에서 함수가 실제로 바인딩 될때 기계어가 없는걸 보고 에러를 나게 해야한다.
void foo()
{
Cursor c;
Cursor c1 = c;
Cursor c2;
c2 = c;
}


// 2. 유일한 객체를 반환하기 위한 정적 인터페이스 도입
static Cursor* getInstance() // self in Android
{
/*
static Cursor cursor; // 데이터 영역에 선언하는건 마이어's 싱글톤
// 이거보단 고프 싱글톤을 많이 씀. (힙에 선언)
return &cursor;
*/

// mutex.lock(); // 동적할당에 실패하면 예외 발생
// -> unlock 호출이 안됨
// -> Dead Lock 발생

// RAII (Resource Acquisition is Initialization)
// : 소멸될 때 자원을 획득하자! (?)
AutoLock<Mutex> l(mutex); // 생성자와 파괴자를 통해서 lock/unlock함
// 예외 발생시 스택을 풀면서 나가는 성질을
// 이용한 것이다. (Stack Unwinding)
// 스택이 풀리면서 파괴자가 호출됨.
// 이를 통해 Exception Safety를 보장
if (pInstance == 0)
pInstance = new Cursor;
// mutex.unlock();

return pInstance;
}
};
Cursor* Cursor::pInstance = 0; // 초기화


void main()
{
Cursor* c1 = Cursor::getInstance();
Cursor* c2 = Cursor::getInstance();

cout << &c1 << endl;
cout << &c2 << endl;

//Cursor c3(*c1);
//Cursor c3 = *c1; // 복사 생성자들... 이놈들도 문제다!

Cursor* c3;

cout << &c3 << endl;
}

싱글톤을 이용한 NullLog 클래스 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <iostream>
#include <string>
using namespace std;

struct ILog
{
virtual void write(string msg) = 0;

// Null 객체 확인을 위한 인터페이스를 제공해야 한다.
virtual bool is_null() { return false; };
};

class NullLog : public ILog
{
static NullLog* pInstance;
public:
static NullLog* getInstance()
{
if(pInstance == 0)
pInstance = new NullLog;

return pInstance;
}

void write(string msg) {}
bool is_null() { return true; }
};

class ConsoleLog : public ILog
{
public:
void write(string msg) { cout << "Console: " << msg << endl; }
};

class FileLog : public ILog
{
public:
void write(string msg) { cout << "Console:" << msg << endl; }
};

class LogService
{
public:
ILog* pLog;

LogService(ILog* p) { pLog = p; }

void run()
{
//...
pLog->write("url error");

//...
pLog->write("File error");
}
};

void main()
{
NullLog nullObj;
LogService log(&nullObj);
log.run();
}

Null 포인터를 체크할 필요가 없다.

아직 정리중

[참고] 추천 서적

Effective C++

C++ Refactoring을 배우고 싶으면 이 책을 봐라. C++에선 포인터가 있어서 할 말이 많지만, Java는 그렇지 않기 때문에 설계적인 측면의 내용을 주로 다룸.

창시자의 책

엄청 자세히 설명되어 있음. 1000 페이지 정도 된다.