类库大魔王的日常

一本流水帐

各种吐槽,倒苦水,开坑,立Flag。毫无营养,慎入!


C++/Go, Windows/Linux/macOS, iOS/Android, server/client development

Go语言用表驱动替换if-else/switch-case/select-case

没心情给avege做新功能,就断断续续做些重构工作,用gocyclo看出来很多函数的圈复杂度都很高,常规的做法,除了把一个大函数拆成几个小函数外,还要对代码逻辑进行调整,比较可观的做法是把if-elseswitch-caseselect-case替换掉。

Go对C的switch-case结构做了扩展,switch后的表达式值除了可以是整形外,还可以是字符串,case后可以同时接几个常量值,这使得我这样有多年C/C++使用经历的人一下子很喜欢用switch-case:

switch inbound {
  case "socks", "socks5":
  ...
  case "tunnel":
  ...
  case "redir":
  ...
}

这么一段代码就有4个分支,圈复杂度就会增加4,如果省略号处的代码稍微冗长一点,放几个if-else结构,圈复杂度还会更高。

重构这块代码分两步:

  1. 提取case处理逻辑到独立的函数中:

    switch inbound {
      case "socks", "socks5":
        onSocks()
      case "tunnel":
      	onTunnel()
      case "redir":
      	onRedir()
    }
    func onSocks() {...}
    func onTunnel() {...}
    func onRedir() {...}
    
  2. 用map替换switch-case

    inboundHandlers := map[string]func() {
      "socks": onSocks,
      "socks5": onSocks,
      "tunnel": onTunnel,
      "redir": onRedir,
    }
    if handler, ok := inboundHandlers[inbound]; ok {
      handler()
    }
    func onSocks() {...}
    func onTunnel() {...}
    func onRedir() {...}
    

如此圈复杂度就降到1了。


上面的handler非常简单,没有参数,没有返回值。可以稍微复杂一点,比如有一批字符串,现在需要判断它们是否匹配某种pattern,这些pattern可能是检测是否匹配一个正则表达式,可能是检测是否以某个子字符串结尾,也可能是检测是否与另一个字符串完全相同等等。我可能会写出这样的代码:

for _, s := range stringArray {
  if regexpMatched(s, pattern) {
    ...
  }
  if equalTo(s, pattern) {
    ...
  }
  if strings.HasSuffix(s, pattern) {
    ...
  }
  ...
}

这样有几种pattern,就会有几个if分支。而且,同样的匹配算法,可能因为pattern值不同,需要分别用一个if分支去处理,代码则会变得更冗长。得益于Go把函数作为first class value,可以通过closure来重构这块代码:

type checker func(string)bool
func regex(pattern string) checker {
  r := regexp.MustCompile(pattern)
  return func(s string)bool {
    retrun r.MatchString(s)
  }
}
func suffix(pattern string) checker {...}
func prefix(pattern string) checker {...}
func contains(pattern string) checker {...}
func equal(pattern string) checker {...}
checkers := []checker{
  regex(`\d+\w+`),
  regex(`\w+\d+`),
  suffix(`end with me`),
  prefix(`start with me`),
  contains(`including me`),
  equal(`equal to me`),
}

for _, s := range stringArray {
  for _, c := range checkers {
    if c(s) {
      ....
    }
  }
}

如此实现代码就变量整洁很多,只多了一个for循环,一次if分支便能检测所有patterns。如果有了新的检测条件,只要在checkers中新加一条规则即可。如果有新的算法,只要新加一个closure即可。而且像regex函数的实现,每个正则表达式也只需要在最开始被编译一次即可。


Go的一大精髓是channel,同时引入select关键字来简化操作,比如在Go中使用定时器,就需要使用到这种机制:

secondTicker := time.NewTicker(1 * time.Second)
minuteTicker := time.NewTicker(1 * time.Minute)
hourTicker := time.NewTicker(1 * time.Hour)
dayTicker := time.NewTicker(24 * time.Hour)
weekTicker := time.NewTicker(7 * 24 * time.Hour)
for doQuit := false; !doQuit; {
	select {
	case <-secondTicker.C:
		onSecondTimer()
	case <-minuteTicker.C:
		onMinuteTimer()
	case <-hourTicker.C:
		onHourTimer()
	case <-dayTicker.C:
		onDayTimer()
	case <-weekTicker.C:
		onWeekTimer()
	case <-quit:
		onQuit()
	}
}

这段代码为不同时长分别创建了一个定时器,然后用select检测定时器触发的channel,于是就有了这一堆的case

Go在标准库reflect包中提供了一个Select函数用于同时监听多个channels,但是这个设施稍微简陋了点,于是可以这么实现:

type onTicker func()
onTickers := []struct {
	*time.Ticker
	onTicker
}{
	{time.NewTicker(1 * time.Second), onSecondTicker},
	{time.NewTicker(1 * time.Minute), onMinuteTicker},
	{time.NewTicker(1 * time.Hour), onHourTicker},
	{time.NewTicker(24 * time.Hour), onDayTicker},
	{time.NewTicker(7 * 24 * time.Hour), onWeekTicker},
}

cases := make([]reflect.SelectCase, len(onTickers)+1)
for i, v := range onTickers {
	cases[i].Dir = reflect.SelectRecv
	cases[i].Chan = reflect.ValueOf(v.Ticker.C)
}
cases[len(onTickers)].Dir = reflect.SelectRecv
cases[len(onTickers)].Chan = reflect.ValueOf(quit)

for chosen, _, _ := reflect.Select(cases); chosen < len(onTickers); chosen, _, _ = reflect.Select(cases) {
	onTickers[chosen].onTicker()
}

for _, v := range onTickers {
	v.Ticker.Stop()
}

也就是说Select函数只接受一个[]reflect.SelectCase,有了事件也只返回那个channel在[]reflect.SelectCase中的索引号,于是我这里就另外创建了一个跟[]reflect.SelectCase对应的辅助slice onTickers用于存放相应的信息。

这个实现看起来代码似乎反而变复杂了,但优势是以后如果有新的channel要加进来,或要删掉一个已有的channel,只需要修改onTickers初始化的部分就行了。

毕竟以前接受的面向对象的教育是尽量少地对已有代码作改动嘛。

本文地址:

https://blog.minidump.info/2017/02/replace-switch-select-case-with-table-driven-in-go/

上一篇

OpenVPN同时监听TCP和UDP端口

在路由器上部署了全局翻墙后,出于简化部署的考虑,我把接在路由器后的一个Raspberry Pi作为VPN server,部署了OpenVPN,然后再在路由器上设置端口映射,可以从外网连回Raspberry Pi。这个方案有几个好处: OpenVPN相比PPTP等,自定义协议和端口更灵活方便 ...…

network 全文阅读
下一篇

新坑:imchenwen,用外部播放器观看在线视频

不知道怎么想的,突然开了个新坑,因为bilibili mac client并不能好好地工作,所以自己写一个也挺好玩的。目前已经完成了基本的功能,可以通过右键菜单调起外部的播放器观看几大主流视频网站,可以自行设置使用的播放器路径及参数,也可以自行选择要观看的视频质量。目前存在的一些问题: VI...…

imchenwen 全文阅读