当前位置: 首页 > news >正文

Kotlin 高阶函数详解

高阶函数

在 Kotlin 中,函数是一等公民,高阶函数是 Kotlin 的一大难点,如果高阶函数不懂的话,那么要学习 Kotlin 中的协程、阅读 Kotlin 的源码是非常难的,因为源码中有太多高阶函数了。

高阶函数的定义

高阶函数的定义非常简单:一个函数如果参数类型是函数或者返回值类型是函数,那么这就是一个高阶函数

函数类型

kotlin 中,有整型 Int、字符串类型 String,同样函数也有类型,举个例子:

fun add(num1: Int, num2: Int): Int {return num1 + num2
}

这个 add 函数的函数类型就是 (Int, Int) -> Int函数类型其实就是将函数的 “参数类型” 和 “返回值类型” 抽象出来,既然 (Int, Int) -> Int 是函数类型,那么它就可以跟整型,字符串类型一样,将一个变量定义成函数类型,如下所示,变量 c 的类型就是函数类型,这时编译器没有报错,所以是可以将变量的类型设置为函数类型的。

那么怎么给 c 这个变量赋值呢?类比整型、字符串变量的赋值,要给一个函数类型的变量赋值,我们需要将一个具有相同函数类型的函数引用赋值给变量就可以了,具体写法如下所示:

val c: (Int, Int) -> Int = ::addfun add(num1: Int, num2: Int): Int = num1 + num2

::add 这种写法是一种函数引用方式的写法。

除了函数引用这种方式外,Kotlin 还支持用 Lambda 表达式对一个函数类型的变量进行赋值。如下所示:

val c: (Int, Int) -> Int = {num1: Int, num2: Int -> num1 + num2}

实际项目中,绝大多数情况下我们都是用 Lambda 表达式来调用高阶函数的。

Lambda 表达式语法结构:{参数名1: 参数类型, 参数名2: 参数类型 -> 函数体} 函数体中可以编写任意行代码,最后一行代码会自动作为 Lambda 表达式的返回值

了解了函数类型高阶函数的定义,我们很简单的就可以定义高阶函数了,如下所示:

// 参数是函数类型的高阶函数
fun higherFunction(func: (Int, Int) -> Int) {}
// 返回值是函数类型的高阶函数
fun higherFunction(): (Int, Int) -> Int {}

高阶函数的调用

我们以 Kotlin 中数组的遍历为例子来讲高阶函数的调用。

首先我们定义一个 Int 类型的数组,如下所示:

val intArray = intArrayOf(1, 2, 3, 4, 5)

我们不用 for in 的方式来遍历,而是用 forEach 方法来遍历,forEach 函数就是一个高阶函数,源码如下所示:

