iOS 面试策略之经验之谈-面向协议的编程

2021-05-25 17:48:47 浏览数 (1)

2015 年 WWDC,苹果第一次提出了 Swift 的面向协议编程(Protocol Oriented Programming,以下简称 POP ),这是计算机历史上一个全新的编程范式。在此之前,相对应的面向对象的编程(Object Oriented Programming,以下简称 OOP )已经大行其道 50 年,它几乎完美的解决函数式编程(Functional Programming)的缺点,并且出现在从大型系统到小型应用、从服务器端到前端的各个方面。它的优点被无数程序员称颂,它解决了诸多开发中的大小问题。那么问题来了,既然 OOP 如此万能,为什么 Swift 要弄出全新的 POP ?

笔者认为,原因有三。其一,OOP 有自身的缺点。在继承、代码复用等方面,其灵活度不高。而 POP 恰好可以优雅得解决这些问题;其二,POP 可以保证 Swift 作为静态语言的安全性,而彼时 Objective-C 时代的 OOP,其动态特性经常会导致异常;其三,OOP 无法应用于值类型,而 POP 却可以将其优势拓展到结构体(struct)和枚举(enum)类型上。

本节将通过问题串联的形式,说明 POP 相比于 OOP 的优势,同时展示 POP 在实际开发中的运用。

POP vs OOP

1.什么是 OOP ?它在 iOS 开发中有哪些优点?

关键词:#面向对象编程

OOP 全称是 Object Oriented Programming,即面向对象的编程,是目前最主流的编程范式。在 iOS 开发中,绝大多数的部分运用的都是 OOP。

在 iOS 开发中,它有如下优点:

  • 封装和权限控制。相关的属性和方法被放入一个类中,Objective-C 中 ".h" 文件负责声明公共变量和方法,".m" 文件负责声明私有变量,并实现所有方法。Swift 中也有 public/internal/fileprivate/private 等权限控制。
  • 命名空间。在 Swift 中,不同的 class 即使命名相同,在不同的 bundle 中由于命名空间不同,它们依然可以和谐共存毫无冲突。这在 App 很大、bundle 很多的时候特别有用。Objective-C 没有命名空间,所以很多类在命名时都加入了驼峰式的前缀。
  • 扩展性。在 Swift 中,class 可以通过 extension 来进行增加新方法,通过动态特性亦可以增加新变量。这样我们可以保证在不破坏原来代码封装的情况下实现新的功能。Objective-C 中,我们可以用 category 来实现类似功能。另外,Swift 和 Objective-C 中还可以通过 protocol 和代理模式来实现更加灵活的扩展。
  • 继承和多态。同其他语言一样,iOS 开发中我们可以将共同的方法和变量定义在父类中,在子类继承时再各自实现对应功能,做到代码复用的高效运作。同时针对不同情况可以调用不同子类,大大增加代码的灵活性。

2.请谈谈 OOP 在 iOS 开发中的缺点

关键词:#内存 #继承

一般面试官这样问,我们不仅要回答出缺点,还要说出一个比较成熟的解决方案。一个专业的程序员不仅要知道问题出在哪里,更要知道该怎么修正问题。

OOP 有以下几个缺点:

  • 隐式共享。class 是引用类型,在代码中某处改变某个实例变量的时候,另一处在调用此变量时就会受此修改影响。示例代码如下:
代码语言:txt复制
class People { var name = “”}
// 创建张三,设置其名字为张三
let zhangSan = People()
zhangSan.name = “张三”

// 创建李四,设置其名字为李四
let liSi = zhangSan
Lisi.name = “李四”

print(zhangSan.name) // 李四
print(Lisi.name) // 李四

