2021年12月

导语

今天,我们来探讨下Go编程模式-函数式选项模式,英文叫Functional Optional。这是一个函数式编程的应用案例,目前在Go语言中比较流行的一种编程模式。某些业务对象在初始化时,如果属性多,大部分属性没有默认值,初始化的函数会非常冗长,代码也不好维护,下面就来看下函数式选项模式(Functional Optional)怎么解决这个问题。

业务对象的初始化

在实际的业务开发过程中,我们会经常性地需要对一个对象(或是业务实体)进行初始化。比如下面这个业务实体Company,又很多待初始化的列表:

type Company struct {
    Name      string
    ID        string
    Owner     string
    Addr      string
    Industry  string
    Product   string
    License   string
    Date      string
}

针对上面业务实体Company,有不少的属性字段(随便举的例子哈,不一定真实,用来理解下面的编程模式)

  1. Company下必填的属性是Name和ID,除了这两个之外,其他都是选填;
  2. 剩下的字段都是选填,必须有默认值;

我们需要有多种不同的创建 Company 的函数签名,如下所示:

func NewDefaultCompany(name string, id string) (*Company, error) {
    return &Company{name, id, "none", "china", "all", "none", "none", "none"}, nil
}

func NewCompanyWithOwner(name string, id string, owner string) (*Company, error) {
    return &Company{name, id, owner, "china", "all", "none", "none", "none"}, nil
}

func NewCompanyWithIndustry(name string, id string, owner string, industry string) (*Company, error) {
    return &Company{name, id, owner, "china", industry, "none", "none", "none"}, nil
}

func NewCompanyFull(name string, id string, owner string, addr string, industry string, product string, license string, date string) (*Company, error) {
    return &Company{name, id, owner, addr, industry, product, license, date}, nil
}

因为Go语言不支持重载,所以看到实际研发过程中,就会产生过多的签名,久而久之,代码的维护成本,需要初始化业务对象时,还得仔细看,究竟使用哪一个比较合理,或者需要再增加一个函数签名。

显然,上面的编码风格是很差的,要解决上面的问题,最常见的方法就是再抽象一个配置对象,比如:

type Config struct {
    Owner     string
    Addr      string
    Industry  string
    Product   string
    License   string
    Date      string
}

我们把那些非必输的选项都移到一个结构体里,于是 Company 对象变成了:

type Company struct {
    Name      string
    ID        string
    Conf      *Config
}

创建 Company 对象的函数签名可以收敛为一个:

func NewCompany(name string, id string, conf *Config) (*Company, error) {
    //...
}

这段代码算是不错了,大多数情况下,我们可能就止步于此了。但是,对于有洁癖的有追求的程序员来说,他们能看到其中有一点不好的是,Config 并不是必需的,所以,你需要判断是否是 nil 或是 Empty – Config{}这让我们的代码感觉还是有点不是很干净。

Builder模式

Builder设计模式是可以解决上面引入 Config 之后仍然存在的问题,引入Builder模式后,写出来的代码如下:

Company company = new Company.Builder()
  .name("ABC")
  .id("1")
  .owner("zhang san")
  .addr("china")
  .industry("all")
  .product("none")
  .license("1")
  .date("2020.2.2")
  .build();