public inline fun IntArray.forEach(action: (Int) -> Unit): Unit {for (element in this) action(element)

首先高阶函数肯定是一个函数,那么方法的调用如下这样写肯定是没有问题的:

intArray.forEach(?)

只是这个  是个函数类型的参数,函数类型是 (Int) -> Unit,那么我就定义一个相同的函数类型的变量传给 forEach 不就好了嘛,如下所示:

val action: (Int) -> Unit = ??fun main() {intArray.forEach(action)
}

通过上述的学习,我们知道这里的 ?? 可以是函数引用或者是 Lambda 表达式,如果我们用函数引用那代码就是这样的:

val action: (Int) -> Unit = ::printValuefun main() {intArray.forEach(action)
}fun printValue(value: Int): Unit {println(value)
}

前面我们已经讲过,实际项目中,绝大多数情况下我们都是用 Lambda 表达式来调用高阶函数的,因为函数引用比较麻烦,为了调用高阶函数,我们还得特意写一个函数。并且 Lambda 表达式还有很多简便的写法。

我们利用 Lambda 表达式来改写上述代码,如下所示:

val action: (Int) -> Unit = {value: Int -> println(value)}fun main() {intArray.forEach(action)
}

Lambda 表达式有很多简便的写法,现在我们就对 {value: Int -> println(value)} 进行简化:

  1. Kotlin 有类型推到机制,所以 Int 可以去掉
val action: (Int) -> Unit = {value -> println(value)}
  1. Lambda 表达式如果只有一个参数,可以直接用 it 来代替,并且不需要声明参数名
val action: (Int) -> Unit = {println(it)}

将简化后的代码代入,现在上述的代码就变成如下这样:

fun main() {intArray.forEach({println(it)})
}

这个代码还可以进行简化:

  1. 当 Lambda 参数是函数的最后一个参数时,可以将 Lambda 表达式移到函数括号的外面
fun main() {intArray.forEach(){println(it)}
}
  1. 如果 Lambda 表达式是函数的唯一一个参数的话,还可以将函数的括号省略
fun main() {intArray.forEach{println(it)}
}

到此为止就无法继续简化了,这就是最终版本,相比较于最开始的样子,这个代码已经非常简洁了。

带有接收者的函数类型

前面我们举了 forEach 高阶函数,我们再来看一个高阶函数 apply,看看这两者有什么区别,apply 函数源码如下:

public inline fun <T> T.apply(block: T.() -> Unit): T {block()return this
}

apply 函数接收的函数类型是 T.() -> Unit,相比较于前面我们所见的函数类型,多了一个 T.,那么这个 T. 有什么作用呢?

再说作用之前,我们再来看一个高阶函数 also,这几个高阶函数都是定义在 Kotlin 标准库中的,目的是在对象上下文内执行代码块,also 函数的源码如下所示:

public inline fun <T> T.also(block: (T) -> Unit): T {block(this)return this
}

also 函数接收的函数类型是 (T) -> Unit

我们来看一下这两个函数实际运用中有哪些不同,如下所示:

假设这里我们把泛型 T 当中 User,User.() -> Unit 表示这个函数类型是定义在 User 类当中的,那么这里将函数类型定义到 User 类当中有什么好处呢?好处就是当我们调用 apply 函数时传入的 Lambda 表达式将会自动拥有 User 的上下文,以便访问接收者对象的成员而无需任何额外的限定符。

这个说起来确实有点抽象,但是结合上面的图片我觉得还是比较容易懂的。

到这里为止,高阶函数的理论知识我们已经算是讲完了。

高阶函数的应用

案例一:统计文件中各个字符(不包括空白字符)的个数

fun main() {File("build.gradle").readText() // 读文件,直接以 String 的格式返回.toCharArray()  // 将字符串转换成字符数组.filter { !it.isWhitespace() }  // 过滤空白字符.groupBy { it } // 按照集合中每个字符分组.map {it.key to it.value.size } // 映射,重新生成新的集合.let {println(it)}
}

运行结果如下所示:

这个案例中我们用到了 filter、groupBy、map 和 let 这几个高阶函数。如果对这个写法不是很懂的话,可以将每一步的结果打印出来看一下。

inline 优化

在讲什么是 inline 优化之前我们先来看一下高阶函数的实现原理。我们知道 Kotlin 和 Java 是完全兼容的,最后都会被编译成 .class 文件,但是 Java 里面没有高阶函数的概念,那么 Kotlin 高阶函数如果被反编译成 Java 代码会是什么样子的呢?

例:我们来看下面这个高阶函数 foo():

fun main() {var i = 0foo {i++println(i)}
}fun foo(block: () -> Unit) {block()
}

反编译之后的 Java 代码:

// 主要代码,省略了一些没用的代码
public final class HigherFunctionKt {public static final void main() {foo((Function0)(new Function0() {public Object invoke() {this.invoke();return Unit.INSTANCE;}public final void invoke() {int var10001 = i.element++;int var1 = i.element;System.out.println(var1);}}));}public static final void foo(@NotNull Function0 block) {Intrinsics.checkNotNullParameter(block, "block");block.invoke();}
}

这里的 Function0 是一个接口,可以看到高阶函数 foo 的函数类型参数,变成了 Function0,而 main() 函数当中的高阶函数调用,也变成了“匿名内部类”的调用方式。所以高阶函数最终还是以匿名内部类的形式在运行,难道 Kotlin 高阶函数只是为了简化“匿名内部类”的写法吗?

当然不是,Kotlin 高阶函数的性能是远远高于匿名内部类,某些极端情况下,甚至有几百倍的性能提升。当然我们上面的实现是无法提高性能的,不过写法也很简单,只需要在函数的前面加上一个 inline 关键字就可以了。

我们来测试一下,看看 inline 关键字是不是真的能提高高阶函数的性能,这里我们利用 JMH 来进行测试,代码如下:

@BenchmarkMode(Mode.Throughput) // 基准测试的模式,采用整体吞吐量的模式
@Warmup(iterations = 3) // 预热次数
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 测试参数,iterations = 10 表示进行10轮测试
@Threads(8) // 每个进程中的测试线程数
@Fork(2)  // 进行 fork 的次数,表示 JMH 会 fork 出两个进程来进行测试
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基准测试结果的时间类型
open class SequenceBenchmark {// 不用inline的高阶函数fun foo(block: () -> Unit) {block()}// 使用inline的高阶函数inline fun fooInline(block: () -> Unit) {block()}// 测试无inline的代码@Benchmarkfun testNonInlined() {var i = 0foo {i++}}// 测试inline的代码@Benchmarkfun testInlined() {var i = 0fooInline {i++}}
}fun main() {val options = OptionsBuilder().include(SequenceBenchmark::class.java.simpleName).output("benchmark_sequence.log").build()Runner(options).run()
}

测试结果如下,分数越高性能越好:

从上面的测试结果我们能看出来,是否使用 inline,它们之间的效率几乎相差 30 倍。而这还仅仅只是最简单的情况,如果在一些复杂的代码场景下,多个高阶函数嵌套执行,它们之间的执行效率会相差上百倍。

如果我们将函数嵌套十层,再来测试,会发现性能差距更大,代码如下所示:

@BenchmarkMode(Mode.Throughput) // 基准测试的模式,采用整体吞吐量的模式
@Warmup(iterations = 3) // 预热次数
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS) // 测试参数,iterations = 10 表示进行10轮测试
@Threads(8) // 每个进程中的测试线程数
@Fork(2)  // 进行 fork 的次数,表示 JMH 会 fork 出两个进程来进行测试
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 基准测试结果的时间类型
open class SequenceBenchmark {// 不用inline的高阶函数fun foo(block: () -> Unit) {block()}// 使用inline的高阶函数inline fun fooInline(block: () -> Unit) {block()}@Benchmarkfun testNonInlined() {var i = 0 foo { foo { foo { foo { foo { foo { foo { foo { foo { foo { i++ } } } } } } } } } }}@Benchmarkfun testInlined() { var i = 0 fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline { fooInline {
}fun main() {val options = OptionsBuilder().include(SequenceBenchmark::class.java.simpleName).output("benchmark_sequence.log").build()Runner(options).run()
}

