冒号和他的学生们
25.软件应变
潜其心能观天下之理,定其心能应天下之变 ——《吕坤·呻吟语》
第七课刚一开堂,冒号就提了一个问题:“如果把一个Java程序中所有的private关键字换成public,请问该程序还能工作吗?”
“应该还能工作,除非——此前不能工作。”问号小心翼翼地回答。
冒号接着问:“既然如此,何必费事区分它们呢?”
叹号嘴一撇:“当然是为了信息隐藏啰。”
冒号步步紧逼:“隐藏什么信息呢?又为什么要隐藏?”
叹号应对:“对象的状态需要隐藏。如果一个对象的状态直接暴露在外,让客户随意修改,可能会破坏对象的内在逻辑。”
冒号依旧穷追不舍:“那为什么对象的方法有些也需要隐藏?”
“以前我也有此疑问,看别人代码时最感兴趣的就是那些私有方法。”引号不打自招。
逗号逗他:“看来你患有偷窥癖哦。”
引号暗暗踢了逗号一脚:“现在我明白了,这是为了实现数据抽象,将接口与实现分离开来。”
冒号仍不罢休:“这种抽象究竟有何实际好处?”
句号抢答:“一方面,抽象接口描述了一个类最本质的行为特征;另一方面,具体实现随时可能变动,隐藏它们可以保证这种变动不会波及客户代码。”
“说到点子上了!”冒号终于停止了追问,“软件与硬件之别,不仅是无形与有形之别,更是变化与固化之别。所谓变化,指源代码随时可能因需而变。一个软件修改维护的时间通常会超过编写时间,越复杂越成熟的程序越是如此。软件的难点有二:其一是逻辑的复杂,其二是需求的变化。许多程序员看重前者而看轻后者,大部分时间花在寻求解决方案上,而不是在选择解决方案上。他们目眩于奇技淫巧却不解大巧若拙之妙,殊不知充满技巧的代码不仅难于理解而易于出错,且因其普适性低而受变化的冲击更大。众所周知,比武时最忌招式用老,老即难以变化,一旦为对手看破则后果不堪设想。同样,动不动凌空跃起只是影视作品中招徕眼球的花哨场面,实战中很少出现,盖因空中不易变招。当然凡事皆有度,无一招用老,便无一招用实,难以完成致命一击。反映在软件上,那就是过度设计会带来不必要的复杂和效率损失。”
众人均想,又上起久违的武术课了。
冒号滔滔不绝:“一言以蔽之,软件之软,体现在适应变化的能力。许多编程设计思想包括OOP的思想都是以此为主题的,抽象与封装便是典型代表。抽象一个对象模型即是将一类对象最本质因而最不易变化的部分提炼出来,而封装——准确地说是信息隐藏——则是将非本质、容易变化的部分隐藏起来,从而将一个类划分为阴阳两面。由于变化多发生在阴面,对外是屏蔽的,因此修改该面毫无累及客户之忧,由此提高了软件的抗变能力。有些人误认为信息隐藏是出于软件安全(security)的考虑,实乃是似是而非的皮相之见。”
问号提问:“软件的变化主要有哪些?”
“软件的变化大致分两种:一种是出于内在需求而作的结构性变化,通常以改善软件质量为目的,即所谓的重构(refactoring);一种是出于外在需求而作的功能性变化,通常以满足客户需要为目的。理想的抽象与封装,应能完全避免第一类变化对于客户代码的影响,也能最大限度地降低第二类变化的副作用。只是知易行难,为细微的变化而付出巨大代价的例子比比皆是。‘千年虫’就是一个最典型的例子,而当32 位的IPv4 全部换成128位的IPv6 ,其代价也不遑多让。从中可以看出,信息隐藏,尤其是结构性信息隐藏是多么的重要!下面看一个简单的例子。”冒号打开幻灯片——
// 用直角坐标实现的复数类
public class Complex
{
private double x;
private double y;
public Complex(double x, double y)
{
this.x = x;
this.y = y;
}
public double real() { return x; }
public double imaginary() { return y; }
public double modulus() { return StrictMath.hypot(x, y); }
public double argument() { return StrictMath.atan2(y, x); }
public Complex add(Complex other)
{
return new Complex(x + other.x, y + other.y);
}
public Complex multiply(Complex other)
{
return new Complex(x * other.x - y * other.y,
x * other.y + y * other.x);
}
}
“这是一个用直角坐标实现的复数Java类,为简明起见,仅仅实现了实部、虚部、模、辐角、加法和乘法等运算。同样地,我们也可以用极坐标来实现。”冒号投影出另一段代码——
// 用极坐标实现的复数类
public class Complex
{
private double r;
private double theta;
public Complex(double x, double y)
{
r = StrictMath.hypot(x, y);
theta = StrictMath.atan2(y, x);
}
public double real() { return r * StrictMath.cos(theta); }
public double imaginary() { return r * StrictMath.sin(theta); }
public double modulus() { return r; }
public double argument() { return theta; }
public Complex add(Complex other)
{
return new Complex
(r * StrictMath.cos(theta) + other.r * StrictMath.cos(other.theta),
r * StrictMath.sin(theta) + other.r * StrictMath.sin(other.theta));
}
public Complex multiply(Complex other)
{
Complex product = new Complex(0, 0);
product.r = r * other.r;
product.theta = theta + other.theta;
return product;
}
}
句号似已深明其意:“这两个类的接口相同而实现方式不同,它们的区别是结构性的,而不是功能性的。就实现效率而论,直角坐标便于加减运算,而极坐标便于乘除、乘方开方等运算。实现者可能会为采用何种方案而举棋不定,好在由于隐藏了结构性信息,即使以后修改了实现方案,也不会影响客户。”
冒号补充道:“如果将代码移植到C++,修改了实现方案,还是可能在一定程度上影响客户的。”
叹号有些惊讶:“为什么?C++不也是OOP语言吗?”
冒号解释:“由于C++需要头文件,即使私有成员也必须在头文件中声明。这意味着改动任何私有数据结构甚至私有方法的签名,所有包含该头文件的源代码虽不必改写,却需要重新编译链接。这对大型程序来说通常是难以忍受的,同时也说明设计与语言息息相关的。如果一个设计者只是高高在上,完全不考虑语言细节,难免流于纸上谈兵。”
逗号问道:“为什么Java不需要头文件呢?”
“因为Java、C#包括D语言中类似头文件的信息,已经在编译时自动提取并保存了。”冒号道出缘由,“出于历史原因和效率上的考虑,C++仍沿用C的头文件用法,成为除指针和内存管理之外最令人头痛的问题。因此在C++中应尽可能地使用前置声明(forward declaration),减少包含的(included)头文件。另外,可以将一些私有静态(private static)成员从头文件转移到实现代码中,以匿名命名空间(anonymous namespace)的方式来实现完全隐藏。此外还有一个非常有用的技巧——柄/体(handle/body)模式或称桥梁模式(bridge pattern),可以将接口与实现完全分开。这种模式不仅可以解决C++中的头文件问题,对Java等不需要头文件的语言也是有用的。下面我们用这种模式重新实现Complex类。”
幻灯一闪,新的源码出现在众人眼前——
// 复数计算接口ComplexImpl
public interface ComplexImpl
{
public double real();
public double imaginary();
public double modulus();
public double argument();
public Complex add(Complex other);
public Complex multiply(Complex other);
}
// 用直角坐标实现的ComplexImpl
public class ComplexCartesianImpl implements ComplexImpl
{
private double x;
private double y;
public ComplexCartesianImpl(double x, double y)
{
this.x = x;
this.y = y;
}
public double real() { return x; }
public double imaginary() { return y; }
public double modulus() { return StrictMath.hypot(x, y); }
public double argument() { return StrictMath.atan2(y, x); }
public Complex add(Complex other)
{
return new Complex(x + other.real(), y + other.imaginary());
}
public Complex multiply(Complex other)
{
return new Complex(x * other.real() - y * other.imaginary(),
x * other.imaginary() + y * other.real());
}
}
// 用极坐标实现的ComplexImpl
public class ComplexPolarImpl implements ComplexImpl
{
private double r;
private double theta;
// 以下省略。。。
}
// 用桥梁模式实现的复数类
public class Complex
{
private ComplexImpl impl;
public Complex(double x, double y)
{
impl = new ComplexCartesianImpl(x, y);
//或者:impl = new ComplexPolarImpl(x, y);
}
public double real() { return impl.real(); }
public double imaginary() { return impl.imaginary(); }
public double modulus() { return impl.modulus(); }
public double argument() { return impl.argument(); }
public Complex add(Complex other) { return impl.add(other); }
public Complex multiply(Complex other) { return impl.multiply(other); }
}
冒号进而指出:“这是桥梁模式的简化版。稍加改进,我们不仅可以在编译期间决定具体实现方式,甚至可以让客户在运行期间选择实现方式。你们课后不妨试试。”
引号一拍大腿:“妙!如此既免除了实现者抉择的烦恼,也给赋予使用者更大的自由,可谓一举两得啊。”
句号也道:“信息隐藏虽能将抽象接口与具体实现分离,但仍然封装在同一类中。桥梁模式则让二者彻底解耦(decouple),增强了对变化的适应力,具有更大的灵活性和可扩展性。”
“当然这也增加了一定的复杂性和效率上的损失,具体运用时应酌情考量,避免过度设计。”冒号提醒道,“最后,如果Complex类需要功能上的变化,比如增加乘方、开方等运算,只要不修改现有运算的签名,是不会伤及客户代码的。”