我们在日常使用 JavaScript 开发过程中,一般可通过面向过程的编程范式直接实现页面交互功能,一段独立的逻辑直接用函数来实现就能单独调用,后续扩展功能可利用原型模式的概念动态增添。ES6(ECMAScript 2015)增加了面向对象的特性,可以使用对象来进行页面开发,搭配 React 及 Vue 这类的框架可以快速搭建一个完整的系统。

最近 MySQL 官方宣布了正式在 MySQL 中引入了 JavaScript 支持,可以通过编写执行 JavaScript 存储过程、在 SQL 语句中执行 JavaScript 代码等来进一步方便操作,同时可支持 ECMAScript 2021 标准特性,对于 JavaScript 来说算是一个里程碑事件。

今天我们就来聊聊 JavaScript 的另外一个特性:函数式编程

函数式编程(Functional Programming)是一种编程范式,它强调使用纯函数来解决问题。以下是函数式编程的几个主要特点:

  • 纯函数(Pure Functions):函数的输出只依赖于其输入,并且执行过程中没有副作用(例如更改全局变量或改变输入参数的值)。这使得函数在给定相同输入的情况下,无论运行多少次,都会产生相同的结果。
  • 不可变性(Immutability):在函数式编程中,数据一旦被创建,就不能改变。如果需要修改数据,就必须创建一个新的副本。
  • 高阶函数(Higher-Order Functions):在函数式编程中,函数可以接受其他函数作为参数,并且可以把函数作为结果返回。这样可以轻松地构建和组合函数。
  • 递归代替循环(Recursing):由于函数式编程避免了状态的变化,因此循环必须通过递归来实现。递归的精髓是描述问题,而这正是函数式编程的精髓。
  • 表达式求值(Expression Evaluation):函数式编程通常倾向于使用表达式而不是语句。换言之,每个块代码(如函数、if条件等)都应该计算并返回值。
  • 引用透明(Referential Transparency):由于函数的输出只依赖于其输入(纯函数),相同的输入总是得到相同的输出,这使得函数在程序中可以进行自由地替换和重组,也利于进行各种优化。
  • 函数组合(Function Composition):通过将小函数串联起来,以解决复杂问题。
  • 惰性计算(Lazy Evaluation):只有在需要时才计算表达式的值,这样可以提高效率,处理无限数据结构等。

并且在使用递归进行调用时,可进一步优化为尾递归调用。尾递归(Tail Call Optimization)是指在函数返回的时候调用自身本身,并且 return 语句不能包含表达式。这样编译器或者解释器就可以把其优化成一个循环,使得不管函数递归多少次,都只需要一个栈帧,大大减少了栈的使用,可以有效避免栈溢出的情况,并且能够提高运行速度。

不过对于 JavaScript 来说,尽管 ES6 规范中明确引入了尾递归优化(Tail Call Optimization),理论上 JavaScript 应该支持尾递归优化。不过由于一些实际的问题和限制,大部分的浏览器依然没有实现尾递归优化。例如,在 Chrome 、Firefox 、和 Safari 等主流浏览器中,只有 Safari 的 JavaScriptCore 引擎在开启了严格模式("use strict")下才会进行尾递归优化。因此虽然理论上 ES6 规范包含了尾递归优化,但在实践中,我们仍然不能完全依赖或假定这个优化已经普遍存在。

这一次我们直接拿类似 Array.prototype.indexOf() 的实现逻辑来举例说明函数式编程的示例。

我们来看正常的实现方式如下所示:

function indexOf (x, y) {
    for (let i = 0; i < x.length; i++) {
        if (x[i] == y) return i;
    }
    return -1;
}

首先我们需要把循环优化成递归的形式。

function indexOf (x, y, i=0) {
    if (i >= x.length) return -1;
    if (x[i] == y) return i;
    return indexOf(x, y, i+1);
}

如果要将 indexOf 转换成函数变量,该怎么递归呢?我们可以考虑以下形式的定义:

function combinator(func) {
  func(func);
}

进一步优化成箭头函数的形式。

(func) => (func(func)) 

一般匿名函数的递归实现,可以使用这种方式:

(...) ( // 左边的(...)为函数体,需定义为传递三个参数的箭头函数,可以将(func) => (func(func)) 带入
    () => ( // 第一个调用参数,一般为 func 的实现
        ...
    ),
    arg1, // 第二调用参数
    arg2, // 第三调用参数
) 

带入到 indexOf 的定义。

( (func, x, y, i) => func(func, x, y, i) ) (  // 函数体
    (func, x, y, i=0) => (
        i >= x.length ?  -1 :
            x[i] == y  ?  i : func (func, x, y, i+1)
    ), // 第一个调用参数
    arr, // 第二调用参数,传入数组
    2 // 第三调用参数,传入具体值
)

