主流的编程范式有三种:面向过程、面向对象和函数式编程,我们现在使用的主流编程语言 c# 或 java,都是面向对象语言,所以常常说的设计模式也是在面向对象语言这个前提之下。
面向对象的基础知识和一些设计原则,我认为是学习设计模式的基础,本文就聊下这些基础知识。
在面试时,一问到面向对象,几乎每个人都能脱口而出:封装、继承、多态。但大部分只能说出一个简单的概念,而多态还有很多连概念都说不清楚。我们学习面向对象,不止需要了解概念,更需要知道每个特性存在的意义和目的。
对于面向对象的特性,面向对象的语言都会给出相应的支持,不同语言可能会有细微差别,下面的示例以 c# 语言为主。
我们先来思考下,平时写代码时有哪些是属于封装,是不是会有下面的一些场景:
1、将一些属性字段放到一个类中;
2、将一些方法放到一个类中
3、将某些类组织到某个特定的命名空间下。
而在 c# 9.0 版本中还提供了属性的 init 特性,可以更方便地提供封装性:
public class userinfo
{
public string name { get; init; }
}
userinfo user = new userinfo { name = "oec2003" };
//当 user 初始化完了之后就不能再改变 name 的值
user.name = "oec2004";
除了属性、方法和类也有对应的访问修饰符,这些访问修饰符的灵活运用就达到了封装的目的,用来隐藏信息或进行数据的保护。
试想一下,如果我们对类中属性或方法全部都使用 public ,调用方可以任意修改属性和调用方法,这样会使代码变得不可控,属性可能被很多地方以不同的方式进行修改,代码难以维护。而且不熟悉业务的开发人员如果随意改动了一些关键属性,可能引发严重的问题。
从另一个方面来说,类的共有属性和方法暴露的越多,对于调用者来说就会越复杂,越容易出现问题,合理地进行封装,可以提高可读性、可维护性,减少出错。
这时,你是不是可以想想,平时写代码时,属性、方法、类如果要让外部进行调用,都统一写上 public 了呢?
目前面向对象的语言基本都支持继承特性,只是语法上有些细微的差别,比如 c# 语言是使用冒号,java 语言使用 extends 关键字。但都是标识 is-a 的关系。
在 c# 中一个类可以继承多个接口,但只能继承一个父类,我们通常说的 c# 只支持单继承指的是 c# 只能继承一个父类,但在 c 、python 等语言中类是可以继承多个类的。
我们经常会跟开发人员讲,不要到处复制代码,代码要做到能够复用,发现同一个逻辑在两个不同的类中的时候,可以抽象出来一个父类,让这两个类继承这个父类。这个思路没有问题,也确实能解决我们的实际问题,提升代码质量。
但随着功能的增加,我们需要对类的属性和方法进行扩展,会发现需要新添加的属性或方法放在父类或子类都不合适,只能继续进行抽象,长此下去,继承关系会变得非常复杂,变得难以维护。有条设计原则是这么说的:组合优于继承,其实就是为了解决这个问题。
组合和继承的选择是一种权衡和选择,当涉及的类经常变化可能导致继承层级向着复杂化演化时,需要考虑采用组合的方式,如果相关类比较稳定,继承层级不深(一般不超过 3 层),就可以放心使用继承。
在具体的模式中,组合模式、策略模式等就是使用组合的方式实现,模板模式使用的是继承方式实现。
多态的字面意思就是同样的一个语法调用,能够表达多个不同的意思。如果说继承的最大好处是复用,那么多态的好处就是方便扩展。
在 c# 语言中两个比较典型的多态场景就是方法的重写和方法的重载:
- 重写:存在继承关系的类或接口,在子类中对父类的方法进行重新构建逻辑,但调用方法、参数、返回值保持一致,通常有下面几种情况: 普通的父类中有用 virtual 关键字标识的虚方法,在子类中使用 override 关键字进行重写;子类对抽象类的抽象方法进行重写;子类对接口中的方法进行实现。
- 重载:类中的多个方法,方法名相同,但参数个数或类型不相同,称之为重载方法。例如 c# 中的 file 类的 open 方法就有三个重载,如下图:
方法的重写,在实际应用中非常常见,比如零代码平台中的消息组件会有多种发送消息的方式,下面用一个示例代码演示下:
public interface imessage
{
void send(string msg);
}
public class emailmessage : imessage
{
public void send(string msg)
{
console.writeline($"send email message {msg}");
}
}
public class wechatmessage : imessage
{
public void send(string msg)
{
console.writeline($"send wechat message {msg}");
}
}
class program
{
static void main(string[] args)
{
list messagelist = new list();
messagelist.add(new emailmessage());
messagelist.add(new wechatmessage());
messagelist.foreach(s=>s.send("test message"));
}
}
为什么说能提高扩展性呢?如果这时消息组件需要扩展发送短信的消息种类,只需要编写短信类型的消息类实现 imessage 接口的 send 方法即可。
还有一种场景,比如登陆的时候,有基于用户名密码的认证、企业微信的认证、钉钉的认证、和对接第三方的认证,又应该怎么设计呢?
我们虽然都在使用着面向对象的语言,但很多的时候思维还是面向过程的,具体体现在:
- 实体类的属性直接定义为 public ,set 和 get 都安排上,外部可以任意获取和赋值,很多时候使用代码生产工具直接生产实体类,默认的 set 和 get 都是 public ,也没有去依据具体的业务进行修改,严重破坏了封装特性;
- 数据和行为的分离,也就是所谓的贫血模式,但真正的对象是数据行为在一起的,我们可能每天都在写这样的代码,一种面向过程式的代码;
- 为了代码复用,代码中会存在大量的 helper 类或者 utils、common 类,这些类通常是静态类,里面有各种各样的静态方法,在往里面添加方法时需要思考下,真的需要放到这里吗?
- 按照功能驱动,比如页面上的一个按钮操作,对应了一个 api 接口,不管你的代码时如何设计和分层,一层层往下知道数据库访问。
所以不要以为使用了面向对象的语言就是在使用面向对象编程,重要的是抽象的思维,这种抽象需要我们去思考,去全盘考虑,相比较面向过程显得更难,所以懒惰的程序员更容易写出面向过程的代码。
这些面向对象的基础知识是学习设计模式的根基,掌握基础知识,然后愿意去思考,总结才能够学习好设计模式,并将其应用到实际的工作中。下一篇将介绍面向对象中的常用设计原则,设计模式也都是基于这些设计原则演化而来。