测试结果如下:

从上面的性能测试数据我们可以看到,在嵌套了 10 个层级以后,我们 testInlined 的性能几乎没有什么变化;而当 testNonInlined 嵌套了 10 层以后,性能也比 1 层嵌套差了 6 倍。并且此时,两个函数的性能差距将近 200 倍。

那么 inline 关键字是如何让高阶函数的性能提高这么多的呢?

inline 原理

其实内联函数的工作原理很简单,就是 Kotlin 编译器会将内联函数中的代码在编译的时候自动替换到调用它的地方,这样也就不存在运行时的开销了

以下面这段代码作为例子:

// 使用inline的高阶函数
inline fun fooInline(block: () -> Unit) {block()
}@Benchmark
fun testInlined() {var i = 0fooInline {fooInline {fooInline {fooInline {fooInline {fooInline {fooInline {fooInline {fooInline {fooInline {i++}}}}}}}}}}
}

根据内联函数的原理,上面的代码等价于下面这样:

// 使用inline的高阶函数
inline fun fooInline(block: () -> Unit) {block()
}@Benchmark
fun testInlined() {var i = 0fooInline { i++}
}

所以在嵌套了 10 个层级以后,testInlined 的性能几乎没有什么变化。把这段代码反编译成 Java 代码,也是如此:

@Benchmark
public final void testInlined() {int i = 0;int $i$f$fooInline = false;int var4 = false;int $i$f$fooInline = false;int var7 = false;int $i$f$fooInline = false;int var10 = false;int $i$f$fooInline = false;int var13 = false;int $i$f$fooInline = false;int var16 = false;int $i$f$fooInline = false;int var19 = false;int $i$f$fooInline = false;int var22 = false;int $i$f$fooInline = false;int var25 = false;int $i$f$fooInline = false;int var28 = false;int $i$f$fooInline = false;int var31 = false;int i = i + 1;
}

