如何编写高质量的函数 -- 敲山震虎篇

29,716 阅读25分钟

一千个读者,有一千个哈姆雷特。

我将会从函数的执行机制、鲁棒性、函数式编程、设计模式等方面,全面阐述如何编写高质量的函数。

引言

如何编写高质量的函数,这是一个很难回答的问题,不同人心中对高质量有自己的看法,这里我将全面的阐述我个人对如何编写高质量函数的一些看法。看法可能不够全面,也可能会有一些错误的见解,欢迎一起讨论,就像过日子的人,小吵小闹总会不经意的出现,一颗包容的心莫过于是最好的 best practice

本文写作风格

写博客趋向意识流(胡诌流),按着我心里想的去写,不会去详细的说明某一个知识点,如果需要详细讨论,可以在文末加我微信细聊。

下面开始吧,我打算用三篇文章来完成 如何编写高质量的函数 这个系列。

三篇文章我将从以下几个方面去阐述,如何编写高质量的函数。

  • 函数(一切皆有可能)
  • 函数的命名
  • 函数的注释
  • 函数的复杂度
  • 函数的鲁棒性(防御性编程)
  • 函数的入参和出参(返回)
  • 如何用函数式编程打通函数的任督二脉
  • 如何用设计模式让函数如虎添翼
  • 编写对 V8 友好的函数是一种什么 style
  • 前端工程师的函数狂想录

下面开始胡诌啦:小声 BB ,可以先点个赞鼓励一下么(给一波精神上的鼓励)。

PS: 写过文章的小伙伴都知道,写一篇好文很不容易,很消耗精力,而写完博客后,最大的精神满足莫过于小伙伴的一个肯定的赞。 哎,其实这个系列已经写好了,本来想一篇文章发完的,但是看了下,2 万字,太多了,还是分三篇吧。

本篇只说第一节 函数 ,擒贼先擒王,下面我们来盘一盘函数的七七八八,往 XXX 上盘😂。

函数(一切皆有可能)

函数二字,代表着一切皆有可能。

我们想一下:我们用的函数究竟离我们有多远。就像打麻将一样,你觉得你能像雀神那样,想摸啥就来啥么(夸张修辞手法)。

天天和函数打交道,有没有想过函数出现的目的是什么?我们再深入想一下,函数的执行机制是什么?下面我们就来简单的分析一下。

函数出现的目的

函数是迄今为止发明出来的用于节约空间和提高性能的最重要的手段。

PS: 注意,没有之一。

函数的执行机制

有句话说的好,知己知彼,百战不殆。想要胜利,一定要非常的了解敌人。JS 肯定不是敌人啦,但是要想掌握 JS 的函数,要更轻松的编写高质量的函数,那就要去掌握在 JS 中,函数的执行机制。

怎么去解释函数的执行机制呢?

我个人认为,很多前端或者其他编程语言的开发者,对计算机的一些底层原理不太清楚,比如编译原理,计算机组成原理等。

我来模仿一个前端面试题:输入一个 url 后,会发生什么?(哈哈哈哈哈隔)。来出一个面试题:

执行一个函数,会发生什么?

参考下面代码:

function say() {
  let str = 'hello world'
  console.log(str)    
}

是不是发现很酷,这道面试题要是交给你,你能答出多少呢?

中断 5 分钟想一下。

好了,中断结束。如果让我来答,我大致会这样说:

首先我要创建一个函数,打住。如果你学过 C++ ,你可能不会这样说,你会这样说,我要先开辟一个堆内存。

所以,我会从创建函数到执行函数以及其底层实现,这三个层次进行分析:

创建函数

函数不是平白无故产生的,你要去创建一个函数,而创建函数的时候,究竟发生了什么呢?

答案如下:

第一步:我要开辟一个新的堆内存

为什么呢?因为每个字母都是要存储空间的,只要有数据,就一定得有存储数据的地方。而计算机组成原理中,堆允许程序在运行时动态地申请某个大小的内存空间,所以你可以在程序运行的时候,为函数申请内存。

第二步:我创建一个函数 say ,把这个函数体中的代码放在这个堆内存中。

想一下函数体是以什么样的形式放在堆内存中的?很明显,是以字符串的形式。 为什么呢?我们来看一下 say 函数体的代码是什么,如下:

let str = 'hello world'
console.log(str)  

