Go语言中函数作为一等公民,探索灵活性和应用之道

241 阅读11分钟

我们先看看 Ward Cunningham 对“一等公民”的诠释

如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。

简单来说就是 Go 可以将函数赋值给一个变量。

函数可以作为一个参数传入函数、变量类型或者是返回值

作为入参

kube-proxy 中定义了 makeEndpointFunc, 在 ipvsnftablesiptables 都有对应的方法.

type makeEndpointFunc func(info *BaseEndpointInfo, svcPortName *ServicePortName) Endpoint

虽然实现不同,但是通过函数类型的统一,在不同的硬件支持下,仍然能够实例化出同样的 Endpoint 的信息,应用也不需要关心 Endpoint 具体是什么实现,只要能正常获取节点必要的信息完成上层逻辑就行,不需要关心 Endpoint 具体实现。

func NewEndpointsChangeTracker(hostname string, makeEndpointInfo makeEndpointFunc, ipFamily v1.IPFamily, recorder events.EventRecorder, processEndpointsMapChange processEndpointsMapChangeFunc) *EndpointsChangeTracker {
 return &EndpointsChangeTracker{
  endpointSliceCache:        NewEndpointSliceCache(hostname, ipFamily, recorder, makeEndpointInfo),
 }
}

可以看到 NewEndpointsChangeTracker 直接使用传入的 makeEndpointInfo 来初始化 Cache

闭包实现有状态的函数

正常一个函数调用完,就是释放相应的栈内存,但是有闭包的函数需要将闭包调用完,对应的栈内存才会被释放。

用一个专业一点的说法就是:函数调用返回后会有一个没有释放资源的栈区。

虽然有性能上的损耗,但是在编码给我们带来的便利性,也让我们可以在适当的时候应用他来提高代码的灵活性。

可以通过闭包来实现参数的传递,构造出新的函数。从而可以实现有状态的函数。

func NewDeploymentController(...) (*DeploymentController, error) {
  logger := klog.FromContext(ctx)  
  
 dInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
  AddFunc: func(obj interface{}) {
   dc.addDeployment(logger, obj)
  },
  UpdateFunc: func(oldObj, newObj interface{}) {
   dc.updateDeployment(logger, oldObj, newObj)
  },
  DeleteFunc: func(obj interface{}) {
   dc.deleteDeployment(logger, obj)
  },
 })
}

我们在调用 ResourceEventHandlerFuncs 的时候就不需要再传入 logger 参数了,可以直接构造函数的 logger ,而不需要再进行 logger 参数的定义。

灵活的可变参

我们看 k8sShardInformerFactory 构建方法的例子

type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory

SharedInformerOption 是一个方法类型, 传入 sharedInformerFactory 进行值的设置,然后返回的还是 sharedInformerFactory

func WithNamespace(namespace string) SharedInformerOption {
 return func(factory *sharedInformerFactory) *sharedInformerFactory {
  factory.namespace = namespace
  return factory
 }
}

type sharedInformerFactory struct {
 //...
 namespace        string
 //...
}

我们看 WithNamespace 通过闭包实现了 factory 参数的设置, WithNamespace 返回的是一个函数类型。

func NewSharedInformerFactoryWithOptions(
 client kubernetes.Interface,
 defaultResync time.Duration, 
 options ...SharedInformerOption,
) SharedInformerFactory {
 factory := &sharedInformerFactory{}

 // 这里应用所有可变参数
 for _, opt := range options {
  factory = opt(factory)
 }

 return factory
}

options 是这个例子中的可变参数,利用了 Go 可以支持相同类型的可变参数,我们实际业务中要设置的往往不是相同类型的,可以使用闭包函数来应用参数。

函子的应用

函子非常适合用来对容器集合元素进行批量同构处理,而且代码也比每次都对容器中的元素进行循环处理要优雅、简洁许多。函子是一个接口,定义了一个函数,函数入参为容器元素转换函数,返回值为函子接口。

函子实现是一个结构体,结构体有一个容器属性,实现了接口函数,逻辑为循环容器元素,并调用转换函数,将结果append到一个新容器,最后通过新容器构造一个新的实现结构体并返回。

他适用那些需要遍历处理每一个数据,支持像插件一样随意更换处理逻辑。

我们来看下面的例子

type User struct {
 ID   int
 Name string
}

type Functor[T any] []T