总结

如果一个函数的参数是函数类型或者返回值是函数类型,那么这个函数就是高阶函数。高阶函数可以简化我们的代码,并且利用 inline 关键字可以提高高阶函数的性能。

在 kotlin 源码的 Standard.kt 文件中定义了几个我们平时会经常用到的高阶函数,可以去看一看。

相关文章:

Kotlin 高阶函数详解

高阶函数 在 Kotlin 中&#xff0c;函数是一等公民&#xff0c;高阶函数是 Kotlin 的一大难点&#xff0c;如果高阶函数不懂的话&#xff0c;那么要学习 Kotlin 中的协程、阅读 Kotlin 的源码是非常难的&#xff0c;因为源码中有太多高阶函数了。 高阶函数的定义 高阶函数的…...

DL——week2

要学明白的知识点&#xff1a; np.dot()的作用 两个数组的点积&#xff0c;即对应元素相乘 numpy.dot(a,b,outNone) a: ndarray 数组 b: ndarray 数组 out: ndarray, 可选&#xff0c;用来保存dot&#xff08;&#xff09;的计算结果 numpy Ndarray对象 N维数组对象ndarray&am…...

如何撰写骨灰级博士论文?这是史上最全博士论文指导!

博士论文的写作是博士研究生主要要完成的工作。由于存在着较高的难度&#xff0c;较长的写作周期&#xff0c;以及在创新&#xff0c;写作规范&#xff0c;实际及理论意义等方面有着比较高的要求&#xff0c;博士论文的完成一般说来是有相当难度的。一篇好的博士论文不仅是一本…...

08.SpringBoot请求相应

文章目录 1 请求1.1 Postman1.2 简单参数1.2.1 原始方式1.2.2 SpringBoot方式1.2.3 参数名不一致 1.3 实体参数1.3.1 简单实体对象1.3.2 复杂实体对象 1.4 数组集合参数1.4.1 数组1.4.2 集合 1.5 日期参数1.6 JSON参数1.7 路径参数 2 响应2.1 ResponseBody注解2.2 统一响应结果…...

C#详解-Contains、StartsWith、EndsWith、Indexof、lastdexof

目录 简介: 过程: 举例1.1 举例1.2 ​ 总结: 简介: 在C#中Contains、StarsWith和EndWith、IndexOf都是字符串函数。 1.Contains函数用于判断一个字符串是否包含指定的子字符串&#xff0c;返回一个布尔值&#xff08;True或False&#xff09;。 2.StartsWith函数用于判断一…...

FATE框架中pipline基础教程

目录 1. 用pipline上传数据2. 用 Pipeline 进行 Hetero SecureBoost 的训练和预测3. 用 Pipeline 构建神经网络模型3.1 Homo-NN Quick Start: A Binary Classification Task3.2 Hetero-NN Quick Start: A Binary Classification Task 4. 自定义数据集示例&#xff1a;实现一个简…...

Atlas 元数据管理

Atlas 元数据管理 1.Atlas入门 1.1概述 元数据原理和治理功能&#xff0c;用以构建数据资产的目录。对这个资产进行分类和管理&#xff0c;形成数据字典。 提供围绕数据资产的协作功能。 表和表之间的血缘依赖 字段和字段之间的血缘依赖 1.2架构图 导入和导出&#xff1…...

编程题练习@8-23

分享8月23日两道编程题&#xff1a; 1 开幕式排列 题目描述 导演在组织进行大运会开幕式的排练&#xff0c;其中一个环节是需要参演人员围成一个环形。 演出人员站成了一圈&#xff0c;出于美观度的考虑&#xff0c;导演不希望某一个演员身边的其他人比他低太多或者高太多。 现…...

static相关知识点详解

文章目录 一. 修饰成员变量二. 修饰成员方法三. 修饰代码块四. 修饰类 一. 修饰成员变量 static 修饰的成员变量&#xff0c;称为静态成员变量&#xff0c;该变量不属于某个具体的对象&#xff0c;是所有对象所共享的。 public class Student {private String name;private sta…...

