接口

abstract 中的抽象方法只规定了不能是 private 的,而接口中的“抽象方法”只能是 public 的。
这样的成员访问级别就决定了接口的本质:接口是服务消费者服务提供者之间的契约。因此契约就必须是透明的,对双方都是可见的。

除了publicabstract 的抽象方法还可以是 protectedinternal,它们都不是给功能调用者准备的,各自有特定的可见目标。

接口即契约(contract)
契约使自由合作成为可能,所谓自由合作就是一份合同摆在这里,它既约束服务的使用者也约束服务的提供者。
如果该契约的使用者和提供者有多个,它们之间还能自由组合。

接口契约示例

未使用接口时:

  1. class Program
  2. {
  3. static void Main(string[] args)
  4. {
  5. int[] nums1 = new int[] { 1, 2, 3, 4, 5 };
  6. ArrayList nums2 = new ArrayList { 1, 2, 3, 4, 5 };
  7. Console.WriteLine(Sum(nums1));
  8. Console.WriteLine(Avg(nums1));
  9. Console.WriteLine(Sum(nums2));
  10. Console.WriteLine(Avg(nums2));
  11. }
  12. static int Sum(int[] nums)
  13. {
  14. int sum = 0;
  15. foreach (var n in nums)
  16. {
  17. sum += n;
  18. }
  19. return sum;
  20. }
  21. static double Avg(int[] nums)
  22. {
  23. int sum = 0;
  24. double count = 0;
  25. foreach (var n in nums)
  26. {
  27. sum += n;
  28. count++;
  29. }
  30. return sum / count;
  31. }
  32. static int Sum(ArrayList nums)
  33. {
  34. int sum = 0;
  35. foreach (var n in nums)
  36. {
  37. sum += (int)n;
  38. }
  39. return sum;
  40. }
  41. static double Avg(ArrayList nums)
  42. {
  43. int sum = 0;
  44. double count = 0;
  45. foreach (var n in nums)
  46. {
  47. sum += (int)n;
  48. count++;
  49. }
  50. return sum / count;
  51. }
  52. }

供方是 nums1 和 nums2,需方是 Sum 和 Avg 这两函数。

需方需要传进来的参数可以迭代就行,别的不关心也用不到。

整型数组的基类是 Array,Array 和 ArrayList 都实现了 IEnumerable。

  1. static int Sum(IEnumerable nums)
  2. {
  3. int sum = 0;
  4. foreach (var n in nums)
  5. {
  6. sum += (int)n;
  7. }
  8. return sum;
  9. }
  10. static double Avg(IEnumerable nums)
  11. {
  12. int sum = 0;
  13. double count = 0;
  14. foreach (var n in nums)
  15. {
  16. sum += (int)n;
  17. count++;
  18. }
  19. return sum / count;
  20. }

依赖与耦合

现实世界中有分工、合作,面向对象是对现实世界的抽象,它也有分工、合作。

类与类、对象与对象间的分工、合作。

在面向对象中,合作有个专业术语“依赖”,依赖的同时就出现了耦合。依赖越直接,耦合就越紧。

Car 与 Engine 紧耦合的示例:

  1. class Program
  2. {
  3. static void Main(string[] args)
  4. {
  5. var engine = new Engine();
  6. var car = new Car(engine);
  7. car.Run(3);
  8. Console.WriteLine(car.Speed);
  9. }
  10. }
  11. class Engine
  12. {
  13. public int RPM { get; private set; }
  14. public void Work(int gas)
  15. {
  16. this.RPM = 1000 * gas;
  17. }
  18. }
  19. class Car
  20. {
  21. // Car 里面有个 Engine 类型的字段,它两就是紧耦合了
  22. // Car 依赖于 Engine
  23. private Engine _engine;
  24. public int Speed { get; private set; }
  25. public Car(Engine engine)
  26. {
  27. _engine = engine;
  28. }
  29. public void Run(int gas)
  30. {
  31. _engine.Work(gas);
  32. this.Speed = _engine.RPM / 100;
  33. }
  34. }