func (f Functor[T]) Map(fn func(T) T) Functor[T] {
 var result Functor[T]
 for _, v := range f {
  result = append(result, fn(v))
 }
 return result
}

通过 Functor 定义了函子的泛型,然后通过 Map 来处理每一个元素


func main() {
 users := []User{
  {ID: 1, Name: "Alice"},
  {ID: 2, Name: "Bob"},
  {ID: 3, Name: "Charlie"},
 }

 upperUserName := func(u User) User {
  u.Name = strings.ToUpper(u.Name)
  return u
 }

 us := Functor[User](users)
 us = us.Map(upperUserName)

 // 打印转换后的用户数据
 for _, u := range us {
  fmt.Printf("ID: %d, Name: %s\n", u.ID, u.Name)
 }
}

由于go 1.18支持了范型,让函子的用武之地更大了,原来没有支持之前,需要为每一种数据类型都实现一次,代码并不是特别通用。

函子看起来更像是一个语法糖,真正编写逻辑的时候,函子对代码抽象的帮助并不大,所以这里更多的是介绍使用的技巧,实际应用可以根据自己实际情况决定。

如何写好函数

这么多技巧,最终的目的也是服务于我们的系统设计落地到代码,让代码更好理解和维护。

那么我们怎么能在实践中真正的写好函数呢?

减少函数的参数

一个函数有太多参数,可以考虑是否用结构体封装起来,将信息进行聚合。

我们看一个创建用户的例子

func createUser(name string, age int, email string, address string, phoneNumber string) error{
    // ...
}

这里所有的参数都是可以归为用户的信息,所以我们可以通过 User 结构体进行聚合

// 使用结构体封装参数
type User struct {
    Name        string
    Age         int
    Email       string
    Address     string
    PhoneNumber string
}

func createUser(user User) error {
 //...
}

这样给参数赋予了给多的语义,也因为聚合成了一个结构体,让阅读代码的时候有更多的结构上下文信息。

也可以用可变参数,提供了修改的同时也有默认值,减少了调用方需要提供过多参数的心智负担,并且配置值如果是联动变化的,那就没必要写两个,而是由一个值推导出另一个值。

减少内部变量

方法内部如果变量太多也意味着方法承载了太多职能,需要进行拆分。

**一个函数做成瑞士军刀并不好,**会给调用方带来过多心智负担,修改的时候也需要找到所有使用者看是否会产生副作用。

那实际中我们怎么进行评估呢?可以通过编写单元测试来验证你的函数是否负责度过高

我们对一个函数进行测试,如果发现单元测试写起来十分困难,这种情况就要考虑函数承载的职责过多或者是语义不清晰。

func CreateOrder(order *Order) error {
 // 验证订单
 if len(order.Items) == 0 {
  return errors.New("order must have at least one item")
 }
 
 // 计算总价并更新库存
 totalPrice := 0.0
 for _, item := range order.Items {
  price, err := GetProductPrice(item.ProductID)
  if err != nil {
   return err
  }
  totalPrice += price * float64(item.Quantity)
  
  // 更新库存
  if err := UpdateInventory(item.ProductID, item.Quantity); err != nil {
   return err
  }
 }
 
 return nil
}

在这个方法里面我们发现,如果要给这个方法写单元测试,需要验证的东西非常多。

我们要订单合法性是否正确、验证订单总金额计算是否正确、库存是否更新成功,一旦有一个发生改动,测试起来将变得更加复杂。

我们可以对验证、库存和计算总价进行拆分

func ProcessOrder(order *Order) error {
 // 验证
 if err := ValidateOrder(order); err != nil {
  return err
 }

 // 计算总价
 totalPrice, err := CalculateTotalPrice(order)
 if err != nil {
  return err
 }

 // 更新库存
 if err := ProcessInventory(order); err != nil {
  return err
 }

 return nil
}

这样我们不需要对 ProcessOrder 这个方法进行测试,在集成测试再去验证即可,因为它更多承担的是胶水代码的作用。

这样我们单独测试验证订单、计算总金额和更新库存都会更简单。

注意函数长度

方法的长度多长合适呢?这里没有过多的定论,但是过分追求长度反而会带来副作用

最主要是控制好函数的圈复杂度,避免圈复杂度过高,导致阅读代码的时候需要记住的上下文太多,这就需要我们尽早进行返回,减少函数的阅读负担。

我们看一个注册用户的例子