然后我们需要用到高阶函数的定义方式了,在前面我们已经知道高阶函数就是将其他函数作为参数传入,并且可以把函数作为结果返回的这么一个形式。比方说

highfunc = function(func) {
    return function(x, y, i=0) {
        return i >= x.length ?  -1 :
            x[i] == y  ?  i : func (func, x, y, i+1)
    }
}

let arr = [0,1,2,3,4,5];
indexOf = highfunc(highfunc);
indexOf(arr, 2);

但是如果让调用者直接这么调用,还是很不方便的,所以我们用一个函数把 highfunc(highfunc) 给代理一下。

indexOf = function(highfunc) {
    return highfunc(highfunc);
} (
    // 调用参数是一个函数
    function(func) {
        return function(x, y, i=0) {
            i >= x.length ?  -1 :
                x[i] == y  ?  i : func (func, x, y, i+1)
        }
    }
)

let arr = [0,1,2,3,4,5];
indexOf(arr, 2); // 于是我们就可以直接使用了

再用箭头函数重构一下,就是我们的最终版本。

// 为了应用函数式编程不可变性的特点,需声明成 const
const indexOf = ( highfunc => highfunc( highfunc ) ) (
    func => (x, y, i = 0) => (
        i >= x.length ?  -1 :
            x[i] == y  ?  i : func (func) (x, y, i+1)
    )
);

let arr = [0,1,2,3,4,5];
indexOf(arr, 2);

接下来我们看看在 Go 中是否可以使用函数式编程,并且是否可以利用到 TCO 的特性来优化性能。这里我们直接写一个逻辑来验证。
在之前的博文中分享过一个切片是否存在元素的泛型检查函数,这里我们就拿这个函数尝试编写一个递归函数试试。(因 Go 中 func 类型无法用 const 来声明,并且函数泛型无法使用在 Lambda 表达式中,所以只能定义一个外部函数用来验证 TCO)

import "time"

func In[T comparable](needle T, haystack []T) bool {
    for _, item := range haystack {
        if item == needle {
            return true
        }
    }
    return false
}

func FnIn[T comparable](needle T, haystack []T) bool {
    // 注意这边必须先定义才能在内部递归
    var f func(needle T, haystack []T, i int) bool
    f = func(needle T, haystack []T, i int) bool {
        if i >= len(haystack) {
            return false
        }

        if haystack[i] == needle {
            return true
        }

        return f(needle, haystack, i+1)
    }
    return f(needle, haystack, 0)
}

func TrackTime() func() {
    pre := time.Now()
    return func() {
        elapsed := time.Since(pre)
        println("elapsed: ", elapsed.Microseconds())
    }
}

func TestIn(arr []int) {
    defer TrackTime()()
    println(In(99999, arr))
}

func TestFnIn(arr []int) {
    defer TrackTime()()
    println(FnIn(99999, arr))
}

func main() {
    arr := make([]int, 0, 100000)
    for i := 1; i <= 100000; i++ {
        arr = append(arr, i)
    }

    TestIn(arr)
    TestFnIn(arr)
}

执行结果相差很大。(多次执行时间不同,但两者的差距依然很大)

true
elapsed:  543  
true           
elapsed:  10285

GPT-4 给出的解释是这样:

函数式编程中的尾递归是一种特殊的递归形式,
在尾递归中,函数返回的是一个函数调用本身,而且不需要在返回后执行更多的代码。
在支持尾递归优化的语言中,编译器或解释器会对这种特殊情况进行优化,
使得函数在递归调用时可以复用当前的栈帧而不是创建一个新的,这样可以防止栈溢出并提高性能。

然而,并不是所有的语言都内建支持尾递归优化。在Go语言中,尾递归并不会得到特殊处理。
Go的编译器没有实现尾调用优化(TCO),因此即使你写了一个尾递归的函数,每次函数调用仍然会创建一个新的栈帧。
这样不仅增加了内存的使用,也增加了函数调用的开销,从而导致了性能的下降。

与之相反,Go语言中的循环(如for循环)是构建在语言中作为一等公民的,并且在运行时非常高效。
Go语言的编译器和运行时都对循环进行了优化,使其性能非常好。

简单来说,虽然函数式编程的某些概念(如无副作用、不变性等)在软件工程中具有其价值,
但在Go语言这样没有尾递归优化的语境中,循环通常是更合适且更高性能的选择。
在选择编程范式和构造时,考虑目标平台的特性是很重要的。

好吧,还是老老实实该用循环的还是直接用循环来迭代就好了。

其实函数式编程在 Go 中的最佳应用就是 函数选项模式(Functional Options Pattern),简单来说就是通过构造 Options 结构来替代函数强制传参的方式,可解决部分传参及缺省参数的处理。

