什么是接口和抽象类?
谨记:设计严谨的软件重要的标准就是需要经的起测试,一个程序好不好被测试,测试发现问题能不能被良好的修复,程序状况能否被监控,这都有赖于对抽象类和接口的正确使用。
接口和抽象类,是高阶面向对象设计的起点。想要学习设计模式,必须有着对抽象类和接口的良好认知,和SOLID的认知,并在日常工作中正确的使用他们。
先简述一下SOLID的特指的五种原则,优秀的设计模式,都是参考于这五种原则的实现;
SOLID:
SRP: Single Responsibility Principle 单一职责原则
OCP: Open Closed Principle 开闭原则
LSP: Liskov Substitution Principle 里氏替换原则
ISP: Interface Segregation Principle 接口隔离原则
DIP: Dependency Inversion Principle 依赖反转原则
什么是接口和抽象类:
- 接口和抽象类都是 “软件工程产物”
- 具体类——抽象类——接口:越来越抽象,内部实现的东西越来越少
- 抽象类是未完全实现逻辑的类(可以有字段和非public成员,他们代表了 “具体逻辑”)
- 抽象类为复用而生:专门作为基类来使用,也具有解耦的功能。
- 抽象类封装的是确定的,开放的是不确定的,推迟到合适的子类中去实现。
- 接口是完全未实现逻辑的 “类”,(“纯虚类”;只有函数成员;成员默认为public且不能为private) 但在新的C#版本中,接口也能拥有属性,索引,事件,和方法的默认实现。
- 接口为解耦而生:“高内聚,底耦合”,方便单元测试。
- 接口是一个 “协约”,它规定你必须有什么。
- 它们都不能实例化,只能用来声明变量,引用具体类的实例。
为做基类而生的 “抽象类”与 “开放/关闭原则”: (所有的规则都是为了更好的协作)
我们应该封装那些不变的,稳定的,确定的而把那些不确定的,有可能改变的成员声明为抽象成员,并且留给子类去实现。
错误的功能封装实现:违反了开闭原则,每当新增功能就会新增代码
class Program { static void Main(string[] args) { Vehicle vehicle = new Car(); vehicle.Run("car"); } } class Vehicle { public void Stop() { Console.WriteLine("stopped!"); } public void Fill() { Console.WriteLine("Pay and fill..."); } public void Run(string type) { //这时候又来一辆车 我们又得加代码了 破坏了 开闭原则 switch (type) { case "car": Console.WriteLine("car is running..."); break; case "truck": Console.WriteLine("truck is running..."); break; default: break; } } } class Car : Vehicle { public void Run() { Console.WriteLine("car is running..."); } } class Truck : Vehicle { public void Run() { Console.WriteLine("truck is running..."); } }
利用多态的机制优化上述代码:
class Program { static void Main(string[] args) { Vehicle vehicle = new Car(); vehicle.Run(); } } class Vehicle { public void Stop() { Console.WriteLine("stopped!"); } public void Fill() { Console.WriteLine("Pay and fill..."); } public virtual void Run() { Console.WriteLine("vehicle is running..."); } } class Car : Vehicle { public override void Run() { Console.WriteLine("car is running..."); } } class Truck : Vehicle { public override void Run() { Console.WriteLine("truck is running..."); } }
我们可以发现vehicle的Run方法,我们基本上很少能用到,那我们去掉它的方法实现,一个虚方法却没有函数实现,这不就是纯虚方法了吗? 在C#中,纯虚方法的替代类型是abstract。
在现在这个方法中,它封装了确定的方法,开放了不确定的方法,符合了抽象类的设计,也遵循了开闭/原则。
class Program { static void Main(string[] args) { Vehicle vehicle = new Car(); vehicle.Run(); } } //封装出了确定的方法 开放了不确定的方法 abstract class Vehicle { public void Stop() { Console.WriteLine("stopped!"); } public void Fill() { Console.WriteLine("Pay and fill..."); } public abstract void Run(); } class Car : Vehicle { public override void Run() { Console.WriteLine("car is running..."); } } class Truck : Vehicle { public override void Run() { Console.WriteLine("truck is running..."); } }
这个时候我们如果想要新增一个类型的汽车,只需要继承并实现它的抽象方法即可,这更符合开闭/原则。
现在我们让VehicleBase做为纯抽象类,这在C#中是一种常见的模式,我们可以分“代”的来完成开放的不确定方法,让方法慢慢变得清晰和确定。
class Program { static void Main(string[] args) { Vehicle vehicle = new Car(); vehicle.Run(); } } abstract class VehicleBase { public abstract void Stop(); public abstract void Fill(); public abstract void Run(); } abstract class Vehicle : VehicleBase { public override void Fill() { Console.WriteLine("pay and fill..."); } public override void Stop() { Console.WriteLine("stopped!"); } } class Car : Vehicle { public override void Run() { Console.WriteLine("car is running..."); } } class Truck : Vehicle { public override void Run() { Console.WriteLine("truck is running..."); } }
让我们在思考下去,纯抽象类,不就是接口的默认实现模式吗,我们将纯抽象类改成接口。
class Program { static void Main(string[] args) { Vehicle vehicle = new Car(); vehicle.Run(); } } interface IVehicle { void Stop(); void Fill(); void Run(); } abstract class Vehicle : IVehicle { public void Fill() { Console.WriteLine("pay and fill..."); } public void Stop() { Console.WriteLine("stopped!"); } abstract public void Run(); } class Car : Vehicle { public override void Run() { Console.WriteLine("car is running..."); } } class Truck : Vehicle { public override void Run() { Console.WriteLine("truck is running..."); } }
这是不是就熟悉多了,这是一种良好的设计方法了。
接口是一种契约
他不仅仅约束提供服务方应如何去实现这个服务,例如需要返回一个什么样的结果。也约束了消费方如何去消费这个服务,例如你应该提供怎么样的操作。
现在没有接口,我们要实现来自于两个消费者的需求,这两个需求的内部逻辑都是一样的,给一串数字求和,求平均数,但是因为他们的产品不同,所以入参不同。
static void Main(string[] args) { ArrayList nums1 = new ArrayList() { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; int[] nums2 = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; Console.WriteLine(Sum(nums1)); Console.WriteLine(Avg(nums1)); Console.WriteLine(Sum(nums2)); Console.WriteLine(Avg(nums2)); } static int Sum(int[] nums) { int result = 0; foreach (int x in nums) result += x; return result; } static int Avg(int[] nums) { int result = 0; foreach (int x in nums) result += x; return result / nums.Length; } static int Sum(ArrayList nums) { int result = 0; foreach (int x in nums) result += x; return result; } static int Avg(ArrayList nums) { int result = 0; foreach (int x in nums) result += x; return result / nums.Count; }
但我们程序员观察了内部的逻辑,发现这两个参数在内部都使用了foreach,突然灵光一闪,能用foreach实现迭代不就证明他们都实现了IEnumerable接口吗,于是优化了代码:
static void Main(string[] args) { ArrayList nums1 = new ArrayList() { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; int[] nums2 = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; Console.WriteLine(Sum(nums1)); Console.WriteLine(Avg(nums1)); Console.WriteLine(Sum(nums2)); Console.WriteLine(Avg(nums2)); } static int Sum(IEnumerable nums) { int result = 0; foreach (int x in nums) result += x; return result; } static int Avg(IEnumerable nums) { int result = 0; int count = 0; foreach (int x in nums) { result += x; count++; } return result / count; }
没错,这其实也是多态的一种实现。
接口的解耦
紧耦合:一件需要警惕的事情,现在有这样一个场景,你负责开发了一个引擎功能,在其他团队负责的小车功能中,他们需要你的引擎功能作为基础,想想看这个时候如果你的功能出了错误,他们的工作也就白做了,不仅如此,他们工作的进度,也取决于你的开发进度。
我们使用接口来让耦合变得更松弛,现在我们都在使用手机,但是手机的品牌有很多供我们选择,但他们都为我们提供了相同的服务,这些服务便可以被接口所决定和约束,你必须要有打电话,接电话,发送短信的功能等等,你才能称的上是手机。并且我们使用手机不可能只使用这一个手机,我们可能会使用不同的手机,而换手机这个操作也不能让我们受到影响。
static void Main(string[] args) { var userPhone = new UserPhone(new Xiaomi()); userPhone.Use(); } class UserPhone { private IPhone _phone; public UserPhone(IPhone phone) { _phone = phone; } public void Use() { _phone.Send(); _phone.Call(); _phone.Recived(); } } interface IPhone { void Call(); void Send(); void Recived(); } class Huawei : IPhone { public void Call() { Console.WriteLine("Huawei call"); } public void Recived() { Console.WriteLine("Huawei Recived"); } public void Send() { Console.WriteLine("Huawei Send"); } } class Xiaomi : IPhone { public void Call() { Console.WriteLine("Xiaomi call"); } public void Recived() { Console.WriteLine("Xiaomi Recived"); } public void Send() { Console.WriteLine("Xiaomi Send"); } }
依赖反转原则
我们在日常编程中,我们惯性的自顶向下的思维,会将大问题分解成一个个小的问题,可能会产生一层层的耦合。
在这个例子中,耦合度很高,driver只能驾驶Car,而不能驾驶其他汽车。
引入IVehicle接口,将车的类型于Driver解耦,现在Diver可以利用多态来掌控不同类型的汽车。注意箭头的指向!现在依赖反转了。
引入抽象类,作为Driver们的基类,它依赖了IVehicle,现在组合方式成了 2 X 2,Driver们和Car耦合大大降低了。
接口提供的易测试性
现在有一个小例子,厂家生产了电风扇,电风扇不同的电流对应了电风扇不同的状态。我们试着来一步步优化并测试这个方法。
class Program { static void Main(string[] args) { DeskFan fan = new DeskFan(new PowerSupply()); fan.Work(); } class PowerSupply { public int GetPower() => 100; } class DeskFan { private PowerSupply _powerSupply; public DeskFan(PowerSupply powerSupply) { _powerSupply = powerSupply; } public void Work() { int power = _powerSupply.GetPower(); if (power <= 0) { Console.WriteLine("Don't Work"); } else if (power < 100) { Console.WriteLine("Low"); } else if(power < 200) { Console.WriteLine("Work Fine"); } else { Console.WriteLine("Warning"); } } } }
引入接口解耦合:
class Program { static void Main(string[] args) { DeskFan fan = new DeskFan(new PowerSupply()); fan.Work(); } interface IPowerSupply { int GetPower(); } class PowerSupply : IPowerSupply { public int GetPower() => 100; } class DeskFan { private IPowerSupply _powerSupply; public DeskFan(IPowerSupply powerSupply) { _powerSupply = powerSupply; } public void Work() { int power = _powerSupply.GetPower(); if (power <= 0) { Console.WriteLine("Don't Work"); } else if (power < 100) { Console.WriteLine("Low"); } else if(power < 200) { Console.WriteLine("Work Fine"); } else { Console.WriteLine("Warning"); } } } }
接下来我们写了一个xUnit测试项目,对现有接口进行测试。
public class DeskFanTest { [Fact] public void PowerSupplyThenZero_Ok() { var fan = new DeskFan(new PowerSupplyThenZero()); var expected = "Work Fine"; var actual = fan.Work(); Assert.Equal(expected, actual); } [Fact] public void PowerSupplylessThen200_Bad() { var fan = new DeskFan(new PowerSupplyThen200()); var expected = "Work Fine"; var actual = fan.Work(); Assert.Equal(expected, actual); } } class PowerSupplyThenZero : IPowerSupply { public int GetPower() { return 140; } } class PowerSupplyThen200 : IPowerSupply { public int GetPower() { return 210; } } }
但我们这个测试项目其实还是有问题的,我们的测试用例都需要一个个来创建,比较麻烦,我们这里引入Mock。
public class DeskFanTest { [Fact] public void PowerSupplyThenZero_Ok() { var mockPower = new Mock<IPowerSupply>(); mockPower.Setup(s=>s.GetPower()).Returns(100); var fan = new DeskFan(mockPower.Object); var expected = "Work Fine"; var actual = fan.Work(); Assert.Equal(expected, actual); } [Fact] public void PowerSupplylessThen200_Bad() { var mockPower = new Mock<IPowerSupply>(); mockPower.Setup(s => s.GetPower()).Returns(300); var fan = new DeskFan(mockPower.Object); var expected = "Warning"; var actual = fan.Work(); Assert.Equal(expected, actual); } }
具有良好的测试性对于一个持续集成的环境也是有很大帮助的,每当我们 check in 代码时,持续集成工具都会先 run一遍测试项目,如果这一次的代码 check in 使之前的测试项目不能通过了,那这一次 check in 则是失败的。
接口的D/I原则
我们说到接口是服务提供方和服务消费方之间的协议,那现在服务提供方提供了消费方在规定协议中的功能接口,但消费方对功能的需求的软性需求,则有可能服务提供方还多提供了服务,但这些服务可能永远都没有被用到,那么这个接口就太胖了,造成这种原因可能是一个接口中包含了多个其他场景的功能,那么它的设计违反了接口隔离原则的。而实现了这个接口的类也违反了单一职责原则。
以下的这个例子:声明了两个接口IVehicle和ITank,但是呢这两个接口中都封装了相同的Run方法,并且ITank的Fire方法也未曾被使用过(并且Fire应该是属于武器部分),还有呢就是我们如果想让Driver开坦克,就必须要该代码了。所以这个接口可以被重新隔离。
public class Program { public static void Main(string[] args) { Driver driver= new Driver(new Car()); driver.Run(); } class Driver { private readonly IVehicle _vehicle; public Driver(IVehicle vehicle) { _vehicle = vehicle; } public void Run() { _vehicle.Run(); } } interface IVehicle { void Run(); } class Car : IVehicle { public void Run() { Console.WriteLine("car is running..."); } } class Truck : IVehicle { public void Run() { Console.WriteLine("truck is running..."); } } interface ITank { void Run(); void Fire(); } class SmallTank : ITank { public void Fire() { Console.WriteLine("small boom!!"); } public void Run() { Console.WriteLine("small tank is running..."); } } class BigTank : ITank { public void Fire() { Console.WriteLine("big boom!!"); } public void Run() { Console.WriteLine("big tank is running..."); } } }
我们引入IWeapon接口来隔离Fire和Run,并且让ITank也遵守IVehicle的规定,那么Driver就能把Tank当Car开了。
public class Program { public static void Main(string[] args) { Driver tankDriver= new Driver(new BigTank()); tankDriver.Run(); Driver carDriver = new Driver(new Car()); carDriver.Run(); } class Driver { private readonly IVehicle _vehicle; public Driver(IVehicle vehicle) { _vehicle = vehicle; } public void Run() { _vehicle.Run(); } } interface IVehicle { void Run(); } class Car : IVehicle { public void Run() { Console.WriteLine("car is running..."); } } class Truck : IVehicle { public void Run() { Console.WriteLine("truck is running..."); } } interface IWeapon { void Fire(); } interface ITank : IVehicle, IWeapon { } class SmallTank : ITank { public void Fire() { Console.WriteLine("small boom!!"); } public void Run() { Console.WriteLine("small tank is running..."); } } class BigTank : ITank { public void Fire() { Console.WriteLine("big boom!!"); } public void Run() { Console.WriteLine("big tank is running..."); } } }
我们用另一个例子来理解 “合适的” 接口,还记得上面我们数组元素相加的例子吗,我们想要的功能其实就是对int[]类型的元素进行迭代累加而已,不需要其他的功能,所以我们使用的不是IList或者ICollection这些 “胖接口” 因为这些里面的功能我们都用不到,所以我们提供IEnumerable接口即可。这更符合接口隔离原则,太胖的接口还会使得阻碍我们服务的提供。
public class Program { public static void Main(string[] args) { int[] nums = new int[] { 1, 2, 3, 4, 5 }; var roc = new ReadOnlyCollection(nums); var result1 = Sum(nums); Console.WriteLine(result1); // 15 //会出错 因为ICollection是一个胖接口 //我们只需要使用迭代功能即可 胖接口挡住了我们合格的服务 var result2 = Sum(roc); var result3 = Sum1(roc); Console.WriteLine(result3); //15 } static int Sum(ICollection collection) { int result = 0; foreach (var item in collection) { result += (int)item; } return result; } static int Sum1(IEnumerable collection) { int result = 0; foreach (var item in collection) { result += (int)item; } return result; } } class ReadOnlyCollection : IEnumerable { private readonly int[] _array; public ReadOnlyCollection(int[] array) { _array = array; } public IEnumerator GetEnumerator() { return new Enumerator(this); } public class Enumerator : IEnumerator { private readonly ReadOnlyCollection _readOnlyCollection; private int _head; public Enumerator(ReadOnlyCollection readOnlyCollection) { _readOnlyCollection = readOnlyCollection; _head = -1; } public bool MoveNext() { return ++_head < _readOnlyCollection._array.Length; } public void Reset() { _head = -1; } public object Current { get { object o = _readOnlyCollection._array[_head]; return o; } } } }
对接口的进一步隔离还可以使用显示接口:现在有一个这样的例子,有一个男孩其实是一个超级英雄,他白天在学校里当学生,晚上惩奸除恶还不想被人知道。
public class Program { public static void Main(string[] args) { //student只能访问到learn方法 var student = new Boy(); student.Learn(); //想要调用KillBadGuy 你需要让他变成英雄 IHero hero = student; hero.KillBadGuy(); } public interface IStudent { void Learn(); } public interface IHero { void KillBadGuy(); } public class Boy : IStudent, IHero { public void Learn() { Console.WriteLine("I am a Student Keep learn"); } void IHero.KillBadGuy() { Console.WriteLine("kill the bad guy..."); } } }