你觉得这些语句以什么形式的结构放在堆内存中比较好呢,不用考虑也是字符串,因为没有规律。如果是对象的话,由于有规律,可以按照键值对的形式存储在堆内存中。而没规律的通常都是变成字符串的形式。

第三步:在当前上下文中声明 say 函数(变量),函数声明和定义会提升到最前面

注意有个关键的点,就是当前上下文,我们可以理解为上下文堆栈(栈),say 是放在堆栈(栈)中的,同时它的右边还有一个堆内存地址,用来指向堆中的函数体的。

PS: 建议去学习一下数据结构,栈中的一块一块的,我们称为帧。你可以把栈理解中 DOM 树,帧理解为节点,每一帧( 节点 )都有自己的名字和内容。

第四步:把开辟的堆内存地址赋值给函数名 say

这里一个关键点就是,把堆内存地址赋值给函数名 say

我特意在白板上画了一个简单的示意图:

结合上图 say 右边的存储,再去理解上面的四个步骤,是不是有点感悟了呢。

你真的懂赋值这个操作吗?

这里我突然想提一个简单的知识,就是赋值这个操作,比如我把堆内存地址赋值给函数名 say ,那么这意味着什么呢?

有很多人可能不明白,其实这里很简单,这一步赋值操作,从计算机组成原理角度看,内存分为好几个区域,比如代码区域,栈区域,堆区域等。

理解这几个区域一个最关键的点,就是要明白,每一个存储空间的内存地址都是不一样。也就是说,赋值(引用类型)的操作就是将堆区域的某一个地址,通过总线管道流入(复制)到对应栈区域的某一个地址中,从而使栈区域的某一个地址内的存储空间中有了引用堆区域数据的地址,这里业界叫句柄,说白了就是指针。只不过在高级语言中,把指针给隐藏了,直接有变量代替指针。

所以一个简单的赋值,其在计算机底层实现上,都是很复杂的,这里,也许通过汇编语言,你可以更好的去理解赋值的真正含义,比如 1 + 1 用汇编语言编写,就是下面代码:

start:
mov ax, 1
mov bx, 1
add ax, bx
end start;

从上面代码中,我们可以看到,把 1 赋值给 ax ,使用到了 mov 指令。而 movmove 移动的缩写,这也证明了,在赋值这个操作上,其实本质上是数据或者数据的句柄在一张地址表中的流动。

PS: 所以如果是值类型,那就是直接把数据,流(移动)到指定内存地址的存储空间中。

创建函数就先说到这了,其实我已经说得非常详细了,从计算机底层去解释一些最基础的现象。

执行函数

执行函数这个步骤,也非常重要,执行函数究竟是什么样的过程,现在我就用我个人的总结去解释这个过程。

思考一个点。

我们知道,函数体的代码是在保存在堆内存中的,而且是字符串形式。那么如果我们要执行堆内存中的代码,首先要做的就是讲字符串变成真正的 JS 代码,这个是比较容易理解的,就像数据传输中的序列化和反序列化。

思考题一:为什么会存在序列化和反序列化?大家可以自行思考一下,有些越简单的道理,背后越是有着非凡的思想

将字符串变成真正的 JS 代码

如何将字符串变成 JS 代码,这里有一个前置知识,就是:

每一个函数调用,都会在函数上下文堆栈中创建帧。

栈是什么?

栈是一个基本的数据结构,这里我就不解释了,小伙伴不懂的先去百度一下看看。

为什么函数执行要在栈中执行呢?

最关键的一点就是,栈是先进后出的数据结构,我们想一下,被也就意味着可以很好的保存和恢复调用现场。为什么?我们来看一段代码:

function f1() {
  let b = 1;
  function f2() {
    cnsole.log(b)
  }
  return f2
}

let fun = f1()
fun()

这里先不解释,继续往下看。

函数上下文堆栈是什么?

我们可以这样去理解,函数上下文堆栈是一个数据结构,不管它是什么,如果学过 C++ 或者 C 的,可以理解成是一个 struct (结构体)。这个结构体负责管理函数执行已经关闭变量作用域。函数上下文堆栈在程序运行时就会产生,并且一开始加入到栈里面的是全局上下文帧,位于栈底。

开始执行函数

首先要明白一点:

执行函数(函数调用)是在栈上完成的