Redisson 分布式锁

Redis是基础客户端库&#xff0c;可用于执行基本操作。 Redisson是基于Redis的Java客户端&#xff0c;提供高级功能如分布式锁、分布式集合和分布式对象。 Redisson提供更友好的API&#xff0c;支持异步和响应式编程&#xff0c;提供内置线程安全和失败重试机制。 实现步骤…...

继承(C++)

继承 一、初识继承概念“登场”语法格式 继承方式九种继承方式组合小结&#xff08;对九种组合解释&#xff09; 二、继承的特性赋值转换 一一 切片 / 切割作用域 一一 隐藏 / 重定义 三、派生类的默认成员函数派生类的默认成员函数1. 构造函数2. 拷贝构造3. 赋值运算符重载4. …...

文心一言 VS 讯飞星火 VS chatgpt (80)-- 算法导论7.4 5题

五、如果用go语言&#xff0c;当输入数据已经“几乎有序”时&#xff0c;插入排序速度很快。在实际应用中&#xff0c;我们可以利用这一特点来提高快速排序的速度。当对一个长度小于 k 的子数组调用快速排序时&#xff0c;让它不做任何排序就返回。当上层的快速排序调用返回后&…...

SpringCloud 概述

文章目录 SpringCloud 概述一、微服务中的相关概念1、服务注册与发现2、负载均衡3、熔断4、链路追踪5、API网关 二、SpringCloud的介绍三、SpringCloud的架构1、SpringCloud中的核心组件&#xff08;1&#xff09;Spring Cloud Netflix组件&#xff08;2&#xff09;Spring Clo…...

Apache ShenYu 学习笔记一

1、简介 这是一个异步的&#xff0c;高性能的&#xff0c;跨语言的&#xff0c;响应式的 API 网关。 官网文档&#xff1a;Apache ShenYu 介绍 | Apache ShenYu仓库地址&#xff1a;GitHub - apache/shenyu: Apache ShenYu is a Java native API Gateway for service proxy, pr…...

uniapp 禁止遮罩层下的页面滚动

使用 touchmove.stop.prevent"toMoveHandle" 事件修饰符 若需要禁止蒙版下的页面滚动&#xff0c;可使用 touchmove.stop.prevent"moveHandle"&#xff0c;moveHandle 可以用来处理 touchmove 的事件&#xff0c;也可以是一个空函数。将这个方法直接丢到弹…...

postgresql 分组

postgresql 数据汇总 分组汇总聚合函数注意 总结 分组统计总结 高级分组总结 分组汇总 聚合函数 聚合函数&#xff08;aggregate function&#xff09;针对一组数据行进行运算&#xff0c;并且返回单个结果。PostgreSQL 支持以下常见的聚合函数&#xff1a; • AVG - 计算一…...

RT1052的EPWM

文章目录 1 EPWM介绍1.1 引脚1.2 时钟1.3 比较寄存器 2 函数 1 EPWM介绍 RT1052 具有 4 个 eFlexPWM(eFlexWM1~eFlex_PWM4)。 每个 eFlexPWM 可以产生四路互补 PWM即产生 8 个 PWM&#xff0c;也可以产生相互独立的 PWM 波。四路分别是模块0-3每个 eFlexPWM 具有各自的故障检…...

k8s 安装istio (一)

前置条件 已经完成 K8S安装过程十&#xff1a;Kubernetes CNI插件与CoreDNS服务部署 部署 istio 服务网格与 Ingress 服务用到了 helm 与 kubectl 这两个命令行工具&#xff0c;这个命令行工具依赖 ~/.kube/config 这个配置文件&#xff0c;目前只在 kubernetes master 节点中…...

vue 项目在编译时,总是出现系统崩的状态,报错信息中有v7 或者 v8 的样式-项目太大内存溢出

vue 项目在编译时&#xff0c;总是出现系统崩的状态&#xff0c;node 命令框也会报错&#xff0c;如下图&#xff1a;有v7 或者 v8 的样式。 原因分析&#xff1a; 分析&#xff1a;遇到与上面图片相似的问题&#xff0c;我们要首先要想到是否是 有关内存的问题&#xff0c;当然…...

低功耗蓝牙射频指纹识别

