【温故】设计原则: 里氏替换原则

前言

最近在读《敏捷软件开发 原则、模式与实践》,里面提到了几大设计原则,于是想复习下,并且用C#把文中代码重新写一遍

Liskov替换原则(LSP)

概念

子类必须能够替换掉他们的基类。

违反LSP的简单例子

很多情况下,违反LSP会导致违反OCP(开闭原则,上一篇博客有具体讲到)。

特别是在运行时用ifelse去区分一个对象的类型,以采取不同的行为,大概率会违反LSP。

using System;

namespace LSPExample
{
    // 违反LSP的例子:使用类型检查而不是多态
    public enum ShapeType
    {
        Square,
        Circle
    }

    public class Shape
    {
        public ShapeType Type { get; protected set; }
        
        public Shape(ShapeType type)
        {
            Type = type;
        }
    }

    public class Circle : Shape
    {
        public double Radius { get; set; }
        public Point Center { get; set; }
        
        public Circle() : base(ShapeType.Circle) { }
        
        public void Draw()
        {
            Console.WriteLine($"Drawing circle at ({Center.X}, {Center.Y}) with radius {Radius}");
        }
    }

    public class Square : Shape
    {
        public double Side { get; set; }
        public Point TopLeft { get; set; }
        
        public Square() : base(ShapeType.Square) { }
        
        public void Draw()
        {
            Console.WriteLine($"Drawing square at ({TopLeft.X}, {TopLeft.Y}) with side {Side}");
        }
    }

    public class Point
    {
        public double X { get; set; }
        public double Y { get; set; }
        
        public Point(double x, double y)
        {
            X = x;
            Y = y;
        }
    }

    public class Program
    {
        // 违反OCP和LSP的函数:需要知道所有具体类型
        public static void DrawShape(Shape shape)
        {
            // 使用类型检查而不是多态
            if (shape.Type == ShapeType.Square)
            {
                ((Square)shape).Draw();
            }
            else if (shape.Type == ShapeType.Circle)
            {
                ((Circle)shape).Draw();
            }
            // 每次添加新形状都需要修改这里!
        }
    }
}
问题分析
  1. DrawShape函数必须知道所有具体的形状类型

  2. 添加新形状需要修改DrawShape函数,违反开闭原则

  3. 使用类型检查而不是多态,代码难以维护

正方形和矩形的微妙违规

在数学中,正方形是一种特殊的矩形,那么是否正方形就要继承矩形呢。

using System;

namespace RectangleSquareExample
{
    // 初始设计:违反LSP
    public class Rectangle
    {
        public virtual double Width { get; protected set; }
        public virtual double Height { get; protected set; }
        
        public virtual void SetWidth(double width)
        {
            Width = width;
        }
        
        public virtual void SetHeight(double height)
        {
            Height = height;
        }
        
        public double Area => Width * Height;
    }

    // 正方形继承自矩形 - 看似合理,实则违反LSP
    public class Square : Rectangle
    {
        public override void SetWidth(double width)
        {
            base.SetWidth(width);
            base.SetHeight(width); // 保持正方形特性
        }
        
        public override void SetHeight(double height)
        {
            base.SetWidth(height);
            base.SetHeight(height); // 保持正方形特性
        }
    }

    public class Program
    {
        // 这个函数假设长方形的宽和高可以独立修改
        public static void TestRectangle(Rectangle rect)
        {
            rect.SetWidth(5);
            rect.SetHeight(4);
            
            // 预期:面积 = 20
            Console.WriteLine($"Expected area: 20, Actual area: {rect.Area}");
            
            // 如果传入Square,这里会失败!
            // 因为SetHeight(4)也会把宽度设为4,实际面积是16
            Assert.Equal(20, rectangle.Area);
        }
    }
}
问题分析

Square的设计者,没有考虑到Square和Rectangle在对象行为上的不同。把数学中的正方形是矩形,和面向对象的继承概念混为一谈。

基于契约的设计(Design by Contract)

概念

使用基于契约的设计,类的编写者显式地规定针对该类的契约。客户代码的编写者可以通过该契约了解可以依赖的行为方式。契约是通过为每个方法声明的前置条件(preconditions)和后置条件(postconditions)来指定的。要使一个方法得以执行,前置条件必须要为真。执行完毕后,该方法要保证后置条件为真。

Rectangle基于契约的设计
public class RectangleWithContract
    {
        private double width;
        private double height;
        
        public double Width
        {
            get => width;
            set
            {
                // 前置条件:值必须为正数
                Contract.Requires(value > 0, "Width must be positive");
                
                // 后置条件:设置后Width等于新值,Height不变
                double oldHeight = height;
                width = value;
                
                // 验证后置条件
                Contract.Ensures(Width == value);
                Contract.Ensures(Height == oldHeight);
            }
        }
        
        public double Height
        {
            get => height;
            set
            {
                Contract.Requires(value > 0, "Height must be positive");
                
                double oldWidth = width;
                height = value;
                
                Contract.Ensures(Height == value);
                Contract.Ensures(Width == oldWidth);
            }
        }
        
        public double Area => Width * Height;
    }

此时,Rectangle的契约:设置Width时需要保持Height不变。但Square无法满足,因为修改Width需要同时修改Height。

基于契约的设计可以第一时间让开发者提前发现错误。

当然,正方形和矩形的例子可能比较简单,一般人可能也不会犯这种错误。但在工作中,遇到一些复杂需求时,接受你代码的人可能没有完全理解你的代码就开始工作了,此时,基于契约的设计和单元测试的好处就体现出来了,代码即文档。

可能违反LSP的情况

原文还举了很多例子,但本文有点太长了。直接总结下可能违反LSP的情况和实现方式。

  1. 用ifelse区分对象类型

  2. 子类的方法退化或为空实现,基类使用者期望的功能被静默丢弃

  3. 子类加强了前置条件或减弱了后置条件

实现LSP的方式

  1. 提取公共抽象层,提取两个不适合继承的类的基类。

  2. 使用组合替代继承,两个不适合继承的类,应实现一个共享接口。

  3. 基于契约设计(Design By Contract)。

  4. 通过单元测试明确契约,测试即文档,帮助开发者理解正确使用方式。

参考文献

敏捷软件开发 原则、模式与实践

上一篇: 【温故】设计原则: 开闭原则
下一篇: 【温故】设计原则:接口隔离原则
ZZHSourceCode
博客等级 码龄9年 8粉丝 34原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值