这也就是为什么 JS 函数可以递归。因为栈的先进后出的数据结构,赋予了其递归能力。

继续往下看,函数执行大致有以下步骤:

第一步:会形成一个供代码执行的环境,也是一个栈内存

这里,我们先思考几个问题:

  • 这个供代码执行的环境是什么?
  • 这个栈内存是怎么分配出来的?
  • 这个栈内存的内部是一种什么样的样子?

第二步:将存储的字符串复制一份到新开辟的栈内存中,使其变为真正的 JS 代码

这步很好理解,

第三步:先对形参进行赋值,再进行变量提升,比如将 var function 变量提升。

第四步:在这个新开辟的作用域中自上而下执行

思考题:为什么是自上而下执行呢?

将执行结果返回给当前调用的函数

思考题:将执行结果返回给当前调用的函数,其背后是如何实现的呢?

谈谈底层实现

这里为什么要谈谈底层实现呢,因为有还有一些知识点我没有提到,比如前面的一些思考,这里我想统一提一下。

计算机中最本质的闭包解释

函数在执行的时候,都会形成一个全新的私有作用域,也叫私有栈内存。

目的有如下几点:

第一点:把原有堆内存中存储的字符串变成真正的 JS 代码

第二点: 保护该栈内存的私有变量不受外界的干扰

函数执行的这种保护机制,在计算机中称之为 闭包 。

可能有人不明白,咋就私有了呢?

没问题,我们可以反推。假设不是私有栈内存的,那么当你执行一个递归时,基本就完了,因为一个函数上下文堆栈中,有很多相同的 JS 代码,比如局部变量等,如果不私有化,那岂不乱套了,所以假设矛盾,私有栈内存成立。

栈内存是怎么分配出来?

首先,你要明白 JS 的栈内存是系统自动分配的,大小固定。想一想,如果自动适应的话,那就基本不存在除死循环这种情况之外的的栈溢出了。

这个栈内存的内部是一种什么样的样子?

这个确实挺让人好奇的,为什么呢?我举个例子,你天天写 return 语句,那你知道 return 的底层实现吗?你天天都在写子程序,那你知道子程序的底层的一些真相吗?

我们来看一张图:

上图显示了一次函数调用的栈结构,从结构中我们可以看到,内部有哪些东西,比如实参,局部变量,返回地址。

看下面代码:

function f1() {
  return 'hello godkun'    
}
let result = f1()
f2(result)

上面这行代码的底层含义就是,f() 函数在私有栈内存中执行完后,使用 return 后,将 return 后的执行结果传递给 EAX (累加寄存器),常用于函数返回值。对寄存器不了解的可以自行搜索学习一下,这里就不再说了。这里我说一下 Return AddrAddr 主要目的是让子程序能够多次被调用。

看下面代码:

function main() {
  say()
  // TODO:
  say()
}

上面代码,在 main 函数中进行了多次调用子程序 say ,在底层实现上面,是通过在栈结构中保存一个 Addr 用来保存函数的起始运行地址,当第一个 say 函数运行完以后,Addr 就会指向起始运行地址,以备后面多次调用子程序。

JS 引擎是如何执行函数

上面我从很多方面分析了函数执行的机制,可能有点难懂。现在我来简要分析一下,JS 引擎是如何执行函数的。

这里我就不造轮子了,有一篇博客写的非常好,我发自内心的认为我写不出来比这还好的博客了。就算写出来,我感觉也没必要了。但是这篇博客写的过于概括,很多细节没有提到,这里我要在此篇博客的基础上分析很多很重要的细节。

博客地址:

探索JS引擎工作原理

下面我开始分析,代码如下:

//定义一个全局变量 x
var x = 1 
function A(y) {
 //定义一个局部变量 x
  var x = 2
  function B(z) {
    //定义一个内部函数 B
    console.log(x + y + z)
  }
  //返回函数B的引用
  return B 
}
//执行A,返回B
var C = A(1)
//执行函数B
C(1)

PS: 建议大家先看一下博客,知道一些基本概念,然后再看我的分析。

下面开始分析:

执行 A 函数时

JS 引擎构造的 ESCstack 结构如下:

简称 A 图:

执行 B 函数时

JS 引擎构造的 ESCstack 结构如下:

简称 B 图:

下面开始最为关键的个人感悟 show time

