Oct 11

讨论:里氏替换原则指导我们设计

Lrdcq , 2019/10/11 12:23 , 程序 , 閱讀(1515) , Via 本站原創
最近在内部群里有一些讨论,主要是各种重构重构和重构遇到的困难,和里氏替换原则真的是一个可执行的原则么,特别是iOS日常业务开发上。我们的结论是,麻烦!但是可以解决问题!
里氏替换原则是我们六大设计原则中存在感最弱的: “派生类对象可以在程序中代替其基类对象。” 也就是说,任何子类通过某种方式均可以保持和其父类一样的行为。

吵起来

说完描述,所有客户端开发同学都吵起来了:我们写界面的方式都不符合里氏替换原则,我们的做法有错嘛?
我们来看看常见的栗子:我们写一个按钮,有基类UIButton,然后我要定制一个红色主题的按钮,会这么写:
UIButton *btn = [UIButton new];
[btn setBackgroundColor:[UIColor redColor] forState:UIControlStateNormal];
[btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

当然,这个写法是非常正确的——通过熟悉组合去完成基础UIButton定制,还没遇到问题。不过,当我这个红色的按钮需要大量复用的时候,我们八成就会继承一个按钮出来了:
@interface RedButton : UIButton

@end

@implementation RedButton

- (instancetype)init {
    if (self = [super init]) {
        [self setBackgroundColor:[UIColor redColor] forState:UIControlStateNormal];
        [self setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
    }
    return self;
}

//同时限制其设置为别的颜色
- (void)setBackgroundColor:(UIColor *)color forState:(UIControlState)state {
    [super setBackgroundColor:[UIColor redColor] forState:state];
}

- (void)setTitleColor:(UIColor *)color forState:(UIControlState)state {
    [super setTitleColor:[UIColor whiteColor] forState:state];
}

@end

这样,我们就可以拿着这个RedButton漫天复用了。
但是这样做很明显不符合里氏替换原则的描述,即RedButton修改了UIButton的行为,使得RedButton并不能直接替换UIButton去使用了。

于是吵了起来:里氏替换原则真的有道理/可执行么?上面这个写法看起来没问题呀?实际上没有子类替换父类的场景呀?因此我们讨论2个问题:

1. 这么写有什么问题么?

首先,属性本质上是UIButton用来组合特定内容的方式,我们已经通过继承来替代组合了。用继承替代组合的坏处是,我们这个场景继承一个红色的按钮,还不如抽象行为“将一个按钮变红”来得方便。
不过看里氏替换涉及到的问题的话,UIButton和RedButton本身这个关系上,如果RedButton不限制setBackgroundColor/setTitleColor方法,它就不是一个真正的RedButton——毕竟颜色用户还可以改。如果做了限制——用户会疑惑,调用父类的方法居然无效。
因此,这个抽象本身是矛盾的。

2. 有没有更好的实现方法?

RedButton其实是一个相对于UIButton能力受限或者说行为受限的按钮,和UIButton其实没有啥直接关系,或者说RedButton和UIButton是平行的类更好,因此,苹果其实提供了方案,一个自定义的能力受限的交互控件应该继承于——UIControl,要有的能力都有了。而UIButton和RedButton没啥关系。

或者直面问题,我们不是真的要一个RedButton,而是只是想方便的在UIButton的构造过程中组合出红色的样式,并且复用——上文描述的这个需求,听其实就是工厂模式解决的问题了。因此在iOS中,我们可以添加工厂或者工厂方法:
@implementation RedButtonFactory
//或者
@implementation UIButton (RedButtonFactory)

+ (UIButton *)redButton {
    UIButton *btn = [UIButton new];
    [btn setBackgroundColor:[UIColor redColor] forState:UIControlStateNormal];
    [btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
    return btn;
}
@end


大问题

上面举的例子看起来无伤大雅,不过我们实际的一个线上事故暴露了类似的问题:

故事:我们有一个商品卡片,按数据GoodsViewModel的数据控制每个卡片上信息的展示。同时有一些场景有特殊的卡片展示需求,因此从GoodsViewModel上继承了场景AAGoodsViewModel/BBGoodsViewModel/CCGoodsViewModel。并且对数据进行了增删改动。

到目前为止听起来没啥问题。但是有一次基础需求,原本有一个字段yyy,是在普通卡片展示了某个东西YY,但是AAGoodsViewModel不展示getter强制返回null了;需求需要迁移这个字段到yyy2上,开发者修改了基类和对应的getter用的地方,唯独忘记了某个子类还需要复写为null,因此发生了事故。

事故定性:背景上开发者了解其父类抽象方式于业务基于父类的使用方式,但是对其每个子类干了什么不了解。此处子类修改了父类的行为即本应正常返回数据的方法被置为空,因此不符合里氏替换原则。

不过开发者在事后review的时候提出疑问:虽然确实不符合里氏替换,上面这个抽象是常见抽象方法,看起来并没有什么问题呀。并且举例子:有个类“鸟”,有方法canfly为true,子类“鸵鸟”,canfly返回false。

听起来没问题吧。不过经不起推敲:既然已经知道鸟不一定会飞,类“鸟”的canfly直接返回true就是有问题的。这种场景下canfly理应是一个抽象属性由子类去实现。

为什么这里canfly不能是一个带默认值的属性呢?因为一只“鸟”的canfly并不是既能飞也不能飞可以让用户随意修改的,而是一个未知的状态。因此不是像UIButton的background一样可以随便组合。

不过iOS的话,OC抽象类写起来比java麻烦多了,看起来会是:
//路径A:让需要子类实现的方法在父类的实现抛出异常,并且结果上使得父类无法直接运行
@implementation Brid
- (BOOL)canfly {
    @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:[NSString stringWithFormat:@"You must override %@ in a subclass.", NSStringFromSelector(_cmd)] userInfo:nil];
}
@end
//路径B:同时阻止父类通过自己初始化
@implementation Brid
- (instancetype)init {
   if ([self isMemberOfClass:[Brid class]]) {
        @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"You must override Brid in a subclass." userInfo:nil];
        return nil;
    } else {
        return [super init];
    }
}
@end
//路径C:用NS_UNAVAILABLE去阻止父类初始化并通过私有头文件提供初始化方法,比较黑科技,不推荐
@implementation Brid
- (instancetype)init NS_UNAVAILABLE;
+ (instancetype)new NS_UNAVAILABLE;
@end


依赖解除

注意到大部分场景下,里氏替换原则完成的基础是通过抽象类/接口/组合等方式解除目前看起来正常的类型与类型之间的直接依赖。其实本质上还是在说少用继承,多用组合,避免没有必要的继承。那么在实际代码场景里,有哪些替换继承解除依赖的还有哪些路径呢?

0. 当然,业务设计与建模全程接口化与抽象类化。上面描述的两个场景都是选择正确的基类和抽象类即可解。

1. 用delegate或者callback替换没必要的方法重写。
例如我们类型Brid提供fly()方法,并提供生命周期钩子willFly()与didFly(),默认实现为空,继承使用。
可以添加属性flyDelegate = BridFlyProtocol可选的实现两个方法。
可以添加属性willFlyBlock/didFlyBlock可选的实现两个回调。

2. 有意义的方法重写抽象为属性。
比如拓展view触区的- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event方法,我们抽象出hitTestEdgeInsets属性来承接通过这个方法拓展触区这个常用功能。
关键词:里氏替换原则 , oc
logo