仿照上面这个模式,我们可以把上面代码改写成如下的代码(注:下面的代码没有考虑出错处理,其中关于出错处理的更多内容,请参看《谈谈Go语言:出错处理》

type CompanyBuilder struct {
    Company
}
func (cb *CompanyBuilder) Create(name string, id string) *CompanyBuilder {
    cb.Company.Name = name
    cb.Company.ID = id
    return cb
}
func (cb *CompanyBuilder) WithOwner(owner string) *CompanyBuilder {
    cb.Company.Owner = owner
    return cb
}
func (cb *CompanyBuilder) WithIndustry(industry string) *CompanyBuilder {
    cb.Company.Industry = industry
    return cb
}

// 其他属性类似实现
……

func (cb *CompanyBuilder) Build() (Company) {
    return cb.Company
}

如此,就可以使用最上面的写法去初始化 Company 对象了,上面这样的方式也很清楚,不需要额外的 Config 类,使用链式的函数调用的方式来构造一个对象,只需要多加一个 CompanyBuilder 类,这个 CompanyBuilder 类似乎有点多余,我们似乎可以直接在 Company 上进行这样的 CompanyBuilder 构造,的确是这样的。但是在处理错误的时候可能就有点麻烦(需要为 Company 结构增加一个 error 成员,破坏了 Company 结构体的“纯洁”),不如一个包装类更好一些。

如果我们想省掉这个包装的结构体,那么就轮到我们的Functional Options上场了,函数式编程。

Functional Option

首先,定义一个函数类型:

type Option func(*Company)

然后,使用函数式编程的方式定义一组这样的初始化函数:

func Owner(owner string) Option {
    return func(c *Company) {
        c.Owner = owner
    }
}

func Addr(addr string) Option {
    return func(c *Company) {
        c.Addr = addr
    }
}

func Industry(industry string) Option {
    return func(c *Company) {
        c.Industry = industry
    }
}

func Product(product string) Option {
    return func(c *Company) {
        c.Product = product
    }
}

func License(license string) Option {
    return func(c *Company) {
        c.License = license
    }
}

func Date(date string) Option {
    return func(c *Company) {
        c.Date = date
    }
}

上面这组代码传入一个参数,然后返回一个函数,返回的这个函数会设置自己的 Company 参数。例如:当我们调用其中的一个函数用 Owner(“li si”) 时,其返回值是一个 func(c* Company) { c.Owner = “li si” } 的函数。

好了,现在我们再定一个 NewCompany()的函数,其中,有一个可变参数 options 其可以传出多个上面上的函数,然后使用一个for-loop来设置我们的 Company 对象。

func NewCompany(name string, id string, options ...func(*Company)) (*Company, error) {
    company := Company{
        Name:     name,
        ID:       id,
        Owner:    "zhang san",
        Addr:     "china",
        Industry: "all",
        Product:  "none",
        License: "",
        Date: "2020.2.2",
    }

    for _, option := range options {
        option(&company)
    }

    //...
    return &company, nil
}

于是,创建一个 Company 对象的时候,使用方法如下:

company,_ := NewCompany("ABC", "1", Owner("li si"))

怎么样,是不是高度的整洁和优雅?不但解决了使用 Config 对象方式 的需要有一个config参数,但在不需要的时候,是放 nil 还是放 Config{}的选择困难,也不需要引用一个 CompanyBuilder 的控制对象,直接使用函数式编程的试,在代码阅读上也很优雅。

所以,以后,大家在要玩类似的代码时,强烈推荐使用Functional Options这种方式,这种方式至少带来了如下的好处:

  • 直觉式的编程
  • 高度的可配置化
  • 很容易维护和扩展
  • 自文档,整个初始化的过程是按属性解耦的,不是按属性组合的
  • 对于新来的人很容易上手
  • 没有什么令人困惑的事(是nil 还是空)

背景

错误处理一直以一是编程必需要面对的问题,错误处理如果做的好的话,代码的稳定性会很好。不同的语言有不同的出现处理的方式。Go语言也一样,在本篇文章中,我们来讨论一下Go语言的出错出处,尤其是那令人抓狂的 if err != nil 。

在正式讨论Go代码里满屏的 if err != nil 怎么办这个事之前,我想先说一说编程中的错误处理。这样可以让大家在更高的层面理解编程中的错误处理。

C语言的错误检查

首先,我们知道,处理错误最直接的方式是通过错误码,这也是传统的方式,在过程式语言中通常都是用这样的方式处理错误的。比如 C 语言,基本上来说,其通过函数的返回值标识是否有错,然后通过全局的 errno 变量并配合一个 errstr 的数组来告诉你为什么出错。

为什么是这样的设计?道理很简单,除了可以共用一些错误,更重要的是这其实是一种妥协。比如:read(), write(), open() 这些函数的返回值其实是返回有业务逻辑的值。也就是说,这些函数的返回值有两种语义,一种是成功的值,比如 open() 返回的文件句柄指针 FILE* ,或是错误 NULL。这样会导致调用者并不知道是什么原因出错了,需要去检查 errno 来获得出错的原因,从而可以正确地处理错误。

一般而言,这样的错误处理方式在大多数情况下是没什么问题的。但是也有例外的情况,我们来看一下下面这个 C 语言的函数:

int atoi(const char *str)
这个函数是把一个字符串转成整型。但是问题来了,如果一个要传的字符串是非法的(不是数字的格式),如 “ABC” 或者整型溢出了,那么这个函数应该返回什么呢?出错返回,返回什么数都不合理,因为这会和正常的结果混淆在一起。比如,返回 0,那么会和正常的对 “0” 字符的返回值完全混淆在一起。这样就无法判断出错的情况。你可能会说,是不是要检查一下 errno,按道理说应该是要去检查的,但是,我们在 C99 的规格说明书中可以看到这样的描述:

7.20.1The functions atof, atoi, atol, and atoll need not affect the value of the integer expression errno on an error. If the value of the result cannot be represented, the behavior is undefined.

像atoi(), atof(), atol() 或是 atoll() 这样的函数是不会设置 errno的,而且,还说了,如果结果无法计算的话,行为是undefined。所以,后来,libc 又给出了一个新的函数strtol(),这个函数在出错的时会设置全局变量 errno :

long val = strtol(in_str, &endptr, 10);  //10的意思是10进制
//如果无法转换
if (endptr == str) {
    fprintf(stderr, "No digits were found\n");
    exit(EXIT_FAILURE);
}
//如果整型溢出了
if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) {
    fprintf(stderr, "ERROR: number out of range for LONG\n");
    exit(EXIT_FAILURE);
 }