这很容易就造成异常。尤其是在多线程时,我们经常遇到的资源竞速(Race Condition)就是这个情况。解决方案是在多线程时枷锁,当然这个方案会引入死锁和代码复杂度剧增的问题。最好的解决这个问题是尽可能用诸如 struct 这样的值类型取代 class。

  • 冗杂的父类。试想这样一种场景,一个 UIViewController 的子类和一个 UITableViewController 中都需要加入 handleSomething() 这种方法。OOP 的解决方案是直接在 UIViewController 的 extension 中加入 handleSomething()。但是随着新方法越加越多,以后 UIVIewController 会越变越冗杂。当然我们也可以引入一个专门的父类或工具类,但是依然有职权不明确、依赖、冗杂等多种问题。
代码语言:txt复制
另一方面,父类中的 handleSomething() 方法必须由具体实现,它不能根据子类做出灵活调整。子类如果要做特定操作,必须要重写方法来实现。既然子类要重写,那么在父类中的实现在这种时候就显得多此一举。解决方案使用 protocol,这样它的方法就不需要用具体实现了,交给服从它的类或结构体即可。
  • 多继承。 Swift 和 Objective-C 是不支持多继承的,因为这会造成菱形问题,即多个父类实现了同一个方法,子类无法判断继承哪个父类的情况。在 Java 中,有 interface 的解决方案,Swift 中有类似 protocol 的解决方案。

2.说说 POP 相比于 OOP 的优势

关键词:#灵活 #安全

这道题是一个开放性的问题。在面试中一个很好的回答方式是理论 举例。POP 相比 OOP 具有如下优势。

  • 更加灵活。比如上题中我们提到的冗杂的父类的例子。我们可以用协议和其扩展来让所有服从此协议的 class 都可以用到默认的 handleSomething() 方法,同时服从了该协议的同时也增加了代码的可读性。具体代码如下:
代码语言:txt复制
protocol SomethingHandleable {
  func handleSomething()
}

extension SomethingHandleable {
  func handleSomething() {
    // 实现
  }
} 

class ViewController: UIViewController, SomethingHandleable { }
class TableViewController: UITableViewController, SomethingHandleable { }
  • 减少依赖。相对于传入具体的实例变量,我们可以传入 protocol 来实现多态。同时测试时也可以利用 protocol 来 mock 真实的实例,减少对于对象及其实现的依赖。比如下面这个实例:
代码语言:txt复制
protocol Request {
  func send(request: Info)
}

protocol Info {}

class UserRequest: Request {
  // 注意这里我们传入了Info这个protocol,它无需是具体的UserInfo,这方便了我们之后测试和扩展
  func send(info: Info) {
    // 实际实现,一般是把info发给server
  }
}

class UserInfo: Info {}

class MockUserRequest: Request {
  func send(info: Info) { // 这里我们就可以为测试方便来自定义实现 }
}

func testUserRequest() {
  let userRequest = MockUserRequest()
  userRequest.send(info: UserInfo())
}
  • 消除动态分发的风险。对于服从了 protocol 的类或结构体来说,它必须实现 protocol 声明的所有方法。否则编译时就会报错,这根本上杜绝了 runtime 时程序的风险,下面就是 POP 和 OOP 在动态派发时的对比:
代码语言:txt复制
// Objective-C下动态派发runtime报错实例
ViewController *vc = ...
[vc handleSomething];

TableViewController *tvc = ...
[tvc handleSomething];

NSObject *ob = ... // ob 没有实现handleSomething
NSArray *array = @[vc, tvc, ob];
for (id obj in array) { 
  [obj handleSomething]; // 能通过编译,但运行到ob时程序会崩溃
}

// Swift中使用了POP
let vc = ...
let tvc = ...
let ob = ...

let array: [SomethingHandleable] = [vc, tvc, ob] // 这里直接会报错,因为ob没有实现SomethingHandleable协议
  • 协议可以用于值类型。相比于 OOP 只能用于 class,POP 可以用于 struct 和 enum 这样的值类型上。比如下面这个例子:
代码语言:txt复制
protocol Flyable { }

protocol Bird {
  var name: String { get }
  var canFly: Bool { get }
}

extension Bird {
  var canFly: Bool { return self is Flyable }
}

struct ButterFly: Flyable {}

struct Penguin: Bird {
  var name = "Penguin"
}

struct Eagle: Bird, Flyable {
  var name = “Eagle”
}

enum FlyablePokemon: Flyable {
  case Pidgey
  case Duduo
}

POP 面试实战

4.要给一个 UIButton 增加一个点击后抖动的效果,该怎样实现?

关键词:#扩展 #协议

解决方案有三种。个人推荐用 protocol 来解决。

