本文为极客时间《设计模式之美》的读书笔记
面向对象编程
面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
四个特性
封装
强调:控制访问权限,隐藏内部数据,外部仅能通过类提供的有限的接口访问、修改内部数据。
需要特定的语法支持:private、protected、public 关键字。
领域驱动设计是非常符合这个特点的,数据和数据处理的逻辑封装在一起,也就是充血模型。而贫血模型将将数据与操作分离,破坏了面向对象的封装特性,数据和操作分离之后,数据本身的操作就不受限制了,任何代码都可以随意修改数据,是一种典型的面向过程的编程风格。
继承
强调:向上聚合,代码复用,假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。
需要特定的语法支持:extend关键字。
表示一系列有有父子关系的类。
过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。
多态
强调:向下分散,代码的扩展性和复用性。
需要特定的语法支持:比如继承、接口类
子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。
表示一类对象可以有多种表现形式。
抽象
强调:隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。
不需要特定的语法支持,可以通过接口类或者抽象类实现。
notice:
其实以上四个特性中,封装是最能体现java面向对象编程的特点的,最能诠释java面向对象的思想,封装是衡量一段带编程风格到底是面向对象还是面向过程的最好的一个思考角度,而抽象、继承、多态则是强调的为代码的复用性、可维护性、可扩展性。
面向对象和面向过程
首先需要明白的是,虽然java是一个面向对象编程的语言,但是并不代表将所有的代码都塞到类中就是面向对象编程,java代码的编程风格也是区分面向对象和面向过程的。
面向过程和面向对象最基本的区别是:代码的组织方式不同
面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。
哪些代码设计看似是面向对象,实际是面向过程的?
1.滥用 getter、setter 方法
从封装的角度来看
它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。
数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。
规避方法
在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义 setter 方法。除此之外,尽管 getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器,也要防范集合内部数据被修改的危险。
2.滥用全局变量和全局方法
如果用类似 C 语言这样的面向过程的编程语言来做开发,那对全局变量、全局方法肯定不陌生,甚至可以说,在代码中到处可见。但如果你是用类似 Java 这样的面向对象的编程语言来做开发,全局变量和全局方法就不是很多见了。
首先先来看一下java中的全局变量和全局方法
全局变量
- 单例类对象
在全局代码中只有一份,所以,它相当于一个全局变量。 - 静态成员变量
归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。 - 常量
是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个 Constants 类中。
全局方法
- 静态方法
一般用来操作静态变量或者外部数据。
为什么说滥用会让编程方式退化成面向过程
注意这里说的是滥用,并不是说所有的全局变量和全局方法都是面向过程。
滥用全局变量
从封装角度来看:
全局变量即对所有类或者所有方法暴露数据,为所有对象访问和改变全局变量提供了可能。
从继承和多态的角度来看:
其实继承和多态两个特性都是强调代码的复用性和可维护性。滥用全局变量会影响代码的复用性和可维护性。
例子
public class Constants {
public static final String MYSQL_ADDR_KEY = "mysql_addr";
public static final String MYSQL_DB_NAME_KEY = "db_name";
public static final String MYSQL_USERNAME_KEY = "mysql_username";
public static final String MYSQL_PASSWORD_KEY = "mysql_password";
public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
public static final int REDIS_DEFAULT_MAX_IDLE = 50;
public static final int REDIS_DEFAULT_MIN_IDLE = 20;
public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
// ...省略更多的常量定义...
}
定义一个如此大而全的 Constants 类,并不是一种很好的设计思路。为什么这么说呢?原因主要有以下几点。
首先,这样的设计会影响代码的可维护性
如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。
其次,这样的设计还会增加代码的编译时间
当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改 Constants 类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。不要小看编译花费的时间,对于一个非常大的工程项目来说,编译一次项目花费的时间可能是几分钟,甚至几十分钟。而我们在开发过程中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到我们的开发效率。
最后,这样的设计还会影响代码的复用性
如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类。即便这个类只依赖 Constants 类中的一小部分常量,我们仍然需要把整个 Constants 类也一并引入,也就引入了很多无关的常量到新的项目中。
那如何改进 Constants 类的设计呢?
有两种思路可以借鉴。
- 第一种是将 Constants 类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到 RedisConstants 类中。
- 不单独地设计 Constants 常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig 类用到了 Redis 配置相关的常量,那我们就直接将这些常量定义在 RedisConfig 中,这样也提高了类设计的内聚性和代码的复用性。
滥用全局方法
即滥用工具类
- 从封装的角度来看:
工具类将数据和处理数据的逻辑进行了分离。
实际上,只包含静态方法不包含任何属性的 Utils 类,是彻彻底底的面向过程的编程风格。但这并不是说,我们就要杜绝使用 Utils 类了。实际上,从刚刚讲的 Utils 类存在的目的来看,它在软件开发中还是挺有用的,能解决代码复用问题。所以,这里并不是说完全不能用 Utils 类,而是说,要尽量避免滥用,不要不加思考地随意去定义 Utils 类。
除此之外,类比 Constants 类的设计,我们设计 Utils 类的时候,最好也能细化一下,针对不同的功能,设计不同的 Utils 类,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要设计一个过于大而全的 Utils 类。
在面向对象编程中,为什么容易写出面向过程风格的代码?
从人类的思考角度来看:
你可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。
而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。
从设计的角度来看:
除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。
所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。
面向过程编程及面向过程编程语言就真的无用武之地了吗?
- 面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如 JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。
- 以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,就是面向过程风格的代码。
- 不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。
面向过程和面向对象编程例子
假设我们有一个记录了用户信息的文本文件 users.txt,每行文本的格式是 name&age&gender(比如,小王 &28& 男)。我们希望写一个程序,从 users.txt 文件中逐行读取用户信息,然后格式化成 name\tage\tgender(其中,\t 是分隔符)这种文本格式,并且按照 age 从小到大排序之后,重新写入到另一个文本文件 formatted_users.txt 中。针对这样一个小程序的开发,我们一块来看看,用面向过程和面向对象两种编程风格,编写出来的代码有什么不同。
C 语言
struct User {
char name[64];
int age;
char gender[16];
};
struct User parse_to_user(char* text) {
// 将text(“小王&28&男”)解析成结构体struct User
}
char* format_to_text(struct User user) {
// 将结构体struct User格式化成文本("小王\t28\t男")
}
void sort_users_by_age(struct User users[]) {
// 按照年龄从小到大排序users
}
void format_user_file(char* origin_file_path, char* new_file_path) {
// open files...
struct User users[1024]; // 假设最大1024个用户
int count = 0;
while(1) { // read until the file is empty
struct User user = parse_to_user(line);
users[count++] = user;
}
sort_users_by_age(users);
for (int i = 0; i < count; ++i) {
char* formatted_user_text = format_to_text(users[i]);
// write to new file...
}
// close files...
}
int main(char** args, int argv) {
format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}
Java 面向对象的编写
public class User {
private String name;
private int age;
private String gender;
public User(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
public static User praseFrom(String userInfoText) {
// 将text(“小王&28&男”)解析成类User
}
public String formatToText() {
// 将类User格式化成文本("小王\t28\t男")
}
}
public class UserFileFormatter {
public void format(String userFile, String formattedUserFile) {
// Open files...
List users = new ArrayList<>();
while (1) { // read until file is empty
// read from file into userText...
User user = User.parseFrom(userText);
users.add(user);
}
// sort users by age...
for (int i = 0; i < users.size(); ++i) {
String formattedUserText = user.formatToText();
// write to new file...
}
// close files...
}
}
public class MainApplication {
public static void main(String[] args) {
UserFileFormatter userFileFormatter = new UserFileFormatter();
userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_users.txt");
}
}
从上面的代码中,我们可以看出,面向过程和面向对象最基本的区别就是,代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。
抽象和接口的区别
- 抽象
抽象类不允许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(Logger logger = new Logger(...); 会报编译错误)。
抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)。不包含代码实现的方法叫作抽象方法。
子类继承抽象类,必须实现抽象类中的所有抽象方法。 - 接口
接口不能包含属性(也就是成员变量)。
接口只能声明方法,方法不能包含代码实现。
类实现接口的时候,必须实现接口中声明的所有方法。
总结
- 狭义的看
- 抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。
抽象强调方法的共用,接口强调同一类方法的不同实现方式。
- 抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。
- 广义的看
- 以上是基于使用层面的角度来给二者进行区分的,但是如果基于java语言的特性来看,两者并不是对立或者有区别的,接口和抽象其实都是抽象的一种实现方式,都是强调隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。
基于接口而非实现编程
对于不稳定的实现,一定要通过接口的实现类实现,而不是通过不稳定的对象实现。
这条原则的设计初衷
将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。所以对于不变的业务也不要过度设计。
例子
public class AliyunImageStore {
//...省略属性、构造函数等...
public void createBucketIfNotExisting(String bucketName) {
// ...创建bucket代码逻辑...
// ...失败会抛出异常..
}
public String generateAccessToken() {
// ...根据accesskey/secrectkey等生成access token
}
public String uploadToAliyun(Image image, String bucketName, String accessToken) {
//...上传图片到阿里云...
//...返回图片存储在阿里云上的地址(url)...
}
public Image downloadFromAliyun(String url, String accessToken) {
//...从阿里云下载图片...
}
}
// AliyunImageStore类的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他无关代码...
public void process() {
Image image = ...; //处理图片,并封装为Image对象
AliyunImageStore imageStore = new AliyunImageStore(/*省略参数*/);
imageStore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}
}
整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。代码实现非常简单,类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我们将图片存储在阿里云的业务需求。
但是,软件开发中唯一不变的就是变化。过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。为了满足这样一个需求的变化,我们该如何修改代码呢?
首先来看一下上面代码改造的问题,上面代码完全是通过实现来编程的,也就是调用者通过具体类对象的实例来调用方法,这就有如下问题:
- 首先,AliyunImageStore 类中有些函数命名暴露了实现细节,比如,uploadToAliyun() 和 downloadFromAliyun()。如果开发这个功能的同事没有接口意识、抽象思维,那这种暴露实现细节的命名方式就不足为奇了,毕竟最初我们只考虑将图片存储在阿里云上。而我们把这种包含“aliyun”字眼的方法,照抄到 PrivateImageStore 类中,显然是不合适的。如果我们在新类中重新命名 uploadToAliyun()、downloadFromAliyun() 这些方法,那就意味着,我们要修改项目中所有使用到这两个方法的代码,代码修改量可能就会很大。
- 其次,将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的。比如,阿里云的图片上传和下载的过程中,需要生产 access token,而私有云不需要 access token。一方面,AliyunImageStore 中定义的 generateAccessToken() 方法不能照抄到 PrivateImageStore 中;另一方面,我们在使用 AliyunImageStore 上传、下载图片的时候,代码中用到了 generateAccessToken() 方法,如果要改为私有云的上传下载流程,这些代码都需要做调整。
那这两个问题该如何解决呢?解决这个问题的根本方法就是,在编写代码的时候,要遵从“基于接口而非实现编程”的原则,具体来讲,我们需要做到下面这 3 点。 - 函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。
- 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
- 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
改造后的代码
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}
public class AliyunImageStore implements ImageStore {
//...省略属性、构造函数等...
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken = generateAccessToken();
//...上传图片到阿里云...
//...返回图片在阿里云上的地址(url)...
}
public Image download(String url) {
String accessToken = generateAccessToken();
//...从阿里云下载图片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...创建bucket...
// ...失败会抛出异常..
}
private String generateAccessToken() {
// ...根据accesskey/secrectkey等生成access token
}
}
// 上传下载流程改变:私有云不需要支持access token
public class PrivateImageStore implements ImageStore {
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
//...上传图片到私有云...
//...返回图片的url...
}
public Image download(String url) {
//...从私有云下载图片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...创建bucket...
// ...失败会抛出异常..
}
}
// ImageStore的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他无关代码...
public void process() {
Image image = ...;//处理图片,并封装为Image对象
ImageStore imageStore = new PrivateImageStore(...);
imagestore.upload(image, BUCKET_NAME);
}
}
总结一下,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。
多用组合少用继承
- 继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。
- 虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。
- 如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。
例子
继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。
public class AbstractBird {
//...省略其他属性和方法...
public void fly() { //... }
}
public class Ostrich extends AbstractBird { //鸵鸟
//...省略其他属性和方法...
public void fly() {
throw new UnSupportedMethodException("I can't fly.'");
}
}
基于接口实现
public interface Flyable {
void fly();
}
public interface Tweetable {
void tweet();
}
public interface EggLayable {
void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
//... 省略其他属性和方法...
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
public class Sparrow impelents Flyable, Tweetable, EggLayable {//麻雀
//... 省略其他属性和方法...
@Override
public void fly() { //... }
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg() 方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?
基于组合
我们可以针对三个接口再定义三个实现类,它们分别是:实现了 fly() 方法的 FlyAbility 类、实现了 tweet() 方法的 TweetAbility 类、实现了 layEgg() 方法的 EggLayAbility 类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:
public interface Flyable {
void fly();
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
private FlyAbility flyAbility = new FlyAbility();
private TweetAbility tweetAbility = new TweetAbility(); //组合
private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
//... 省略其他属性和方法...
@Override
public void fly() {
flyAbility.fly(); // 委托
}
@Override
public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}
总结
- 基于接口非继承可以很好地避免继承的缺点(层次过深、过复杂,也会影响到代码的可维护性),且可以实现继承同样的逻辑业务。
- 但是基于接口实现也是有缺点的:
1.接口的细粒度很粗,定义了很多不是一个维度协议,导致实现类必须实现一些自己不需要的方法。
2.但是如果接口细粒度如果做得很好,那么实现类有时候就必须多实现,实现类就必须实现每一个接口中定义的所有方法,且恰巧我这个实现类还不能让一些方法空着,必须都实现,而且这类实现类会很多,那么在众多实现类中就必然会重复造轮子,写很多相同的代码。 - 而组合就很好地解决以上问题,他将公共的逻辑通过接口实现的方式进行封装,这样大家公共的逻辑在同一个类中得到了实现,大家只需要引用他即可,在需要实现的方法中委托组合类具体实现,这样就很好地提高了代码的复用性。
网友评论