//如果是其它错误
if (errno != 0 && val == 0) {
    perror("strtol");
    exit(EXIT_FAILURE);
}

虽然,strtol() 函数解决了 atoi() 函数的问题,但是我们还是能感觉到不是很舒服和自然。

因为,这种用 返回值 + errno 的错误检查方式会有一些问题:

  • 程序员一不小心就会忘记返回值的检查,从而造成代码的 Bug;
  • 函数接口非常不纯洁,正常值和错误值混淆在一起,导致语义有问题。

所以,后来,有一些类库就开始区分这样的事情。比如,Windows 的系统调用开始使用 HRESULT 的返回来统一错误的返回值,这样可以明确函数调用时的返回值是成功还是错误。但这样一来,函数的 input 和 output 只能通过函数的参数来完成,于是出现了所谓的 入参 和 出参 这样的区别。

然而,这又使得函数接入中参数的语义变得复杂,一些参数是入参,一些参数是出参,函数接口变得复杂了一些。而且,依然没有解决函数的成功或失败可以被人为忽略的问题。

Go语言的错误处理

Go 语言的函数支持多返回值,所以,可以在返回接口把业务语义(业务返回值)和控制语义(出错返回值)区分开来。Go 语言的很多函数都会返回 result, err 两个值,于是:

  • 参数上基本上就是入参,而返回接口把结果和错误分离,这样使得函数的接口语义清晰;
  • 而且,Go 语言中的错误参数如果要忽略,需要显式地忽略,用 _ 这样的变量来忽略;
  • 另外,因为返回的 error 是个接口(其中只有一个方法 Error(),返回一个 string ),所以你可以扩展自定义的错误处理。

另外,如果一个函数返回了多个不同类型的 error,你也可以使用下面这样的方式:

if err != nil {
  switch err.(type) {
    case *json.SyntaxError:
      ...
    case *ZeroDivisionError:
      ...
    case *NullPointerError:
      ...
    default:
      ...
  }
}

我们可以看到,Go语言的错误处理的的方式,本质上是返回值检查,但是他也兼顾了异常的一些好处 – 对错误的扩展。

资源清理

出错后是需要做资源清理的,不同的编程语言有不同的资源清理的编程模式:

  • C语言 – 使用的是 goto fail; 的方式到一个集中的地方进行清理。
  • C++语言- 一般来说使用 RAII模式,通过面向对象的代理模式,把需要清理的资源交给一个代理类,然后在析构函数来解决。
  • Java语言 – 可以在finally 语句块里进行清理。
  • Go语言 – 使用 defer 关键词进行清理。

下面是一个Go语言的资源清理的示例:

func Close(c io.Closer) {
  err := c.Close()
  if err != nil {
    log.Fatal(err)
  }
}
func main() {
  r, err := Open("a")
  if err != nil {
    log.Fatalf("error opening 'a'\n")
  }
  defer Close(r) // 使用defer关键字在函数退出时关闭文件。
  r, err = Open("b")
  if err != nil {
    log.Fatalf("error opening 'b'\n")
  }
  defer Close(r) // 使用defer关键字在函数退出时关闭文件。
}

Error Check Hell

好了,说到 Go 语言的 if err !=nil 的代码了,这样的代码的确是能让人写到吐。那么有没有什么好的方式呢,有的。我们先看如下的一个令人崩溃的代码。

func parse(r io.Reader) (*Point, error) {
    var p Point
    if err := binary.Read(r, binary.BigEndian, &p.Longitude); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.Latitude); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.Distance); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.ElevationGain); err != nil {
        return nil, err
    }
    if err := binary.Read(r, binary.BigEndian, &p.ElevationLoss); err != nil {
        return nil, err
    }
}

要解决这个事,我们可以用函数式编程的方式,如下代码示例:

func parse(r io.Reader) (*Point, error) {
    var p Point
    var err error
    read := func(data interface{}) {
        if err != nil {
            return
        }
        err = binary.Read(r, binary.BigEndian, data)
    }

    read(&p.Longitude)
    read(&p.Latitude)
    read(&p.Distance)
    read(&p.ElevationGain)
    read(&p.ElevationLoss)

    if err != nil {
        return &p, err
    }
    return &p, nil
}