局部变量是如何被保存起来的

核心看下面代码:

EC(B) = {
  [scope]:AO(A),
  var AO(B) = {
    z:1,
    arguments:[],
    this:window
  },
  scopeChain:<AO(B),B[[scope]]>  
}

这是在执行 B函数 时,创建的 B 函数的执行环境(一个对象结构)。里面有一个 AO(B) ,这是 B 函数的活动对象。

AO(B) 的目的是什么?其实 AO(B) 就是每个链表的节点其指向的内容。

同时,这里还定义了 [scope] 属性,我们可以理解为指针,[scope] 指向了 AO(A) ,而 AO(A) 就是函数 A 的活动对象。

函数活动对象保存着 局部变量、参数数组、this 属性。这也是为什么你可以在函数内部使用 thisarguments 的原因。

scopeChain 是作用域链,熟悉数据结构的同学肯定知道我想说什么了,其实函数作用域链本质就是链表,执行哪个函数,那链表就初始化为哪个函数的作用域,然后把当前指向的函数活动对象放到 scopeChain 链表的表头中。

比如执行 B 函数,那 B 的链表看起来就是 AO(B) --> AO(A)

但是别忘了,A 函数也是有自己的链表的,为 AO(A) --> VO(G)。所以整个链表就串起来来,B 的链表(作用域)就是:AO(B) --> AO(A) --> VO(G)

链表是一个闭环,因为查了一圈,回到自己的时候,如果还没找到,那就返回 undefined

思考题:大家可以思考一下 [scope] 和 [[scope]] 的命名方式,为什么是这种形式。

通过 A 函数的 ECS 我们能看到什么

我们能看到,JS 语言是静态作用域语言,在执行函数之前,整个程序的作用域链就一样确定好了,从 A 图中的函数 BB[[scope]] 就可以看到作用域链已经确定好了。不像 lisp 那种在运行时才能确定作用域。

执行环境,上下文环境是一种什么样的存在

执行环境的数据结构是栈结构,其实本质上是给一个数组增加一些属性和方法。

执行环境可以用 ECStack 去表示,可以理解成 ECSack = [] 这种形式。

栈(执行环境)专门用来存放各种数据,比如最经典的就是保存函数执行时的各种子数据结构。

比如 A 函数的执行环境是 EC(A)。当执行函数 A 的时候,相当于 ECStack.push[A] ,当属于 A 的那些东西被放入到栈中的时候,都会被包裹成一个私有栈内存。

私有栈是怎么形成的,这里就要牵扯到汇编语言了,从汇编语言角度去看,会发现一个栈的内存分配,栈结构的各种变换,都是有底层标准去控制的。

所以我们再联系一下,平常我们所说的上下文环境啊,context 等,其实和我上面解释的执行环境并没有什么区别,这样去理解,是不是发现对上下文环境之类的专有名词理解的更为深刻了呢。

因为再跳,本质还是一样的,计算机行业底层标准是确定的。

开启上帝模式看穿 this

this 为什么在运行时才能确定

我们看上面两张图中的红色箭头,箭头处的信息非常非常重要。

A 图,执行 A 函数时,只有 A 函数有 this 属性,执行 B 函数时,只有 B 函数有 this 属性,这也就证实了 this 只有在运行时才会存在。

this 的指向真相

我们看一下 this 的指向,A 函数调用的时候,属性 this 的属性是 window ,而 通过 var C = A(1) 调用 A 函数后,A 函数的执行环境已经 pop 出栈了。此时执行 C() 就是在执行 B 函数,EC(B) 已经在栈顶了,this 属性值是 window 全局变量。

通过 A 图 和 B 图的比较,直接展示 this 的本质。看清真相,this 也不过如此。

作用域的本质是链表中的一个节点。

听不懂没关系,听我娓娓道来。

通过 A 图 和 B 图的比较,直接秒杀 作用域 的所有用法

A 图,执行 A 函数时,B 函数的作用域是创建 A 函数的活动对象 AO(A) 。作用域就是一个属性,一个属于 A函数的执行环境中的属性,它的名字叫做 [scope]

[scope] 指向的是一个函数活动对象,其实这里最核心的一点,就是大家要把这个函数对象当成一个作用域,但最好理解成一个链表节点。