  • 实现一个自定义的 UIButton 类,在其中添加点击抖动效果的方法(shake 方法);
  • 写一个 UIButton 或者 UIView 的拓展(extension),然后在其中增加 shake 方法;
  • 定义一个 protocol,然后在协议扩展(protocol extension)中添加 shake 方法;

分析这三种方法:

  • 在自定义的类中添加 shake 方法扩展性不好。如果 shake 方法被用在其他地方,又要在其他类中再添加一遍 shake 方法,这样代码复用性差。
  • 在 extension 中实现虽然解决了代码复用性问题,但是可读性比较差。团队开发中并不是所有人都知道这个 extension 中存在 shake 方法,同时随着功能的扩展,extension 中新增的方法会层出不穷,它们很难归类管理。
  • 用协议定义解决了复用性、可读性、维护性三个难题。协议的命名(例如 Shakeable)直接可以确定其实现的 UIButton 拥有相应 shake 功能;通过协议扩展,可以针对不同类实现特定的方法,可维护性也大大提高;因为协议扩展通用于所有实现对象,所以代码复用性也很高。

5.优化以下代码

关键词:#Self #关联类型

代码语言:txt复制
protocol Food {}
struct Fish: Food {}
struct Bone: Food {}

protocol Animal {
  func eat(food: Food)
  func greet(other: Animal)
}

struct Cat: Animal {
  func eat(food: Food) {
    guard let _ = food as? Fish else {
      print("猫只吃鱼!")
      return
    }
  }

  func greet(other: Animal) {
    if let _ = other as? Cat {
      print("喵~")
    } else {
      print("猫很傲娇,不会对其他动物打招呼!")
    }
  }
}

struct Dog: Animal {
  func eat(food: Food) {
    guard let _ = food as? Bone else {
      print("狗只啃骨头!")
      return
    }
  }

  func greet(other: Animal) {
    if let _ = other as? Cat {
      print("汪~")
    } else {
      print("狗很骄傲,不会像其他动物打招呼!")
    }
  }
}

首先理清这道题目的基本逻辑,有 2 个协议,分别是 Food 和 Animal,然后两个结构体 fish 和 bone 分别服从 food 协议,cat 和 dog 分别服从 animal 协议。

其中又有两个方法为 eat 和 greet,我们发现分别在 cat 和 dog 中,eat 方法有对应类型的参数,同时 greet 也对应类型的参数。所以假如 cat 和 dog 中能在服从 Animal 协议的同时,又写出对应自己类型的函数,那就可以省掉 if else 这类判断了。比如下面这样:

代码语言:txt复制
struct Dog: Animal {
  func eat(food: Bone) {}
  func greet(other: Dog) { print("汪~") }
}

struct Cat: Animal {
  func eat(food: Fish) {}
  func greet(other: Cat) { print("喵~") }
}

很遗憾直接写成这样,程序是通不过编译的,Xcode 会提示,Cat 和 Dog 没有服从 Animal 协议,因为协议中要求的 food 必须是 Food,不能是 Bone 或者 Fish ,同理 greet 也是同样要求。但是我们可以用 Self 和关联类型去改进 Animal 协议,这样 Cat 和 Dog 这样写就没问题了。代码如下:

代码语言:txt复制
protocol Animal {
  associatedtype FoodType: Food

