面向协议编程
此文为资料汇总文,基于自己的理解收集网络上简明易懂的文章及例子,通篇浏览之后会对这个概念会有初步的认识。
参考资料:面向“接口”编程和面向“实现”编程
Protocol-Oriented Programming in Swift
Introducing Protocol-Oriented Programming in Swift 2
IF YOU'RE SUBCLASSING, YOU'RE DOING IT WRONG.
and so on...
因为简书的Markdown 不支持 [toc]生成目录,另外一种方式生成目录较为繁琐,所以贴个图片版的目录,另外希望简书早点支持[toc]:
目录什么叫做面向协议编程
我自己感觉比较明确的定义就是Apple wwdc15/408视频中的那句话:
Don't start with a class.
Start with a protocol.
从协议的角度开始编程😊
谈谈面对对象编程(OOP)的一些弊端
姿势不错如图如何走心。
-
面对对象的目的是大规模重用代码。
-
面对对象的手段是绑定结构和函数。
-
面对对对象的哲学含义是形象化抽象一个虚拟物体。
以上三个点可谓是面对对象编程的定义以及面对对象的好处,一旦聊到面对对象总会伴随 “代码重用”。我们从真实的世界来考虑这个问题,我们对客观存在的主体看法是会随着时间的改变而改变的,真实世界中甚至不存在固定形式化的抽象,而代码是为了具体问题而出现的,所以不存在通用抽象,也就不存在可以无限重用的定义和逻辑。所以对象也就是用于计算的模型而已,技术手段是正确的(给数据绑定操作) 但是对于目标(大规模代码重用)相去甚远,能重用的应该只有为了解决问题的方法,而不是只有模型。另外的难点,不同人为了解决相似问题,开发出来的模型可以十万八千里,为了重用模型,修改之后又能适应新问题,于是这叫泛化,它估计你去制造全能模型,但是不仅难,还没法向后兼容,有时候就硬是要把飞机做成鱼……这就是面向对象思维的硬伤,创造出一个大家都懂,大家都认为对,大家都能拿去用的模型太难!(摘自知乎精选)
我自己的感觉,类的继承让代码的可读性大大降低,比如我想知道这个类用途还要去看这个类的父类能干嘛假如它还有个祖父类呢?而且想想看假如一个项目由一个基类开始,并伴生了很多子类,解决需求发现需要更改基类的时候,不敢动手是多么恐怖的一件事情。
Java程序员对单个方法的实现超过10行感到非常不安,这代表自己的代码可重用性很差。于是他把一个3个参数的长方法拆成了4个子过程,每个子过程有10个以上的参数。后来他觉得这样很不OOP,于是他又创建了4个interface和4个class。
由一个简单的例子开始
让我们由这个例子开始面向“协议”编程
例子采用Rust语言,编辑器推荐使用CodeRunner
先用面对对象的视角,书可以燃烧,于是书有个方法 burn()。
书并不是唯一会燃烧的东西,木头也可以燃烧,它也有一个方法叫做 burn()。看看不是面向“协议”下是如何燃烧:
struct Book {
title: @str,
author: @str,
}
struct Log {
wood_type: @str,
}
这两个结构体分别表示书(Book)和木头(Log),下面实现它们的方法:
impl Log {
fn burn(&self) {
println(fmt!("The %s log is burning!", self.wood_type));
}
}
impl Book {
fn burn(&self) {
println(fmt!("The book %s by %s is burning!", self.title, self.author));
}
}
现在书与木头都有了 burn() 方法,现在我们烧它们。
先放木头:
fn start_fire(lg: Log) {
lg.burn();
}
fn main() {
let lg = Log {
wood_type: @"Oak",
length: 1,
};
// Burn the oak log!
start_fire(lg);
}
一切ok,输出 "The Oak log is burning!"。
现在因为我们已经有了一个 start_fire 函数,是否我们可以把书也传进去,因为它们都有 burn():
fn main() {
let book = Book {
title: @"The Brothers Karamazov",
author: @"Fyodor Dostoevsky",
};
// Let's try to burn the book...
start_fire(book);
}
可行么?肯定不行啊!函数已经指名需要Log结构体,而不是Book结构体,怎么解决这个问题,再写一个函数接受Book结构体?这样只会得到两个几乎一样的函数。
解决这个问题
加一个协议接口,协议接口在Rust语言中叫做 trait :
struct Book {
title: @str,
author: @str,
}
struct Log {
wood_type: @str,
}
trait Burnable {
fn burn(&self);
}
多了一个 Burnable 的接口,为每个结构体实现它们的接口:
impl Burnable for Log {
fn burn(&self) {
println(fmt!("The %s log is burning!", self.wood_type));
}
}
impl Burnable for Book {
fn burn(&self) {
println(fmt!("The book \"%s\" by %s is burning!", self.title, self.author));
}
}
接下来实现点火函数
fn start_fire<T: Burnable>(item: T) {
item.burn();
}
这里Swift跟Rust很像,T 占位符表示任何实现了这个接口的类型。
这样我们只要往函数里面传任意实现了 Burnable 协议接口的类型就没有问题。主函数:
fn main() {
let lg = Log {
wood_type: @"Oak",
};
let book = Book {
title: @"The Brothers Karamazov",
author: @"Fyodor Dostoevsky",
};
// Burn the oak log!
start_fire(lg);
// Burn the book!
start_fire(book);
}
成功输出:
The Oak log is burning!
The book “The Brothers Karamazov” by Fyodor Dostoevsky is burning!
于是这个函数完全能复用任意实现了 Burnable 协议接口的实例,cool...
在Objective-C中如何面对协议编程
OC毕竟是以面向对象为设计基础的,所以实现比较麻烦,接口在OC中为Protocol,Swift中强化了Protocol协议的地位(下节再讲Swift中的面向协议)。
目前大部分开发以面向对象编程为主,比如使用 ASIHttpRequest 来执行网络请求:
ASIHTTPRequest *request = [ASIHTTPRequest requestWithURL:url];
[request setDidFinishSelector:@selector(requestDone:)];
[request setDidFailSelector:@selector(requestWrong:)];
[request startAsynchronous];
发起请求的时候,我们需要知道要给request对象赋值哪一些属性并调用哪一些方法,现在来看看 AFNetworking 的请求方式:
AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];
[manager GET:@"www.olinone.com" parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {
NSLog(@"good job");
} failure:^(AFHTTPRequestOperation *operation, NSError *error) {
//to do
}];
一目了然,调用者不用关心它有哪些属性,除非接口无法满足需求需要去了解相关属性的定义。这是两种完全不同的设计思路。
接口比属性直观
定义一个对象的时候,一般都要为它定义一些属性,比如 ReactiveCocoa 中的 RACSubscriber 对象定义:
@interface RACSubscriber ()
@property (nonatomic, copy) void (^next)(id value);
@property (nonatomic, copy) void (^error)(NSError *error);
@property (nonatomic, copy) void (^completed)(void);
@end
以接口的形式提供入口:
@interface RACSubscriber
+ (instancetype)subscriberWithNext:(void (^)(id x))next
error:(void (^)(NSError *error))error
completed:(void (^)(void))completed;
@end
接口比属性更加直观,抽象的接口直接描述要做的事情。
接口依赖
设计一个APIService对象
@interface ApiService : NSObject
@property (nonatomic, strong) NSURL *url;
@property (nonatomic, strong) NSDictionary *param;
- (void)execNetRequest;
@end
正常发起Service请求时,调用者需要直接依赖该对象,起不到解耦的目的。当业务变动需要重构该对象时,所有引用该对象的地方都需要改动。如何做到既能满足业务又能兼容变化?抽象接口也许是一个不错的选择,以接口依赖的方式取代对象依赖,改造代码如下:
@protocol ApiServiceProtocol
- (void)requestNetWithUrl:(NSURL *)url Param:(NSDictionary *)param;
@end
@interface NSObject (ApiServiceProtocol) <ApiServiceProtocol>
@end
@implementation NSObject (ApiServiceProtocol)
- (void)requestNetWithUrl:(NSURL *)url Param:(NSDictionary *)param {
ApiService *apiSrevice = [ApiService new];
apiSrevice.url = url;
apiSrevice.param = param;
[apiSrevice execNetRequest];
}
@end
通过接口的定义,调用者可以不再关心ApiService对象,也无需了解其有哪些属性。即使需要重构替换新的对象,调用逻辑也不受任何影响。调用接口往往比访问对象属性更加稳定可靠。
抽象对象
定义ApiServiceProtocol可以隐藏ApiService对象,但是受限于ApiService对象的存在,业务需求发生变化时,仍然需要修改ApiService逻辑代码。如何实现在不修改已有ApiService业务代码的条件下满足新的业务需求?
参考Swift抽象协议的设计理念,可以使用Protocol抽象对象,毕竟调用者也不关心具体实现类。Protocol可以定义方法,可是属性的问题怎么解决?此时,装饰器模式也许正好可以解决该问题,让我们试着继续改造ApiService
@protocol ApiService <ApiServiceProtocol>
// private functions
@end
@interface ApiServicePassthrough : NSObject
@property (nonatomic, strong) NSURL *url;
@property (nonatomic, strong) NSDictionary *param;
- (instancetype)initWithApiService:(id<ApiService>)apiService;
- (void)execNetRequest;
@end
@interface ApiServicePassthrough ()
@property (nonatomic, strong) id<ApiService> apiService;
@end
@implementation ApiServicePassthrough
- (instancetype)initWithApiService:(id<ApiService>)apiService {
if (self = [super init]) {
self.apiService = apiService;
}
return self;
}
- (void)execNetRequest {
[self.apiService requestNetWithUrl:self.url Param:self.param];
}
@end
经过Protocol的改造,ApiService对象化身为ApiService接口,其不再依赖于任何对象,做到了真正的接口依赖取代对象依赖,具有更强的业务兼容性
定义一个Get请求对象
@interface GetApiService : NSObject <ApiService>
@end
@implementation GetApiService
- (void)requestNetWithUrl:(NSURL *)url Param:(NSDictionary *)param {
// to do
}
@end
改变请求代码
@implementation NSObject (ApiServiceProtocol)
- (void)requestNetWithUrl:(NSURL *)url Param:(NSDictionary *)param {
id<ApiService> apiSrevice = [GetApiService new];
ApiServicePassthrough *apiServicePassthrough = [[ApiServicePassthrough alloc] initWithApiService:apiSrevice];
apiServicePassthrough.url = url;
apiServicePassthrough.param = param;
[apiServicePassthrough execNetRequest];
}
@end
对象可以继承对象,Protocol也可以继承Protocol,并且可以继承多个Protocol,Protocol具有更强的灵活性。某一天,业务需求变更需要用到新的Post请求时,可以不用修改 GetApiService一行代码,定义一个新的 PostApiService实现Post请求即可,避免了对象里面出现过多的if-else代码,也保证了代码的整洁性。
依赖注入
GetApiService依然是以对象依赖的形式存在,如何解决这个问题?没错,依赖注入!依赖注入会让测试变得可行。
关于依赖注入可以看这篇文章
借助 objection 开源库改造ApiService:
@implementation NSObject (ApiServiceProtocol)
- (void)requestNetWithUrl:(NSURL *)url Param:(NSDictionary *)param {
id<ApiService> apiSrevice = [[JSObjection createInjector] getObject:[GetApiService class]];
ApiServicePassthrough *apiServicePassthrough = [[ApiServicePassthrough alloc] initWithApiService:apiSrevice];
apiServicePassthrough.url = url;
apiServicePassthrough.param = param;
[apiServicePassthrough execNetRequest];
}
@end
调用者关心请求接口,实现者关心需要实现的接口,各司其职,互不干涉。
我自己的感觉,利用OC来进行面向协议编程还是绕不过类这个坎,反而变成为了面向协议编程而进行协议编程,比较捉鸡。
Swift中的面向协议编程
WWDC15上表示
Swift is a Protocol-Oriented Programming Language
Talk is cheap,show you the code!
先看看这个例子
class Ordered {
func precedes(other: Ordered) -> Bool { fatalError("implement me!") }
}
class Number : Ordered {
var value: Double = 0
override func precedes(other: Ordered) -> Bool {
return value < (other as! Number).value
}
}
as!在swift中表示强制类型转换。
对于这种情况,苹果的工程师表示这是一种 Lost Type Relationships,我对这个的理解 是 失去对类型的控制。也就是这个Number类的函数往里边传非Number类型的参数会出问题。可能你们觉得这个问题还好,只要注意下Number下函数的函数实现就好了,但是在大型项目中,你使用一个类因为担心类型问题而需要去看类的实现,这样的编码是不是很让人烦躁?
利用Protocol来重写
直接上代码吧:
protocol Ordered {
func precedes(other: Self) -> Bool
}
struct Number : Ordered {
var value: Double = 0
func precedes(other: Number) -> Bool {
return self.value < other.value
}
}
用swift中的struct(结构体)来取代class
protocol 的Self表示任何遵循了这个协议的类型。现在就不用担心类型的问题了。
struct与class的区别
struct是值拷贝类型,而class是引用类型。这也是apple的工程师推荐使用struct代替class的原因。
struct无法继承,class可以继承。
关于值拷贝与引用的区别看下面的
code:
struct Dog{
var owner : String?
}
var 梅西的狗 = Dog(owner:"梅西")
var C罗的狗 = Dog(owner:"C罗")
var 贝尔的狗 = Dog(owner:"贝尔")
print(梅西的狗.owner,"与",C罗的狗.owner)
//此行输出 梅西与C罗
C罗的狗 = 梅西的狗
print(梅西的狗.owner,"与",C罗的狗.owner)
//此行输出 梅西与梅西
梅西的狗 = 贝尔的狗
print(梅西的狗.owner,"与",C罗的狗.owner)
//此行输出 贝尔与梅西
//C罗的狗.owner还是梅西
//使用class
class DogClass{
var owner : String?
}
var 梅西的狗 = DogClass()
梅西的狗.owner = "梅西"
var C罗的狗 = DogClass()
C罗的狗.owner = "C罗"
var 贝尔的狗 = DogClass()
贝尔.owner = "贝尔"
print(梅西的狗.owner,"与",C罗的狗.owner)
//此行输出 梅西与C罗
C罗的狗 = 梅西的狗
print(C罗的狗.owner)
//此行输出 梅西
梅西的狗.owner = 贝尔的狗.owner
print(梅西的狗.owner,"与",C罗的狗)
//此行输出 贝尔与贝尔
// C罗的狗的owner也变为贝尔了
再插入一幅图来理解引用类型吧:
简单的运用下我们定义的这个协议吧
以下是一个简单的二分查找算法函数实现:
func binarySearch<T : Ordered>(sortedKeys: [T], forKey k: T) -> Int {
var lo = 0
var hi = sortedKeys.count
while hi > lo {
let mid = lo + (hi - lo) / 2
if sortedKeys[mid].precedes(k) { lo = mid + 1 }
else { hi = mid }
}
return lo }
其中T(可以理解为 “占位符”)表示任何遵循 Ordered 协议的类型,这里就和开头使用Rust语言实现的程序异曲同工了。
Swift2.0引入的一个重要特性 protocol extension
也就是我们可以扩展协议,cool。
我们可以定义一个协议:
protocol MyProtocol {
func method()
}
然后在这个协议的extension中增加函数 method() 的实现:
extension MyProtocol {
func method() {
print("Called")
}
}
创建一个 struct 遵循这个协议:
struct MyStruct: MyProtocol {
}
MyStruct().method()
// 输出:
// Called
这样就可以实现类似继承的功能,而不需要成为某个类的子类。
cool吗?现在我们回过头来想想,使用OC编程中,系统固有的协议不借助黑魔法我们是否可以对已有的协议进行扩展?不能!(关于在OC中如何扩展协议自行搜索,此处不展开了)。
一个简单的例子运用 protocol extension
定义一个 Animal 协议和动物的属性:
protocol Animal {
var name: String { get }
var canFly: Bool { get }
var canSwim: Bool { get }
}
定义三个具体的动物:
struct Parrot: Animal {
let name: String
let canFly = true
let canSwim = false
}
struct Penguin: Animal {
let name: String
let canFly = true
let canSwim = true
}
struct Goldfish: Animal {
let name: String
let canFly = false
let canSwim = true
}
每一个动物都要实现一遍它们的 canFly 与 canSwim 属性显得很业余。
现在来定义Flyable、Swimable两个Protocol:
protocol Flyable {
}
protocol Swimable {
}
利用 extension给protocol添加默认实现:
extension Animal {
var canFly: Bool { return false }
var canSwim: Bool { return false }
}
extension Animal where Self: Flyable {
var canFly: Bool { return true }
}
extension Animal where Self: Swimable {
var canSwim: Bool { return true }
}
这样符合Flyable协议的Animal,canFly属性为true,复合Swimable的Animal,canSwim属性为true。
改造上面三个结构体:
struct Parrot: Animal, Flyable {
let name: String
}
struct Penguin: Animal, Flyable, Swimable {
let name: String
}
struct Goldfish: Animal, Swimable {
let name: String
}
在将来,你需要改动代码,比如 Parrot 老了,没办法飞了,就将Flyable的协议去掉即可。
好处:
- class只能继承一个class,类型可以遵循多个protocol,就可以同时被多个protocol实现多个默认行为。
- class,struct,enum都可以遵循protocol,而class的继承只能是class,protocol能给值类型提供默认的行为。
- 高度解耦不会给类型引进额外的状态。
一个简单的实战
这样一个简单的需求,一个登陆页面,用户输入的密码错误之后,密码框会有一个抖动,实现起来很简单:
import UIKit
class FoodImageView: UIImageView {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
好了,现在产品告诉你,除了密码框要抖动,登陆按钮也要抖动,那这样:
import UIKit
class ActionButton: UIButton {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
这已然是两个重复的代码,而且当你需要变动动画时候,你需要改动两处的代码,这很不ok,有OC编程经验的人会想到利用Category的方式,在swift中即为extension,改造如下:
import UIKit
extension UIView {
func shake() {
let animation = CABasicAnimation(keyPath: "position")
animation.duration = 0.05
animation.repeatCount = 5
animation.autoreverses = true
animation.fromValue = NSValue(CGPoint: CGPointMake(self.center.x - 4.0, self.center.y))
animation.toValue = NSValue(CGPoint: CGPointMake(self.center.x + 4.0, self.center.y))
layer.addAnimation(animation, forKey: "position")
}
}
这样看起来似乎已经很棒了,因为我们节约了代码,但是想一想,有必要为了一部分的视图增加一个共同的实现而是全部的UIView的类都具有这个 shake() 方法么,而且可读性很差,特别是当你的UIView的extension中的代码不停的往下增加变得很冗长的时候:
class FoodImageView: UIImageView {
// other customization here
}
class ActionButton: UIButton {
// other customization here
}
class ViewController: UIViewController {
@IBOutlet weak var foodImageView: FoodImageView!
@IBOutlet weak var actionButton: ActionButton!
@IBAction func onShakeButtonTap(sender: AnyObject) {
foodImageView.shake()
actionButton.shake()
}
}
单独看 FoodImageView 类和 ActionButton 类的时候,你看不出来它们可以抖动,而且 share() 函数到处都可以分布。
利用protocol改造
创建 Shakeable 协议
// Shakeable.swift
import UIKit
protocol Shakeable { }
extension Shakeable where Self: UIView {
func shake() {
// implementation code
}
}
借助protocol extension 我们把 shake() 限定在UIView类中,并且只有遵循 Shakeable 协议的UIView类才会拥有这个函数的默认实现。
class FoodImageView: UIImageView, Shakeable {
}
class ActionButton: UIButton, Shakeable {
}
可读性是不是增强了很多?通过这个类的定义来知道这个类的用途这样的感觉是不是很棒?假如产品看到别家的产品输入密码错误之后有个变暗的动画,然后让你加上,这个时候你只需要定义另外一个协议 比如 Dimmable 协议:
class FoodImageView: UIImageView, Shakeable, Dimmable {
}
这样很方便我们重构代码,怎么说呢,当这个视图不需要抖动的时候,删掉 shakeable协议:
class FoodImageView: UIImageView, Dimmable {
}
尝试从协议开始编程吧!
什么时候使用class?
- 实例的拷贝和比较意义不大的情况下
- 实例的生命周期和外界因素绑定在一起的时候
- 实例处于一种外界流式处理状态中,形象的说,实例像污水一样处于一个处理污水管道中。
final class StringRenderer : Renderer {
var result: String
...
}
在Swift中final关键字可以使这个class拒绝被继承。
别和框架作对
- 当一个框架希望你使用子类或者传递一个对象的时候,别反抗。
小心细致一些
- 编程中不应该存在越来越臃肿的模块。
- 当从class中重构某些东西的时候,考虑非class的处理方式。
总结
wwdc视频中明确表示:
Protocols > Superclasses
Protocol extensions = magic (almost)