如果你能理解成链表节点的话,那你就不会再对为什么会有作用域链这个东西感到陌生,不会再对作用域和作用域链的区别而感到困惑。直接秒杀了作用域相关的所有问题。

PS: B 执行 B 函数时,只有 B 函数有 this 属性,这也就交叉证实了 this 只有在运行时才会存在。

作用域链的本质就是链表

首先通过比较 A 图和 B 图的 scopeChain ,我们可以确定的是:

作用域链本质就是链表,执行哪个函数,那链表就初始化为哪个函数的作用域,然后将该函数的 [scope] 放在表头,形成闭环链表,作用域链的查找,就是通过链表查找的,如果走了一圈还没找到,那就返回 undefined

作用域链也是很 easy 的。

用一道面试题让你更上一层楼(走火入魔)

我决定再举一个例子,这是一个经常被问的面试题,看下面代码:

第一个程序如下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = function() {
      return i
    }
  }
  return result
}

let r = kun()
r.forEach(fn => {
  console.log('fn',fn())
})

第二个程序如下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = (function(n) {
      return function() {
        return n
      }
    })(i)
  }
  return result
}

let r = kun()
r.forEach(fn => {
  console.log('fn', fn())
})

上面两个程序会输出什么结果?并分析一下其原理。

输出结果大家应该都知道了,结果分别是如下截图:

第一个程序,输出 1010

第二个程序,输出 09

那么问题来了,其内部的原理机制你知道吗?

  • 一部分 coder 只能答到立即调用,闭包。
  • 大多数 coder 可以答到作用域相关知识。
  • 极少部分 coder (大佬级别) 可以从核心底层原因来分析。

下面我来展示一下从核心底层原因来分析,是一种什么样的 style

分析输出10个10

代码如下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = function() {
      return i
    }
  }
  return result
}

let r = kun()
r.forEach(fn => {
  console.log('fn',fn())
})

如何去分析,首先我们要明白一点,只有函数在执行的时候,函数的执行环境才会生成。那依据这个规则,我们可以知道在完成 r = kun() 的时候,kun 函数只执行了一次,生成了对应的 AO(kun) 。我们可以看一下 AO(kun) 有什么,如下:

AO(kun):{
  i = 10;
  kun = function(){...};
  kun[[scope]] = this;
}

这时,在执行 kun() 之后,i 的值已经是 10 了。OK ,下面最关键的一点要来了,请注意,kun 函数只执行了一次,也就意味着:

kun 函数的 AO(kun) 中的 i 属性是 10

我们继续分析, kun 函数的作用域链如下:

AO(kun) --> VO(G)

而且 kun 函数已经从栈顶被删除了,之只留下了 AO(kun) ,注意一点:

这里的 AO(kun) 表示一个节点,这个节点有指针和数据,其中指针指向了 VO(G) ,数据就是 kun 函数的活动对象

所以下面问题来了,当去一次执行 result 中的数组的时候,会发生什么现象?注意一点:

result 数组中的每一个函数其作用域都已经确定了,上面也提到过,JS 是静态作用域语言,其在程序声明阶段,所有的作用域都将确定。

所以知道这点以后,那么 result 数组中每一个函数其作用域链都是如下:

AO(result[i]) --> AO(kun) --> VO(G)

因此 result 中的每一个函数执行时,其 i 的值都是沿着这条作用域链去查找的,而且由于 kun 函数只执行了一次,导致了 i 值是最后的结果,也就是 10 。所以输出结果就是 1010

总结一下,就是 result 数组中的 10 个函数在声明后,总共拥有了 10 个链表(作用域链),都是 AO(result[i]) --> AO(kun) --> VO(G) 这种形式,但是 10 个作用域链中的 AO(kun) 都是一样的。所以导致了,输出结果是 1010

通过上面的解释,其实已经从一个相当底层的视角去分析了,需要注意的关键点,我也都提了出来,大家再好好研究下吧。

下面我们来分析输出 09 的结果。

分析输出0到9

代码如下:

function kun() {
  var result = []
  for (var i = 0; i < 10; i++) {
    result[i] = (function(n) {
      return function() {
        return n
      }
    })(i)
  }
  return result
}

let r = kun()
r.forEach(fn => {
  console.log('fn', fn())
})

通过分析 输出结果为 1010 的情况,大家应该有所收获了,或者找到一些感觉了,那输出 09 结果的情况,该怎么去分析呢?且听我娓娓道来。