上面的代码我们可以看到,我们通过使用Closure 的方式把相同的代码给抽出来重新定义一个函数,这样大量的 if err!=nil 处理的很干净了。但是会带来一个问题,那就是有一个 err 变量和一个内部的函数,感觉不是很干净。

那么,我们还能不能搞得更干净一点呢,我们从Go 语言的 bufio.Scanner()中似乎可以学习到一些东西:

scanner := bufio.NewScanner(input)
for scanner.Scan() {
    token := scanner.Text()
    // process token
}
if err := scanner.Err(); err != nil {
    // process the error
}

上面的代码我们可以看到,scanner在操作底层的I/O的时候,那个for-loop中没有任何的 if err !=nil 的情况,退出循环后有一个 scanner.Err() 的检查。看来使用了结构体的方式。模仿它,我们可以把我们的代码重构成下面这样:

首先,定义一个结构体和一个成员函数:

type Reader struct {
    r   io.Reader
    err error
}

func (r *Reader) read(data interface{}) {
    if r.err == nil {
        r.err = binary.Read(r.r, binary.BigEndian, data)
    }
}

然后,我们的代码就可以变成下面这样:

func parse(input io.Reader) (*Point, error) {
    var p Point
    r := Reader{r: input}
    r.read(&p.Longitude)
    r.read(&p.Latitude)
    r.read(&p.Distance)
    r.read(&p.ElevationGain)
    r.read(&p.ElevationLoss)
    if r.err != nil {
        return nil, r.err
    }
    return &p, nil
}

有了上面这个技术,我们的“流式接口Fluent Interface”,也就很容易处理了。如下所示:

var b = []byte {0x78, 0x79, 0x7a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x2c}
var r = bytes.NewReader(b)
type Person struct {
    Name [10]byte
    Age uint8
    Weight uint8
    err error
}
func (p *Person) read(data interface{}) {
    if p.err == nil {
        p.err = binary.Read(r, binary.BigEndian, data)
    }
}
func (p *Person) ReadName() *Person {
    p.read(&p.Name)
    return p
}
func (p *Person) ReadAge() *Person {
    p.read(&p.Age)
    return p
}
func (p *Person) ReadWeight() *Person {
    p.read(&p.Weight)
    return p
}
func (p *Person) Print() *Person {
    if p.err == nil {
        fmt.Printf("Name=%s, Age=%d, Weight=%d\n",p.Name, p.Age, p.Weight)
    }
    return p
}
func main() {
    p := Person{}
    p.ReadName().ReadAge().ReadWeight().Print()
    fmt.Printf("%v\n", p.err)  // EOF 错误
}

相信你应该看懂这个技巧了,但是,其使用场景也就只能在对于同一个业务对象的不断操作下可以简化错误处理,对于多个业务对象的话,还是得需要各种 if err != nil的方式。

包装错误

最后,多说一句,我们需要包装一下错误,而不是干巴巴地把err给返回到上层,我们需要把一些执行的上下文加入。

通常来说,我们会使用 fmt.Errorf()来完成这个事,比如:

if err != nil {
   return fmt.Errorf("something failed: %v", err)
}

另外,在Go语言的开发者中,更为普遍的做法是将错误包装在另一个错误中,同时保留原始内容:

type authorizationError struct {
    operation string
    err error   // original error
}
func (e *authorizationError) Error() string {
    return fmt.Sprintf("authorization failed during %s: %v", e.operation, e.err)
}

当然,更好的方式是通过一种标准的访问方法,这样,我们最好使用一个接口,比如 causer接口中实现 Cause() 方法来暴露原始错误,以供进一步检查:

type causer interface {
    Cause() error
}
func (e *authorizationError) Cause() error {
    return e.err
}

这里有个好消息是,这样的代码不必再写了,有一个第三方的错误库(github.com/pkg/errors),对于这个库,我无论到哪都能看到他的存在,所以,这个基本上来说就是事实上的标准了。代码示例如下:

import "github.com/pkg/errors"
//错误包装
if err != nil {
    return errors.Wrap(err, "read failed")
}
// Cause接口
switch err := errors.Cause(err).(type) {
case *MyError:
    // handle specifically
default:
    // unknown error
}

总结

所以,Go语言的错误处理比起C语言,简直好了不知道多少,别小看错误处理,这块处理的好,整体的代码可维护性,健壮性会好不少,处理不好,那么很可能GO TO HELL ^.^