在 Go 标准库 google.golang.org/grpc 中 NewServer 就用到了函数选项模式:

// NewServer creates a gRPC server which has no service registered and has not
// started to accept requests yet.
func NewServer(opt ...ServerOption) *Server {
    opts := defaultServerOptions
    for _, o := range globalServerOptions {
        o.apply(&opts)
    }
    for _, o := range opt {
        o.apply(&opts)
    }
    s := &Server{
        lis:      make(map[net.Listener]bool),
        opts:     opts,
        conns:    make(map[string]map[transport.ServerTransport]bool),
        services: make(map[string]*serviceInfo),
        quit:     grpcsync.NewEvent(),
        done:     grpcsync.NewEvent(),
        czData:   new(channelzData),
    }
    chainUnaryServerInterceptors(s)
    chainStreamServerInterceptors(s)
    s.cv = sync.NewCond(&s.mu)
    if EnableTracing {
        _, file, line, _ := runtime.Caller(1)
        s.events = trace.NewEventLog("grpc.Server", fmt.Sprintf("%s:%d", file, line))
    }

    if s.opts.numServerWorkers > 0 {
        s.initServerWorkers()
    }

    s.channelzID = channelz.RegisterServer(&channelzServer{s}, "")
    channelz.Info(logger, s.channelzID, "Server created")
    return s
}

NewServer 的参数 opt 就是一个 Options 结构体,用来存储配置信息,并且提供了一些方法去设置 Options

// Server is a gRPC server to serve RPC requests.
type Server struct {
    opts serverOptions

    mu  sync.Mutex // guards following
    lis map[net.Listener]bool
    // conns contains all active server transports. It is a map keyed on a
    // listener address with the value being the set of active transports
    // belonging to that listener.
    conns    map[string]map[transport.ServerTransport]bool
    serve    bool
    drain    bool
    cv       *sync.Cond              // signaled when connections close for GracefulStop
    services map[string]*serviceInfo // service name -> service info
    events   trace.EventLog

    quit               *grpcsync.Event
    done               *grpcsync.Event
    channelzRemoveOnce sync.Once
    serveWG            sync.WaitGroup // counts active Serve goroutines for GracefulStop

    channelzID *channelz.Identifier
    czData     *channelzData

    serverWorkerChannel chan *serverWorkerData
}

type serverOptions struct {
    creds                 credentials.TransportCredentials
    codec                 baseCodec
    cp                    Compressor
    dc                    Decompressor
    unaryInt              UnaryServerInterceptor
    streamInt             StreamServerInterceptor
    chainUnaryInts        []UnaryServerInterceptor
    chainStreamInts       []StreamServerInterceptor
    binaryLogger          binarylog.Logger
    inTapHandle           tap.ServerInHandle
    statsHandlers         []stats.Handler
    maxConcurrentStreams  uint32
    maxReceiveMessageSize int
    maxSendMessageSize    int
    unknownStreamDesc     *StreamDesc
    keepaliveParams       keepalive.ServerParameters
    keepalivePolicy       keepalive.EnforcementPolicy
    initialWindowSize     int32
    initialConnWindowSize int32
    writeBufferSize       int
    readBufferSize        int
    connectionTimeout     time.Duration
    maxHeaderListSize     *uint32
    headerTableSize       *uint32
    numServerWorkers      uint32
    recvBufferPool        SharedBufferPool
}

var defaultServerOptions = serverOptions{
    maxReceiveMessageSize: defaultServerMaxReceiveMessageSize,
    maxSendMessageSize:    defaultServerMaxSendMessageSize,
    connectionTimeout:     120 * time.Second,
    writeBufferSize:       defaultWriteBufSize,
    readBufferSize:        defaultReadBufSize,
    recvBufferPool:        nopBufferPool{},
}
var globalServerOptions []ServerOption

// A ServerOption sets options such as credentials, codec and keepalive parameters, etc.
type ServerOption interface {
    apply(*serverOptions)
}

// EmptyServerOption does not alter the server configuration. It can be embedded
// in another structure to build custom server options.
//
// # Experimental
//
// Notice: This type is EXPERIMENTAL and may be changed or removed in a
// later release.
type EmptyServerOption struct{}

func (EmptyServerOption) apply(*serverOptions) {}

// funcServerOption wraps a function that modifies serverOptions into an
// implementation of the ServerOption interface.
type funcServerOption struct {
    f func(*serverOptions)
}

func (fdo *funcServerOption) apply(do *serverOptions) {
    fdo.f(do)
}

func newFuncServerOption(f func(*serverOptions)) *funcServerOption {
    return &funcServerOption{
        f: f,
    }
}

