Visual

C#语言入门详解(028)——接口,依赖反转,单元测试.mp4 (116.05MB)

028 接口,依赖反转,单元测试 - 图2


接口

abstract 中的抽象方法只规定了不能是 private 的,而接口中的“抽象方法”只能是 public 的。

这样的成员访问级别就决定了接口的本质:接口是服务消费者和服务提供者之间的契约。

既然是契约,那就必须是透明的,对双方都是可见的。

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

接口即契约(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. private IPhone _phone;
  14. public PhoneUser(IPhone phone)
  15. {
  16. _phone = phone;
  17. }
  18. public void UsePhone()
  19. {
  20. _phone.Dail();
  21. _phone.PickUp();
  22. _phone.Receive();
  23. _phone.Send();
  24. }
  25. }
  26. interface IPhone
  27. {
  28. void Dail();
  29. void PickUp();
  30. void Send();
  31. void Receive();
  32. }
  33. class NokiaPhone : IPhone
  34. {
  35. public void Dail()
  36. {
  37. Console.WriteLine("Nokia calling ...");
  38. }
  39. public void PickUp()
  40. {
  41. Console.WriteLine("Hello! This is Tim!");
  42. }
  43. public void Send()
  44. {
  45. Console.WriteLine("Nokia message ring ...");
  46. }
  47. public void Receive()
  48. {
  49. Console.WriteLine("Hello!");
  50. }
  51. }
  52. class EricssonPhone : IPhone
  53. {
  54. public void Dail()
  55. {
  56. Console.WriteLine("Ericsson calling ...");
  57. }
  58. public void PickUp()
  59. {
  60. Console.WriteLine("Hello! This is Tim!");
  61. }
  62. public void Send()
  63. {
  64. Console.WriteLine("Ericsson ring ...");
  65. }
  66. public void Receive()
  67. {
  68. Console.WriteLine("Good evening!");
  69. }
  70. }

没有用接口时,如果一个类坏了,你需要 Open 它再去修改,修改时可能产生难以预料的副作用。引入接口后,耦合度大幅降低,换手机只需要换个类名,就可以了。

等学了反射后,连这里的一行代码都不需要改,只要在配置文件中修改一个名字即可。

在代码中只要有可以替换的地方,就一定有接口的存在;接口就是为了解耦(松耦合)而生。

松耦合最大的好处就是让功能的提供方变得可替换,从而降低紧耦合时“功能的提供方不可替换”带来的高风险和高成本。

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

依赖反转原则

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

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

028 接口,依赖反转,单元测试 - 图3

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

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

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

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

028 接口,依赖反转,单元测试 - 图4

单元测试

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

紧耦合:

  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. public 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. public void PowerLowerThanZero_OK()
  8. {
  9. var fan = new DeskFan(new PowerSupplyLowerThanZero());
  10. var expected = "Won't work.";
  11. var actual = fan.Work();
  12. Assert.Equal(expected, actual);
  13. }
  14. [Fact]
  15. public void PowerHigherThan200_Warning()
  16. {
  17. var fan = new DeskFan(new PowerSupplyHigherThan200());
  18. // 注:此处为了演示,实际程序那边先故意改成了 Exploded!
  19. var expected = "Warning";
  20. var actual = fan.Work();
  21. Assert.Equal(expected, actual);
  22. }
  23. }
  24. class PowerSupplyLowerThanZero : IPowerSupply
  25. {
  26. public int GetPower()
  27. {
  28. return 0;
  29. }
  30. }
  31. class PowerSupplyHigherThan200 : IPowerSupply
  32. {
  33. public int GetPower()
  34. {
  35. return 220;
  36. }
  37. }
  38. }

每当有新的代码提交后,就将 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