紧耦合的问题:

  1. 基础类一旦出问题,上层类写得再好也没辙
  2. 程序调试时很难定位问题源头
  3. 基础类修改时,会影响写上层类的其他程序员的工作

所以程序开发中要尽量避免紧耦合,解决方法就是接口。

接口:

  1. 约束调用者只能调用接口中包含的方法
  2. 让调用者放心去调,不必关心方法怎么实现的、谁提供的

接口解耦示例

以老式手机举例,对用户来说他只关心手机可以接(打)电话和收(发)短信。
对于手机厂商,接口约束了他只要造的是手机,就必须可靠实现上面的四个功能。

用户如果丢了个手机,他只要再买个手机,不必关心是那个牌子的,肯定也包含这四个功能,上手就可以用。用术语来说就是“人和手机是解耦的”。

  1. class Program
  2. {
  3. static void Main(string[] args)
  4. {
  5. //var user = new PhoneUser(new NokiaPhone());
  6. var user = new PhoneUser(new EricssonPhone());
  7. user.UsePhone();
  8. Console.ReadKey();
  9. }
  10. }
  11. class PhoneUser
  12. {
  13. // 字段类型不在是某一手机类型,而是接口类型
  14. private IPhone _phone;
  15. // 构造器接收一个接口类型的量
  16. public PhoneUser(IPhone phone)
  17. {
  18. _phone = phone;
  19. }
  20. public void UsePhone()
  21. {
  22. _phone.Dail();
  23. _phone.PickUp();
  24. _phone.Receive();
  25. _phone.Send();
  26. }
  27. }
  28. interface IPhone
  29. {
  30. void Dail();
  31. void PickUp();
  32. void Send();
  33. void Receive();
  34. }
  35. class NokiaPhone : IPhone
  36. {
  37. public void Dail()
  38. {
  39. Console.WriteLine("Nokia calling ...");
  40. }
  41. public void PickUp()
  42. {
  43. Console.WriteLine("Hello! This is Tim!");
  44. }
  45. public void Send()
  46. {
  47. Console.WriteLine("Nokia message ring ...");
  48. }
  49. public void Receive()
  50. {
  51. Console.WriteLine("Hello!");
  52. }
  53. }
  54. class EricssonPhone : IPhone
  55. {
  56. public void Dail()
  57. {
  58. Console.WriteLine("Ericsson calling ...");
  59. }
  60. public void PickUp()
  61. {
  62. Console.WriteLine("Hello! This is Tim!");
  63. }
  64. public void Send()
  65. {
  66. Console.WriteLine("Ericsson ring ...");
  67. }
  68. public void Receive()
  69. {
  70. Console.WriteLine("Good evening!");
  71. }
  72. }

没有用接口时,如果一个类坏了,你需要 Open 它再去修改,修改时可能产生难以预料的副作用。引入接口后,耦合度大幅降低,换手机只需要换个类名,就可以了。
学习反射后,连这里的一行代码都不需要改,只要在配置文件中修改文件名即可。

在代码中只要有可以替换的地方,就一定有接口的存在;接口就是为了解耦(松耦合)而生。
松耦合最大的好处就是让功能的提供方变得可替换,从而降低紧耦合时“功能的提供方不可替换”带来的高风险和高成本。

  • 高风险:功能提供方一旦出问题,依赖于它的功能都挂;
  • 高成本:如果功能提供方的程序员崩了,会导致功能使用方的整个团队工作受阻;

    依赖反转原则

    解耦在代码中的表现就是依赖反转。

单元测试就是依赖反转在开发中的直接应用和直接受益者。

28接口,依赖反转,单元测试 - 图1

人类解决问题的典型思维:自顶向下,逐步求精。

在面向对象里像这样来解决问题时,这些问题就变成了不同的类,且类和类之间紧耦合,它们也形成了这样的金字塔。