// joinServerOption provides a way to combine arbitrary number of server
// options into one.
type joinServerOption struct {
    opts []ServerOption
}

func (mdo *joinServerOption) apply(do *serverOptions) {
    for _, opt := range mdo.opts {
        opt.apply(do)
    }
}

func newJoinServerOption(opts ...ServerOption) ServerOption {
    return &joinServerOption{opts: opts}
}

然后我们知道 gRPC 有拦截器的功能,类似于业务逻辑中的中间件效果,我们来看看 gRPC 是如何组装拦截器配置的

// UnaryInterceptor returns a ServerOption that sets the UnaryServerInterceptor for the
// server. Only one unary interceptor can be installed. The construction of multiple
// interceptors (e.g., chaining) can be implemented at the caller.
func UnaryInterceptor(i UnaryServerInterceptor) ServerOption {
    return newFuncServerOption(func(o *serverOptions) {
        if o.unaryInt != nil {
            panic("The unary server interceptor was already set and may not be reset.")
        }
        o.unaryInt = i
    })
}

// StreamInterceptor returns a ServerOption that sets the StreamServerInterceptor for the
// server. Only one stream interceptor can be installed.
func StreamInterceptor(i StreamServerInterceptor) ServerOption {
    return newFuncServerOption(func(o *serverOptions) {
        if o.streamInt != nil {
            panic("The stream server interceptor was already set and may not be reset.")
        }
        o.streamInt = i
    })
}

通过 etcd 的 v3rpc.Server 方法我们可以学习到 Options 最佳加载实践,其中就有拦截器的配置:

import (
    "crypto/tls"
    "math"

    "go.etcd.io/etcd/etcdserver"
    pb "go.etcd.io/etcd/etcdserver/etcdserverpb"

    grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
    grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
    "go.etcd.io/etcd/clientv3/credentials"
    "google.golang.org/grpc"
    "google.golang.org/grpc/health"
    healthpb "google.golang.org/grpc/health/grpc_health_v1"
)

const (
    grpcOverheadBytes = 512 * 1024
    maxStreams        = math.MaxUint32
    maxSendBytes      = math.MaxInt32
)

func Server(s *etcdserver.EtcdServer, tls *tls.Config, gopts ...grpc.ServerOption) *grpc.Server {
    var opts []grpc.ServerOption
    opts = append(opts, grpc.CustomCodec(&codec{}))
    if tls != nil {
        bundle := credentials.NewBundle(credentials.Config{TLSConfig: tls})
        opts = append(opts, grpc.Creds(bundle.TransportCredentials()))
    }
    // 加载一元拦截器
    opts = append(opts, grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
        newLogUnaryInterceptor(s),
        newUnaryInterceptor(s),
        grpc_prometheus.UnaryServerInterceptor,
    )))
    // 加载流式拦截器
    opts = append(opts, grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(
        newStreamInterceptor(s),
        grpc_prometheus.StreamServerInterceptor,
    )))
    // 其他一些配置加载
    opts = append(opts, grpc.MaxRecvMsgSize(int(s.Cfg.MaxRequestBytes+grpcOverheadBytes)))
    opts = append(opts, grpc.MaxSendMsgSize(maxSendBytes))
    opts = append(opts, grpc.MaxConcurrentStreams(maxStreams))
    // 起服务
    grpcServer := grpc.NewServer(append(opts, gopts...)...)

    pb.RegisterKVServer(grpcServer, NewQuotaKVServer(s))
    pb.RegisterWatchServer(grpcServer, NewWatchServer(s))
    pb.RegisterLeaseServer(grpcServer, NewQuotaLeaseServer(s))
    pb.RegisterClusterServer(grpcServer, NewClusterServer(s))
    pb.RegisterAuthServer(grpcServer, NewAuthServer(s))
    pb.RegisterMaintenanceServer(grpcServer, NewMaintenanceServer(s))

    // server should register all the services manually
    // use empty service name for all etcd services' health status,
    // see https://github.com/grpc/grpc/blob/master/doc/health-checking.md for more
    hsrv := health.NewServer()
    hsrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
    healthpb.RegisterHealthServer(grpcServer, hsrv)

    // set zero values for metrics registered for this grpc server
    grpc_prometheus.Register(grpcServer)

    return grpcServer
}

如果你想了解 gRPC 拦截器的更多知识,请参考 gRPC 拦截器那点事,希望帮到你(六)

最后我们知道,PHP 跟 JavaScript 很像,可以不用强制声明变量的类型,7.4 还增加了箭头函数的特性,那 PHP 是否可以利用尾递归优化的效果来使用函数式编程呢?答案留给读者来验证吧~

参考文献:
如何读懂并写出装逼的函数式代码