OOP思维导论
数据是什么?
程序员每天都在和数据打交道,但我们能否清晰地回答这样一个问题呢:到底数据是什么?毫不夸张地讲,这个看似简单的问题在理解OOP思想中处于至关重要的地位。我们先从整数的例子来探讨数据的本质:整数..., -1,0, 1, 2,...的本质是什么呢?首先,我们对数据的直观认识来源于其符号表示"0", "1","2"等,但我们很容易意识到孤立地把数1看作一个符号"1"是没有意义的,符号的意义无法脱离它所处的环境而单独存在。这里的环境即整数运算,我们必须从运算中考察其性质。作为整数的1比符号"1"有更多地内涵(注:这里的符号不是计算机中的字符串,只是一种记号)。数据中蕴含的东西除了看得见的直观的符号,还有与之相关的运算和运算规则,它们是有机联系的整体。我们给这个整体取个名字:类型(Type)。用数学的语言,类型定义了符号集和运算规则集,其中,符号集包括运算符(Operator)和操作数(Operand),记为:Type=(SymbolSet, RuleSet)。对于整数类型integer,SymbolSet={..., "-1", "0", "1", "2,", ...}∪{"=", "<", ">", "+", "-", "*", "/", "%", "^"},RuleSet=整数运算规则集(加法结合律,交换律,加减逆运算,乘法与加法关系等)。
为了与无类型的符号区分,强调数据的类型性,我们把数据又称为某种类型的对象(Object),比如:1是整数类型的对象。这里的对象并非OOP语言的类的实例,而是一种数学概念。
类型不变性
一个有趣的现象是,在类型定义中,符号集的选择并不是那么重要。如果我们不用阿拉伯数字,而是采用罗马数字来表示整数,整数的性质并未发生变化。著名的丘奇计数(Church Numberals)用“把任何其他函数f映射到它的n重函数复合”的lambda函数形式表示自然数n:
0 ≡ λf.λx. x
1 ≡ λf.λx. f x
2 ≡ λf.λx. f (f x)
3 ≡ λf.λx. f (f (f x))
...
n ≡ λf.λx. fn x
加法函数 plus(m,n) = m + n 利用了恒等式 f(m + n)(x) = fm(fn(x))。
plus ≡ λm.λn.λf.λx. m f (n f x)
乘法函数 times(m,n) = m * n 利用了恒等式 f(m * n) = (fm)n。
mult ≡ λm.λn.λf. n (m f)
指数函数 exp(m,n) = mn 由Church数定义直接给出。
exp ≡ λm.λn. n m
Church用函数定义自然数的方式看似艰深晦涩,其实只要理解了数据的本质就不难理解函数也是数据。甚至函数表示法比用阿拉伯数字更加接近数据的本质,因为函数符号是有规律的,符号中蕴含了运算,更体现了数据与运算的有机联系。
上面的例子是为了说明,符号集的选择并不那么重要,在采用不同符号集的情况下,符号间的关系保持不变,这种变化中的不变才是类型的本质,我们称之为类型规范或类型不变性(Type Invariant)。比如,先入后出(FILO)就是堆栈类型stack的不变性,它是对于stack对象push和pop两种操作的一种约束性。stack的本质不在于压栈弹栈如何实现名字是不是叫push/pop,而在于压栈和弹栈是否保持了FILO关系。FILO不太容易像整数类型的四则运算一样进行形式化表达,但等价于下面的形式化表示:
{ push(S,x); V ← pop(S) } is equivalent to { V ← x }
即连续的两次操作“1.将x压入堆栈S;2.弹栈并赋值给V”其效果等价于“将x赋值给V”。这种形式化的表述与FILO的规范性表述是等价的。
封装和数据抽象
接着stack的例子引出了另外一个话题。如果是用C语言实现一个stack,我们通常会定义一个结构体Stack存储数据,并分别实现了push和pop两个函数操作数据以实现压栈和弹栈。我们的问题是:应该如何来为这个stack编写单元测试用例呢?一种思路是,为push和pop分别编写测试用例:
测试方案1:
/*C语言*/
void test_push(){
Stack *pStack = create_stack();//创建结构体stack
push(pStack, 1);
ASSERT_EQUAL(1, pStack->items[0]); /*检查状态*/
push(pStack, 2);
ASSERT_EQUAL(2, pStack->items[1]); /*检查状态*/
}
void test_pop(){
Stack *pStack = create_stack();/*创建结构体stack*/
pStack->size = 2;
pStack->items[0] = 1;
pStack->items[1] = 2;/*数据准备*/
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2);
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2);
}
与之相对的是另一种思路,把push和pop联合起来测试FILO:
测试方案2:
/*C语言*/
void test_FILO(){
Stack *pStack = create_stack();
push(pStack, 1);
push(pStack, 2);
int item2 = pop(pStack));
ASSERT_EQUAL(2, item2); /*检查FILO*/
int item1 = pop(pStack);
ASSERT_EQUAL(1, item2); /*检查FILO*/
}
虽然这是C语言,但过程思维和OO思维的差别已经完全体现出来了。如果你写出的是类似方案1的测试用例,那么你是过程思维;反之,如果你写出的是方案2,虽然使用的是C语言,但你已经具备了一些OO思维!过程式与对象式作为两种程序设计范式(Programming Paradigm)体现了两种不同的程序设计思维。过程式以过程抽象为主,把要解决的问题抽象为一个过程,再分解为若干子过程分而治之。过程式程序的砖块是过程,所以我们说以单个函数为基本单元的测试方案1体现了过程思维。而对象式以数据抽象为主,把要解决的问题映射到类型定义,对象是程序的砖块。测试方案2注意到了push和pop是在stack类型不变性下功能内聚的,所以我们说它体现了OO思维。如同看到整数1就自然联想到它可以参与加减运算,并且加减运算是逆运算一样;看到stack类型对象我们就应该联想到可以对它进行push和pop操作,且push和pop具有FILO的关系。可见,OO思维并不是体现在用不用OOP语言,有没有定义类,而在于是否有类型意识。
类型(Type)是数学概念更强调语义,类(Class, Struct)是OOP语言为定义类型所提供的语法机制。从语义上讲,类定义了抽象数据类型(Abstract Data Type),这个过程称为数据抽象(Data Abstraction);从语法上讲,类定义了对外接口,隐藏了内部实现,这个过程称为封装(Encapsulation)。注:Class通常用于定义引用类型(Reference Type),Struct通常用于定义值类型(Value Type),二者的区别在此不详细介绍,本文的“类”包括了Class和Struct。
为了定义stack类型,我们可以用C++写出Stack类声明:
//C++
//Stack.h
class Stack{
public:
Stack();
~Stack();
public:
push(int i);
int pop();
private:
std::vector<int> items;
}
Stack.h是关于Stack类的声明,它定义了Stack类对外接口的语法特征,使用者会直接和Stack.h打交道而不会看到Stack.cpp的实现。所以,类声明和头文件也是一种抽象机制。但是,除了语法上的定义类型相关的操作外,还必须满足类型的语义。我们可以用一个单元测试用例代表客户来表达对Stack类满足FILO的期待:
//C++
void test_FILO(){
Stack stack;
push(stack, 1);
push(stack, 2);
int item2 = stack.pop();
ASSERT_EQUAL(2, item2); /* 检查FILO*/
int item1 = stack.pop();
ASSERT_EQUAL(1, item2); /*检查FILO*/
}
Stack类声明和单元测试结合起来就从语法和语义完成对stack类型的数据抽象。至于具体的实现则被隐藏起来,我们可以有多种不同的实现,只要语法上符合Stack类声明,语义上符合FILO。但是严格地讲,C++这种头文件的类声明机制还不够完美。头文件的问题在于虽然它是面向用户的,用户不需要知道private的东西,但类声明却要求包含private的内容,相当于“走光”。比如:用户拿到上面的Stack.h,看到private的std::vector<int>items就知道Stack内部是基于vector实现的,比如:
//C++
//Stack.cpp
class Stack{
public:
Stack() {}
~Stack() {}
public:
push(int i) {items.push_back(i);}
int pop(
int i = items.back();
items.pop_back();
return i;
);
private:
std::vector<int> items;
}
C++的Pimp惯用法的作用之一便是解决类声明暴露内部实现的问题,让类声明屏蔽掉用户不该也不需要看到的东西:
//C++
//Stack.h
class Stack{
public:
Stack();
~Stack();
public:
push(int i);
int pop();
private:
class StackImpl;//StackImpl类声明
StackImpl *pStackImpl;
}
数据抽象的过程包括了变与不变两个部分:不变的是类的接口语法和语义规范,变化的是类的具体实现。于是,类具有阴阳两面:阳面面向用户是相对固定的,阴面面向实现者是相对易变的。易变的阴面被局部化(Locality)在类内部,而稳定的阳面则被放心地到处使用。阳面的设计属于软件设计范畴;阴面的实现属于算法和数据结构范畴。这种变与不变中体现了一种信息隐藏(Information Hiding)原则,不过信息隐藏原则是普遍原则,凡是抽象都属于信息隐藏,对于数据抽象特别需要重视的是类型的整体性不变性。
TDD
上面先类声明和测试用例,后实现的方式暗合了当前流行的测试驱动开发方法(TDD)。TDD从一定程度上促使程序员从阳面思考类的行为,自然地获得易测性和高代码覆盖率。一般来讲,TDD可以促使做出好的设计,但我也曾见过(误)用TDD把设计搞得更糟糕的情况。我们来看什么时候TDD把设计搞得更糟糕,比如:对于上面的stack需求,有人用TDD小步迭代写出用例:
//C++
void test_push(){
Stack stack;
stack.push(1);
}
这时,这位程序员发现无法验证push方法是否成功,于是乎想到“通过依赖注入(Dependency Injection)把vector与stack解耦,检查vector状态”,于是测试驱动成为:
//C++
void test_push(){
std::vector<int> items;
Stack stack(items); //构造函数依赖注入
stack.push(1);
ASSERT_EQUAL(1, items[0]);//检查状态
}
依赖注入、TDD,这些流行的软件思想都用上了,不过好像在哪里见过这样的代码?没错,和最初C语言的那个每个函数一个测试用例本质上不是一样的吗?哈哈!如果说前面那位用C语言写出结合push和pop测试FILO的朋友是“大智若愚”;我们只能给眼前这位兄弟一个“大愚若智”了。一个用上依赖注入、解耦,等“先进”软件思想的设计居然本质上是面向过程程序的。我们并没有说面向过程并就一定不好,只是恐怕这位喜欢用先进软件思想的朋友并未意识到自己原来是在写面向过程程序吧?!可见,设计的关键不在于我们是否在用某一门语言,甚至不在于你是不是用了什么听起来时髦的思想,而在于是否对编程的本质理解得深入。
“高内聚与低耦合”是软件中的两个方面。抽象数据类型首先体现了一种自身的高内聚性,如:push和pop属于FILO的功能内聚(Functional Cohesion)。耦合是指对象间的关系,UML主要区分依赖、关联、聚合、组合4种横向关系,一般只有前3种关系适合采用依赖注入达到低耦合(可参考我之前的这篇文章)。目前开发社区似乎过分地强调了“低耦合”,忽略了“高内聚”,导致很多人“言必称解耦”,这是对OOP非常大的误解。归根结底,只有抓住事物的本质,做到该高内聚的高内聚,该低耦合的低耦合,才能算是高质量的软件设计。
小结
本文是面向OOP初学者的一个思维引导,也是我自己的一个阶段性学习总结。OOP和程序设计博大精深,需要不断地学习,思考和探索。热情欢迎朋友们批评指正本文的错误和不足,愿与大家共同进步!
参考文献
郑晖,《冒号课堂——编程范式与OOP思想》
Babara Liskov, Data Abstraction and Hierarchy