func RegisterUser(username, password, email string) error {
 if username == "" {
  return errors.New("username cannot be empty")
 } else {
  if len(username) < 3 {
   return errors.New("username must be at least 3 characters long")
  } else {
   if password == "" {
    return errors.New("password cannot be empty")
   } else {
    if len(password) < 6 {
     return errors.New("password must be at least 6 characters long")
    } else {
     if email == "" {
      return errors.New("email cannot be empty")
     } else {
      if !isValidEmail(email) {
       return errors.New("invalid email format")
      } else {
       // 进行用户注册逻辑
       return nil
      }
     }
    }
   }
  }
 }
}

通过错误及时返回,我们可以修改成下面的样子

func RegisterUser(username, password, email string) error {
 if username == "" {
  return errors.New("username cannot be empty")
 }
 if len(username) < 3 {
  return errors.New("username must be at least 3 characters long")
 }
 if password == "" {
  return errors.New("password cannot be empty")
 }
 if len(password) < 6 {
  return errors.New("password must be at least 6 characters long")
 }
 if email == "" {
  return errors.New("email cannot be empty")
 }
 if !isValidEmail(email) {
  return errors.New("invalid email format")
 }

 // 进行用户注册逻辑
 return nil
}

注意函数命名

函数的名字怎么取更好?这个其实是我们需要长期去 review 的。

比如计算用户购物车的总金额,我们看下面的方法命名

func CalculateTotalPriceOfCartForUser(userID int, cartID int) float64 {}

这样远没有下面的命名来的明确

func GetCartTotal(userID, cartID int) float64 {}

小方法拆的多了,有可能会因为大家名字起的不一样,导致重复造轮子,这种情况可以通过函数规范命名来进行适当的规避。

试想如果我们通过直觉能很快找到对应功能的方法,我们还会去重新写一个吗?答案显然是否定的 。

函数抽象层级

函数内的代码是否都在同一个抽象层内,也是衡量一个函数好坏的一个标准。

在同一个抽象层的话就能够通过代码快速搞清楚一个函数究竟在做什么。

Controller 进行参数校验、 Service 进行逻辑处理、 Dao 进行数据库操作,这也是基本MVC 带来的三层抽象,那如果我们在 Controller 中进行数据库的访问,会让代码有了抽象层级的跳跃,理解起来难度也相应的会增大。

函数不是用来消除重复的

函数虽然可以消除重复代码,但是它不是消除重复代码的一种工具,它真正的价值是创造抽象。

如果我们以消除代码去抽象函数,最终代码可能会一团乱麻,甚至在不需要复用的时候,我们是甚至不知道这个函数究竟是用来干什么的。

我最开始抽象函数的原则是通过看代码是否发生重复,可是虽然需求的不断增加,原来复用的代码不断地被赋予个性化的逻辑。

等到最终演化的路径各不相同,我发现很多为了消除“代码重复”的函数变的难以理解。

所以想要写出好的函数,更重要的是创建好的抽象,即便现在没有复用,也会随着业务不断增加,抽象的逻辑会被逐渐复用起来。

随着层级变高,细节越来越少,越接近我们想要解决的实际问题。

我们交流的时候更多的是通过抽象出来的对象来解决我们遇到的业务问题,如何将要解决的问题翻译成维护性高的代码,是一直要致力于努力的方向。

小结

如果对性能有极至的要求,有时候甚至也需要牺牲一些代码的可读性,但是这个在业务研发场景中我们比较少遇到,所以不做过多的讨论。

到这里我们对文章进行小结:

  1. Go函数作为第一公民,可以赋值给变量、作为参数传递或作为返回值,我们看到在 kube-proxy 中的 makeEndpointFunc 定义中得到了体现,通过统一函数类型实现不同硬件的相同接口。

  2. 闭包允许函数保持状态,支持将外部变量捕获到内部函数中,提高了代码灵活性。在NewDeploymentController 中,通过闭包直接引用 logger,避免了额外参数的传递。通过闭包函数如 SharedInformerOption,实现更灵活的函数可变参数。

  3. 在最后也讨论了如何真正写好我们的函数:

a. 减少函数参数和内部变量,通过结构体封装或拆分函数实现

b. 控制函数长度和圈复杂度,尽早返回以减少阅读负担

c. 注意函数命名的准确性和简洁性,避免重复造轮子

d. 保持函数内代码在同一抽象层级,有助于快速理解函数功能

e. 函数的主要价值在于创造抽象,而不仅仅是消除重复代码