Skip to content

Commit 58c4f9f

Browse files
committed
null object
1 parent a208578 commit 58c4f9f

File tree

2 files changed

+154
-0
lines changed

2 files changed

+154
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- [x] Chapter11: Flyweight. 享元模式。Boost库中Flyweight的实现,以及Bimap
2121
- [ ] Chapter12: Proxy。翻译了智能指针、属性代理、虚代理。通信代理。
2222
- [ ] Chapter16: Iterator。
23+
- [ ] Chapter19: Nulll Object. 涉及到对代理模式和pimpl编程技法的运用,以及std::optional
2324
- [ ] Chapter20: Observer. 已翻译 属性观察者、模板观察者Observer\<T>、可观察Observable\<T> 、依赖问题和取消订阅与线程安全。
2425
- [ ] Chapter22: Strategy. 翻译了动态策略。静态策略
2526
- [x] Chapter23: Template Method. 模版方法模式和策略模式的异同。
@@ -141,6 +142,7 @@ class Queue
141142
### 第7章-桥接:Pimpl编程技法-减少编译依赖
142143

143144
PImpl(Pointer to implementation)是一种C++编程技术,其通过将类的实现的详细信息放在另一个单独的类中,并通过不透明的指针来访问。这项技术能够将实现的细节从其对象中去除,还能减少编译依赖。有人将其称为“编译防火墙(Compilation Firewalls)”。
145+
144146
#### Pimpl技法的定义和用处
145147

146148
在C ++中,如果头文件类定义中的任何内容发生更改,则必须重新编译该类的所有用户-即使唯一的更改是该类用户甚至无法访问的私有类成员。这是因为C ++的构建模型基于文本包含(textual inclusion),并且因为C ++假定调用者知道一个类的两个主要方面,而这两个可能会受到私有成员的影响:

docs/chapter-19-null_object.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
### 第19章 空对象
2+
3+
我们并不能总能选择自己想使用的接口。例如,我宁愿让我的车自己开车送我去目的地,而不必把100%的注意力放在道路和开车在我旁边的危险疯子身上。软件也是如此:有时你并不是真的想要某一项功能,但它是内置在接口里的。那么你会怎么做呢?创建一个空对象。
4+
5+
#### 场景
6+
7+
假设继承了使用下列接口的库:
8+
9+
```c++
10+
struct Logger
11+
{
12+
virtual ~Logger() = default;
13+
virtual void info(const string& s) = 0;
14+
virtual void warn(const string& s) = 0;
15+
}
16+
```
17+
18+
这个库使用下面的接口来操作银行账户:
19+
20+
```c++
21+
struct BankAccount
22+
{
23+
std::shared_ptr<Logger> log;
24+
string name;
25+
int balance = 0;
26+
BankAccount(const std::share_ptr<Logger>& logger, const string& name, int balance):
27+
log{ logger },
28+
name{ name },
29+
balance {balance}
30+
{
31+
// more members here
32+
}
33+
};
34+
```
35+
36+
事实上,`BankAccount`可以拥有如下的成员函数:
37+
38+
```c++
39+
void BankAccount::deposit(int amount)
40+
{
41+
balance += amount;
42+
log->info(("Deposited $" + lexical_cast<string>(amount)
43+
+ " to " + name + ", balance is now $"
44+
+ lexical_cast<string>(balance));
45+
}
46+
```
47+
48+
好了,这个实现有什么吗?如果你确实需要日志记录,也没有问题,你只需实现自己的日志记录类...
49+
50+
```c++
51+
struct ConsoleLogger : Logger
52+
{
53+
void info(const string& s) override
54+
{
55+
cout << "INFO: " << s << endl;
56+
}
57+
void warn(const string& s) override
58+
{
59+
cout << "WARNNING!!!" << s << endl;
60+
}
61+
};
62+
```
63+
64+
你可以直接使用它。但是,如果你根本不想要日志记录呢?
65+
66+
### 空对象
67+
68+
我们再来仔细看下`BankAccount`的构造函数
69+
70+
```c++
71+
BankAccount(const shared_ptr<Logger>& logger, const string& name, int balance)
72+
```
73+
74+
由于构造函数接受一个日志记录器,因此传递一个未初始化的`shared_ptr<BankAccount>`是不安全的。`BankAccout`可以使用指针之前,在内部检查指针是否为空,但你不知道它是否这样做了,因为没有额外的文档是不可能知道的。
75+
76+
因此,唯一可以传入`BankAccount`的是一个空对象,一个符合接口但不包含功能的类:
77+
78+
```c++
79+
struct NullLoggor : Logger
80+
{
81+
void info(const string& s) override { }
82+
void warn(const string& s) override { }
83+
};
84+
```
85+
86+
### 共享指针不是空对象
87+
88+
值得注意的是,`shared_ptr`和其他智能指针类都不是空对象。空对象是保留正确操作(执行无操作)的对象。但是,使用对未初始化的智能指针会崩溃会导致程序崩溃:
89+
90+
```c++
91+
shared_ptr<int> n;
92+
int x = *n + 1; // yikes!
93+
```
94+
95+
值得注意的是,从调用的角度来看,没有办法使智能指针是安全的。换句话说,如果`foo`没有初始化,那么`foo->bar()`会神奇地变成一个空操作,那么你不能编写这样的智能指针。原因是前缀*和后缀->操作符只是代理了底层(原始)指针。没有办法对指针做无操作。
96+
97+
#### 改进设计
98+
99+
停下来想一想:如果`BankAccount`在你的控制之下,你能改进接口使它更容易使用吗?这里有一些想法:
100+
101+
- 在所有地方都进行指针检查。这就理清了`BankAccount`的正确性,但并没有消除库使用者的困惑。请记住,你仍然没有说明指针可以是空的。
102+
- 添加一个默认实参值,类似于`const shared_ptr<Logger>& logger = no_logging`其中`no_logging``BankAccount`类的某个成员。即使是这样,你仍然必须在想要使用对象的每个位置对指针值执行检查
103+
- 使用可选(`optional`)类型。它的习惯用法是正确的,并且可以传达意图,但是会导致传入一个`optional<shared_ptr<T>>`以及随后检查可选项是否为空。
104+
105+
#### 隐式空对象
106+
107+
这里有一个激进的想法,需要进行两步操纵。它把涉及到把日志记录过程细分为调用(我们想要一个好的日志记录器接口)和操作(日志记录器实际做的事情)。因此,请考虑以下几点:
108+
109+
```c++
110+
struct OptionalLogger : Logger
111+
{
112+
shared_ptr<Logger> impl;
113+
static shared_ptr<Logger> no_logging;
114+
Logger(const shared_ptr<Logger>& logger) : impl { logger } { }
115+
virtual void info(const string& s) override
116+
{
117+
if(impl) impl->info(s); // null check here
118+
}
119+
// and similar checks for other members
120+
};
121+
122+
// a static instance of a null object
123+
shared_ptr<Logger> BankAccount::no_logging{};
124+
```
125+
126+
现在我们已经从实现中抽象出了调用。我们现在要做的是像下面这样重新定义`BankAccount`构造函数:
127+
128+
```c++
129+
shared_ptr<OptionalLogger> logger;
130+
BankAccount(const string& name, int balance, const shared_ptr<Logger>& logger = no_logging) :
131+
log{ make_shared<OptionalLogger>(logger) },
132+
name{ name },
133+
balance{ balance } { }
134+
```
135+
136+
如您所见,这里有一个巧妙的诡计:我们使用一个`Logger`,但存储一个`OptionalLogger`(这是代理设计模式)。然后,对这个可选记录器的所有调用都是安全的-它们只有在底层对象可用时才“发生”:
137+
138+
```c++
139+
BankAccount account{ "primary account", 1000 };
140+
account.deposit(2000); // no crash
141+
```
142+
143+
上例中实现的代理对象本质上是`Pimpl`编程技法的自定义版本。
144+
145+
#### 总结
146+
147+
空对象模式提出了一个API设计的问题:我们可以对我们所依赖的对象做什么样的假设?如果我们取一个指针(裸指针或智能指针),那么是否有义务在每次使用时检查该指针?
148+
149+
150+
如果你觉得没有这种义务,那么用户实现空对象的唯一方法是构造所需接口的无操作实现,并将该实例传递进来。也就是说,这只适用于函数:例如,如果对象的字段也被使用,那么你就遇到了真正的麻烦。
151+
152+
如果你想主动支持空对象作为参数传递的想法,你需要明确:要么指定参数类型为`std::optional`,给参数一个默认值,暗示它是一个内置的空对象(例如,= no_logging),或只写文档说明什么样的值应当出现在这个位置。

0 commit comments

Comments
 (0)