依赖反转给了我们一种新思路,用来平衡自顶向下的思维方式。

平衡:不要一味推崇依赖反转,很多时候自顶向下就很好用,就该用。

image.png :::info 紧耦合, ↓从上往下, 表示Driver(司机) 依赖于Car。
Driver类内部有对应载具的字段car, 执行Drive方法时就调用的是car的Run方法 ::: :::warning 这里存在的问题是Driver只能开Car, 如果他想驾驶Truck就得修改代码 ::: image.png :::info ①这条线表示Driver,其任是依赖于IVehicle接口 ::: :::info 此时↑是从下往上,表示类去实现接口
注:当类实现一个接口时,类与接口之间的关系也是“紧耦合”。 ::: :::warning 通过接口, Driver里面存储的是IVehicle类型的字段, 既可以引用Car类型的实例, 也可以引用Truck类型的实例。 ::: image.png :::danger 多个服务的提供者和使用者都遵循一个接口(契约)
这些服务的提供者和使用者间就能随意两两配对了 ::: :::info 不管是驾驶员还是智能驾驶,只要实现了IDriver就能开各种车 ::: :::info 服务提供者Car和Truck都实现了IVehicle ::: image.png :::info 接下来就是“设计模式”了 :::

单元测试

用例子来展示接口、解耦和依赖反转原则是怎么被单元测试应用的。

紧耦合:

  1. class Program
  2. {
  3. static void Main(string[] args)
  4. {
  5. var fan = new DeskFan(new PowerSupply());
  6. Console.WriteLine(fan.Work());
  7. }
  8. }
  9. // 背景:电扇有个电源,电源输出电流越大电扇转得越快
  10. // 电源输出有报警上限
  11. class PowerSupply
  12. {
  13. public int GetPower()
  14. {
  15. //return 100;
  16. return 210;
  17. }
  18. }
  19. class DeskFan
  20. {
  21. private PowerSupply _powerSupply;
  22. public DeskFan(PowerSupply powerSupply)
  23. {
  24. _powerSupply = powerSupply;
  25. }
  26. public string Work()
  27. {
  28. int power = _powerSupply.GetPower();
  29. if (power <= 0)
  30. {
  31. return "Won't work.";
  32. }
  33. else if (power < 100)
  34. {
  35. return "Slow";
  36. }
  37. else if (power < 200)
  38. {
  39. return "Work fine";
  40. }
  41. else
  42. {
  43. return "Warning";
  44. }
  45. }
  46. }

现在的问题是:我要测试电扇是否能按预期工作,我必须去修改 PowerSupply 里面的代码,这违反了开闭原则。
而且可能有除了电扇外的别的电器也连到了这个电源上面(在其他位置也引用了 PowerSupply),为了测试电扇工作就去改电源,很可能会造成别的问题。

接口的产生:自底向上(重构)和自顶向下(设计)。
只有对业务足够熟悉才能做到自顶向下,更多时候是一边写一边重构,现在我们就用接口去对电源和风扇进行解耦。

  1. class Program
  2. {
  3. static void Main(string[] args)
  4. {
  5. var fan = new DeskFan(new PowerSupply());
  6. Console.WriteLine(fan.Work());
  7. }
  8. }
  9. interface IPowerSupply
  10. {
  11. int GetPower();
  12. }
  13. public class PowerSupply : IPowerSupply
  14. {
  15. public int GetPower()
  16. {
  17. return 110;
  18. }
  19. }
  20. public class DeskFan
  21. {
  22. private IPowerSupply _powerSupply;
  23. public DeskFan(IPowerSupply powerSupply)
  24. {
  25. _powerSupply = powerSupply;
  26. }
  27. public string Work()
  28. {
  29. int power = _powerSupply.GetPower();
  30. if (power <= 0)
  31. {
  32. return "Won't work.";
  33. }
  34. else if (power < 100)
  35. {
  36. return "Slow";
  37. }
  38. else if (power < 200)
  39. {
  40. return "Work fine";
  41. }
  42. else
  43. {
  44. return "Warning";
  45. }
  46. }
  47. }