首先和上面不一样了,在声明函数 kun 的时候,就已经执行了 10 次匿名函数了。还记得只要执行函数,就会生成函数执行环境么。也就意味着,在 ECS 栈中,有一个 EC(kun) 执行环境,但是有10个匿名的 EC(匿名) 执行环境,分别对应的是 result 数组中的 10 个函数。

具体展示情况,我来用伪代码表达一下:

下面是执行 kun 函数的时候。

ECSack = [  EC(kun) = {    [scope]: VO(G)
    AO(匿名1) = {
      i: 0,
      result[0] = function() {...// return i},
      arguments:[],
      this: window
    },
    scopeChain:<AO(kun), kun[[scope]]>
  },
  // .....
  EC() = [    [scope]: AO(kun)
    AO(kun) = {
      i: 9,
      result[9] = function() {...// return i},
      arguments:[],
      this: window
    },
    scopeChain:<AO(kun), kun[[scope]]>
  ]
]

上面简单的用结构化的语言表示了 kun 函数在声明时的内部情况,首先有两点要注意。

第一点:每一个 EC(kun) 中的 AO(kun) 中的 i 属性值都是不一样的,比如通过上面结构化表示,可以看到:

  1. result[0] 函数的父执行环境是 EC(kun) ,这个 VO(kun) 里面的 i 值 是 0
  2. result[9] 函数的父执行环境是 EC(kun) ,这个 VO(kun) 里面的 i 值 是 9
记住 `AO(kun)` 是一段存储空间。-->

第二点:关于作用域链,也就是 scopeChainresult 中的函数的 链表形式仍然是下面这种形式

AO(result[i]) --> AO(kun) --> VO(G)

但不一样的是,对应节点的存储地址不一样了,相当于是 10 个新的 AO(kun) 。而每一个 AO(kun) 的节点内容中的 i 值是不一样的。

所以总结下就是:

执行 result 数组中的 10 个函数时,走了 10 个不同的链表,同时每个链表的 AO(kun) 节点是不一样的。每个 AO(kun) 节点中的 i 值也是不一样的。

所以输出的结果最后显示为 09

总结

是不是发现从底层去分析和理解的话,很多问题其实都有一个很合理,或者让阅读者可以接受的答案。

总结

敲山震虎篇的知识难度有点大,费了我不少脑子,通过对底层实现原理的分析,我们可以更加深刻的去理解函数的执行机制。

深刻的理解了函数的执行机制,我们才能更流畅的写出高质量的函数。

如何减少作用域链(链表)的查找

比如我们看很多库,想 JQ 等,都会在立即执行函数的最外面传一个 window 参数。这样做的目的是因为,window 是全局对象,通过传参,避免了查找整个作用域链。提高了函数的执行效率,见解了写出了高质量的函数。

如何防止栈溢出 我们知道,每一次执行函数,都会创建函数的执行环境,也就意味着占用一些栈内存,而栈内存大小是固定的,如果写了很大的递归函数,你们就会造成栈内存溢出,引发错误。

我觉得,我们要去努力的达成这样一个成就:

做到当我在手写一个函数时,我心中非常清楚的知道我正在写的每一行代码,其在内存中是怎么表现的,或者说其在底层是如何执行的,从而达到 眼中有码,心中无码 的境界。

如果能做到这样的话,那还怕写不出高质量的函数吗?

备注

  • 敲山震虎篇阅读难度有点大,多去分析分析就会明白了
  • 文章难免有错,还请多多包涵,欢迎在评论处指出错误,多多交流

参考

交流

后续会有其他两篇博客,分别是基础篇和高级篇,可以关注我的掘金博客或者 github 来获取后续的系列文章更新通知。

掘金系列技术文章汇总如下,觉得不错的话,点个 star 鼓励一下。

github.com/godkun/blog

我是源码终结者,欢迎技术交流。

也可以进 前端狂想录群 大家一起头脑风暴。有想加的,因为人满了,可以先加我好友,我来邀请你进群。

风之语

今天是一个开心的节日,既是吃汤圆猜灯谜的元宵节,也是程序员通宵的节日(猿宵节)。

虽然 20 号了,但啥也别说了,祝各位首富来年元宵节快乐(嘿嘿)。

最后:尊重原创,转载请注明出处哈😋