我们在日常使用 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 是否可以利用尾递归优化的效果来使用函数式编程呢?答案留给读者来验证吧~
参考文献:
如何读懂并写出装逼的函数式代码