有接口后,我们就可以专门创建一个用于测试的电源类。

  1. 为了单元测试,将相关的类和接口都显式声明为 public
  2. 示例本身是个 .NET Core Console App,其相应的测试项目最好用 xUnit(官方之选)
  3. 测试项目命名:被测试项目名.Tests,例如 InterfaceExample.Tests
  4. 测试项目要引用被测试项目
  5. 测试项目里面的类和被测试项目的类一一对应,例如 DeskFanTests.cs
  1. using Xunit;
  2. namespace InterfaceExample.Tests
  3. {
  4. public class DeskFanTest
  5. {
  6. // 此处[Fact]称为特征或特性
  7. // 类中存在[Fact],其为测试case
  8. [Fact]
  9. public void PowerLowerThanZero_OK()
  10. {
  11. var fan = new DeskFan(new PowerSupplyLowerThanZero());
  12. var expected = "Won't work.";
  13. var actual = fan.Work();
  14. Assert.Equal(expected, actual);
  15. }
  16. [Fact]
  17. public void PowerHigherThan200_Warning()
  18. {
  19. var fan = new DeskFan(new PowerSupplyHigherThan200());
  20. // 注:此处为了演示,实际程序那边先故意改成了 Exploded!
  21. var expected = "Warning";
  22. var actual = fan.Work();
  23. Assert.Equal(expected, actual);
  24. }
  25. }
  26. class PowerSupplyLowerThanZero : IPowerSupply
  27. {
  28. public int GetPower()
  29. {
  30. return 0;
  31. }
  32. }
  33. class PowerSupplyHigherThan200 : IPowerSupply
  34. {
  35. public int GetPower()
  36. {
  37. return 220;
  38. }
  39. }
  40. }

每当有新的代码提交后,就将 TestCase 全部跑一遍,如果原来通过了的,这次却没有通过(称为回退),就开始 Debug。

平时工作中写测试 case 和写代码的重要性是一样的,没有测试 case 监控的代码的正确性、可靠度都不能保证。
程序想要能被测试,就需要引入接口、松耦合、依赖反转。

Mock

现在的一个问题就是:为了进行测试,我们要不断的创建实现了接口的测试类,造成这些看上去很丑的类越来越多。
可以通过 Mock(模拟) Framework 来解决。

打开测试项目的 NuGet,搜索并安装 Moq(发音就是 Mock)。

  1. using Xunit;
  2. using Moq;
  3. namespace InterfaceExample.Tests
  4. {
  5. public class DeskFanTest
  6. {
  7. [Fact]
  8. public void PowerLowerThanZero_OK()
  9. {
  10. var mock = new Mock<IPowerSupply>();
  11. // 设置该 mock 对应的 GetPower 方法返回 0
  12. mock.Setup(ps => ps.GetPower()).Returns(() => 0);
  13. var fan = new DeskFan(mock.Object);
  14. var expected = "Won't work.";
  15. var actual = fan.Work();
  16. Assert.Equal(expected, actual);
  17. }
  18. [Fact]
  19. public void PowerHigherThan200_Warning()
  20. {
  21. var mock = new Mock<IPowerSupply>();
  22. mock.Setup(ps => ps.GetPower()).Returns(() => 220);
  23. var fan = new DeskFan(mock.Object);
  24. var expected = "Warning";
  25. var actual = fan.Work();
  26. Assert.Equal(expected, actual);
  27. }
  28. }
  29. }

总结

image.png

各种方法间的关系

  • 接口方法用虚线圆表示接口方法是纯虚方法
    • 接口方法的修饰符一定是 public
  • abstract 比纯虚方法稍微实现了点,但并未完全实现,其实现还要下推出去
    • 从 abstract 到 virtual 和实现方法必须加 override
  • virtual、override 和实现方法是有了逻辑有了方法体的方法
    • 从 virtual 到 override 方法也必须加 override