  func greet(other: Self)
  func eat(food: FoodType)
}

Self 相当于是 protocol 的占位符,它表示任意只要满足 Animal 的类型皆可。associatedType 就是关联类型,它实际上是一个类型的占位符,这样我们可以让 Dog 和 Cat 来指定 FoodType 到底是什么类型。而根据 greet 方法中对 FoodType 的使用,Swift 可以自动推断,FoodType 在 Cat 中是 Fish,在 Dog 中是 Bone。

6.试用 Swift 实现二分搜索算法

关键词:#Self #泛型

首先要审题,二分搜索算法,那么输入的对象是什么?是整型数组还是浮点型数组?如果输入不是排序过的数组该如何抛出异常?这些都是要在写答案之前与面试官探讨的问题。

我们先来热个身,假如面试官要求写出对于整型排序数组的二分搜索算法,则代码如下:

代码语言:txt复制
func binarySearch(sortedElements: [Int], for element: Int) -> Bool {
  var lo = 0, hi = sortedElements.count - 1

  while lo <= hi {
    let mid = lo   (hi - lo) / 2
    if sortedElements[mid] == element {
      return true
    } else if sortedElements[mid] < element {
      lo = mid   1
    } else {
      hi = mid - 1
    }
  }

  return false
}

上面的方法完成了面试官的要求,但是有如下几个问题。首先,这个方法只适用于整型数组;其次,虽然变量名为 sortedElements,但是我们无法保证输入的数组就一定是按序排列的。我们来看看用面向协议的编程来实现二分法:

代码语言:txt复制
extension Array where Element: Comparable {
  public var isSorted: Bool {

    var previousIndex = startIndex
    var currentIndex = startIndex   1

    while currentIndex != endIndex {
      if self[previousIndex] > self[currentIndex] {
        return false
      }

      previousIndex = currentIndex
      currentIndex = currentIndex   1
    }

    return true
  }
}

func binarySearch<T: Comparable>(sortedElements: [T], for element: T) -> Bool {
  // 确保输入数组是按序排列的
  assert(sortedElements.isSorted)

  var lo = 0, hi = sortedElements.count - 1

  while lo <= hi {
    let mid = lo   (hi - lo) / 2

    if sortedElements[mid] == element {
      return true
    } else if sortedElements[mid] < element {
      lo = mid   1
    } else {
      hi = mid - 1
    }
  }

  return false
}

上面解法首先在 Array 的扩展中加入了新变量 isSorted 用于判断输入的数组是否按序排列。之后在 binarySearch 的方法中运用了泛型,保证其中每一个元素都遵循 Comparable 协议,并且所有元素都是一个类型。有了上面的写法,我们可以将二分搜索法运用到各种类型的数组中,灵活性大大提高,例如:

代码语言:txt复制
binarySearch(sortedElements: [1,4,7], for: 4)            // true
binarySearch(sortedElements: [1.0,3.2,9.23], for: 3.2)   // true
binarySearch(sortedElements: ["1","2","3"], for: "4")    // false
binarySearch(sortedElements: ["4","2","3"], for: "4")    // assert failure

当然,上面方法还可以进一步优化。例如 Array 的扩展可以放到 Collection 之中;isSorted 可以接受数学符号进行正反向排序查询;binarySearch 方法可以直接写到 Collection 的扩展中进行调用。总之,运用 POP 的思路,可以写出严谨、灵活的代码,其实用性和可读性也非常之好。

文章到这里就结束了,感谢你的观看,只是有些话想对读者们说说:

iOS开发人群越来越少,说实在的,每次在后台看到一些读者的回应都觉得很欣慰,至少你们依然坚守iOS技术岗…为了感谢读者们,我想把我收藏的一些编程干货贡献给大家,回馈每一个读者,希望能帮到你们。

干货主要有:

① iOS中高级开发必看的热门书籍(经典必看)

② iOS开发技术进阶教学视频

③ BAT等各个大厂iOS面试真题 答案.PDF文档

④ iOS开发中高级面试"简历制作"指导视频

如果你用得到的话可以直接拿走;如何获取,具体内容请转看-我的GitHub

我的:GitHub地址

0 人点赞