射频指纹 射频指纹是什么 射频指纹是一种利用无线电信号的特征来识别设备或用户的技术。射频指纹可以用来做设备身份认证、位置跟踪、安全防护等应用。射频指纹的优点是难以伪造、不依赖于额外的硬件或软件、适用于多种无线通信协议。 射频指纹识别流程 射频指纹识别的一般…...

(十)学生端搭建

本次旨在将之前的已完成的部分功能进行拼装到学生端&#xff0c;同时完善学生端的构建。本次工作主要包括&#xff1a; 1.学生端整体界面布局 2.模拟考场与部分个人画像流程的串联 3.整体学生端逻辑 一、学生端 在主界面可以选择自己的用户角色 选择学生则进入学生登录界面…...

AtCoder 第409​场初级竞赛 A~E题解

A Conflict 【题目链接】 原题链接&#xff1a;A - Conflict 【考点】 枚举 【题目大意】 找到是否有两人都想要的物品。 【解析】 遍历两端字符串&#xff0c;只有在同时为 o 时输出 Yes 并结束程序&#xff0c;否则输出 No。 【难度】 GESP三级 【代码参考】 #i…...

ESP32读取DHT11温湿度数据

芯片&#xff1a;ESP32 环境&#xff1a;Arduino 一、安装DHT11传感器库 红框的库&#xff0c;别安装错了 二、代码 注意&#xff0c;DATA口要连接在D15上 #include "DHT.h" // 包含DHT库#define DHTPIN 15 // 定义DHT11数据引脚连接到ESP32的GPIO15 #define D…...

多模态商品数据接口:融合图像、语音与文字的下一代商品详情体验

一、多模态商品数据接口的技术架构 &#xff08;一&#xff09;多模态数据融合引擎 跨模态语义对齐 通过Transformer架构实现图像、语音、文字的语义关联。例如&#xff0c;当用户上传一张“蓝色连衣裙”的图片时&#xff0c;接口可自动提取图像中的颜色&#xff08;RGB值&…...

JUC笔记(上)-复习 涉及死锁 volatile synchronized CAS 原子操作

一、上下文切换 即使单核CPU也可以进行多线程执行代码&#xff0c;CPU会给每个线程分配CPU时间片来实现这个机制。时间片非常短&#xff0c;所以CPU会不断地切换线程执行&#xff0c;从而让我们感觉多个线程是同时执行的。时间片一般是十几毫秒(ms)。通过时间片分配算法执行。…...

智能分布式爬虫的数据处理流水线优化:基于深度强化学习的数据质量控制

在数字化浪潮席卷全球的今天&#xff0c;数据已成为企业和研究机构的核心资产。智能分布式爬虫作为高效的数据采集工具&#xff0c;在大规模数据获取中发挥着关键作用。然而&#xff0c;传统的数据处理流水线在面对复杂多变的网络环境和海量异构数据时&#xff0c;常出现数据质…...

重启Eureka集群中的节点,对已经注册的服务有什么影响

先看答案&#xff0c;如果正确地操作&#xff0c;重启Eureka集群中的节点&#xff0c;对已经注册的服务影响非常小&#xff0c;甚至可以做到无感知。 但如果操作不当&#xff0c;可能会引发短暂的服务发现问题。 下面我们从Eureka的核心工作原理来详细分析这个问题。 Eureka的…...

Netty从入门到进阶(二)

二、Netty入门 1. 概述 1.1 Netty是什么 Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients. Netty是一个异步的、基于事件驱动的网络应用框架&#xff0c;用于…...

Java数值运算常见陷阱与规避方法

整数除法中的舍入问题 问题现象 当开发者预期进行浮点除法却误用整数除法时,会出现小数部分被截断的情况。典型错误模式如下: void process(int value) {double half = value / 2; // 整数除法导致截断// 使用half变量 }此时...

【SpringBoot自动化部署】

SpringBoot自动化部署方法 使用Jenkins进行持续集成与部署 Jenkins是最常用的自动化部署工具之一&#xff0c;能够实现代码拉取、构建、测试和部署的全流程自动化。 配置Jenkins任务时&#xff0c;需要添加Git仓库地址和凭证&#xff0c;设置构建触发器&#xff08;如GitHub…...