反射和元编程

文摘   2024-11-08 08:15   上海  

Mat[1]Jon[2]Jaana[3] 讨论了反射和元编程。其他语言如何使用反射?这与 Go 的方法有何不同?哪些库很好地使用了反射?哪些情况不适合使用反射?有哪些替代方法?我在 Go 代码中一直看到的那些奇怪的结构标记是什么?

本篇内容是根据2020年5月份#133 Reflection and meta programming[4]音频录制内容的整理与翻译

过程中为符合中文惯用表达有适当删改, 版权归原作者所有.



Mat Ryer: 大家好,欢迎来到 Go Time。今天我们将讨论反射(reflection),以及它在 Go 中的含义。我们会讨论 reflect 包,它能做什么,以及一些非常有趣的使用案例,甚至包括标准库中的一些例子。最后,我们还会对它发表一些主观看法,毫无疑问。

今天加入我们的是 Jaana B. Dogan。你好,Jaana。

Jaana Dogan: 你好!

Mat Ryer: 欢迎回来。你最近怎么样?

Jaana Dogan: 很好!你呢?

Mat Ryer: 嗯,还不错,谢谢。Jaana,如果你不介意我说的话,你听起来好像不太兴奋……不过别担心,这会让你开心起来的。Jon Calhoun 也在这里。你好,Jon!

Jon Calhoun: 嗨,Mat。不,她看起来是在思考什么。其他人看不到我们的视频,但她看起来是在深思。

Mat Ryer: 对啊,她在“反思”。

Jon Calhoun: 是的,她在“反思”。

Jaana Dogan: 我会告诉你们我在做什么……确实如此。

Mat Ryer: 好吧,那我们从头开始吧。为了那些不熟悉的人,什么是反射?reflect 包能为我们提供什么功能?

Jon Calhoun: 从高层次来看,它有点像元编程(meta-programming),或者说是运行时与代码交互。我是这么理解的,虽然我不知道官方定义是什么,但我见过的所有使用它的例子都是:当你的代码正在运行时,你想要检查其他代码片段,或者查看其他内容,找到一些关于它们的信息,或者尝试修改它们的不同方面……所以这涉及的内容不是在开发时由开发者完成的,而是在程序运行时进行的。

Mat Ryer: 是的,动态语言经常这样做,对吧?比如 Ruby,还有 JavaScript。我想在 JavaScript 中,你可以在运行时将方法添加到字符串中,几乎可以做任何你想做的事情。它是一种非常灵活的语言。而 Go 是一种强类型语言,它故意不允许这样做,但 reflect 包是一个例外。

Jon Calhoun: 正如你所说的,动态语言中几乎不会将这种行为视为某种特别的东西……它就是语言的一部分。这是人们自然会做的事情。如果你曾经使用过 Ruby 或类似的语言,它显得非常自然,因为你看到大家都在这样做。在任何代码库中,这都不会显得特别突出。但是在 Go 语言中,不仅你需要显式导入 reflect 包,功能也非常有限。我认为这是有意为之的,并且与 Go 想要实现的目标一致。

Jaana Dogan: 我来自强类型背景,我本来想说反射是类型系统无法作为一等公民提供的所有功能……但后来我看了一下维基百科页面,这也是为什么我对定义感到困惑。我刚才在思考,而 Mat 以为我很难过……它说“反射是进程检查、内省和修改自身结构和行为的能力”,所以它基本上涵盖了一切。如果你从日常语言中的“反射”这个词来理解的话,这其实是有道理的。

Mat Ryer: 是的,是的。

Jaana Dogan: 所以我认为它不仅仅局限于那小小的一部分……我在我的思维模型中试图过度限定它,但它其实更广泛。它涵盖了所有关于内省和修改结构与行为的内容。

Mat Ryer: 对。其实在 Go 中进行类型断言(type assertion)时,从某种程度上来说,这也是一种反射,对吗?在运行时你会说“这是某种类型,但我们不知道具体是什么类型,所以我要断言它是某个特定类型,如果断言成功,我就可以执行某些操作。”从某种程度上来说,这也算是反射,对吧?但这还是发生在编译时的,对吧?

Jon Calhoun: 是的,编译时你确实可以加入更多检查……但实际的检查,我猜必须在运行时进行,因为在编译时你并不知道。

Mat Ryer: 嗯,你说得对。

Jaana Dogan: 是的,断言是在运行时发生的,所以你可以说它是一种内省操作,实际上它就是反射。但类型系统为我们提供了一个非常好的功能,让我们能够以一种更优雅的方式实现它,而不是依赖于 reflect 包之类的东西。所以你可以说“是的,这是一个反射功能”,但是它通过语言中的语法糖表现出来。

Mat Ryer: 对,确实如此。它也提供了一些检查机制,比如你不能进行无效的类型断言,编译器在某些时候会帮你做一些检查。但你说得对,这确实是在运行时完成的,这也正是它的目的。

Jon Calhoun: 是的,从这个角度来看确实很有趣。类型断言可能是大家最常见的反射使用方式;我想第二种最常见的应该是结构体标签(struct tags)。虽然大家可能并不会直接使用它们,但我认为大多数 Go 开发者至少见过结构体标签,并且可能会想“这是什么东西?” 所以我觉得这是另一个可以深入讨论的点,因为我认为这是反射在 Go 中的第二大使用场景。

Mat Ryer: 没错。对于那些不熟悉的人来说---尤其是当你处理 JSON 数据时,你会看到这个现象……你可以在结构体字段名后面加一个字符串,这个字符串可以在运行时解析,当然可以从中提取元数据。以 JSON 为例,它允许你指定字段名,这样你可以使用与结构体字段不同的字段名。你还可以选择不包含某个字段。还有一种特殊的语法,是一个字符串加逗号,这其实是 Go 语言中一个比较奇怪的部分,确实比较独特。你还可以告诉它如果字段为空,则省略该字段。如果是默认值,它就不会包含在 JSON 对象中。

我记得我第一次看到这个时……当时感觉这可能是个临时的功能,但事实证明它非常有用,尤其是在这种情况下非常有效。不过 Jon,你写过一个使用结构体标签的项目,对吧?就是那个表单项目。

Jon Calhoun: 是的。

Mat Ryer: 那是什么?

Jon Calhoun: 我做过一些不同的项目……历史上我在很多项目中都使用过反射。我来自 Rails 背景,而---Rails 本质上就是一个大量使用反射的框架。我对那个框架的整体看法就是如此。所以在 Go 中我没有做得那么复杂,因为我觉得在 Go 中那样做并不合适。但我当时想写一些代码,基本上我想要将一个结构体生成一个 HTML 表单,并且当用户提交该表单时,我希望能够解析表单内容,并将用户提交的所有值重新放入该结构体中。这样可以简化我的工作,我可以在多个处理器之间共享这个表单,并简化处理流程。

所以我创建了一个使用结构体标签的 Form 包……当然还有其他方法可以处理这个问题,我们应该讨论一下……但当时我只是想看看能不能通过这种方式处理问题。结构体标签用于一些地方,比如你需要更改字段名。如果你的结构体字段名是“Email”,但你希望它在表单中显示为“e_mail”,你可以使用结构体标签来更改这些信息。这就是我当时使用它的地方。但这个项目也是一个有趣的实践,因为它展示了在 Go 中编写和使用反射是多么令人困惑。

我认为很多人都会在这方面遇到困难。某种程度上我觉得这是有意为之的;他们并不是想让它变得更糟,而是不想让它变得那么容易,以至于人们在不必要的情况下就使用反射。

Mat Ryer: 是啊……因为类型安全性带来了很多好处,这样做是有道理的,不是吗?

Jon Calhoun: 是的。当时我做这个项目时,可能在使用结构体标签这方面有点过头了。比如,对于输入字段的帮助文本或默认值等,我实际上让你可以通过结构体标签来提供这些值……结果是,你可能会有一个非常长的结构体标签,附加在某个字段上……看起来有点怪,因为这不是真正的代码,而是元数据。然而,它提供的功能远远超过你初看时的感觉。

Jaana Dogan: Mat,你刚才说的很有趣---你第一次看到它时,觉得它几乎像是一个临时的解决方案。我当时也是这么觉得,因为我也有这些顾虑……比如,Go 是一门非常强类型、简单的语言,但有时我会觉得自己过度使用了结构体标签……我当时还期待一种类似注解的东西;在其他语言中,我们有注解,注解可以是有类型的,它们可以处理更复杂的情况,而不会牺牲太多类型安全。我本以为 Go 会有类似的东西,这是很久以前---语言诞生之初的想法……但他们希望保持语言的简洁,因此没有引入注解。我意识到的是,我并没有看到太多由于结构体标签引发的混乱。

我觉得大家通常只在非常特定的情况下使用结构体标签,比如定义 JSON 键名之类的。那么你怎么看?你觉得目前的情况足够了吗?我们其实不需要注解,还是说这是一个错失的机会?因为结构体标签难以维护,我们在这方面没有做得很好,或者我们错失了一些通过更丰富的方式来注解字段的机会?

Mat Ryer:  是的,这是一个非常有趣的问题,因为在结构体中为特定用途添加一些额外的元数据确实有其价值。另一种选择是直接用强类型来描述同样的东西。Jon,举个例子,你可能会有一个地址结构体,里面有不同的字段,然后你通过结构体标签给它们添加标签、占位符和帮助文本等信息。你可能会有一个表单类型和字段类型,这样写起来虽然很冗长,但非常清晰,这是它的优点。不过我听说---其实我不太确定---结构体标签的解析速度很慢。这还是一个问题吗?有没有对它进行过优化,还是说它其实已经很快了?

Jon Calhoun: 我不确定,但我从来没有在一个项目中遇到这种速度问题。如果我正在渲染 HTML 并将其发送给用户,那么发送 HTML 所花费的时间几乎肯定会远远超过解析结构体标签的时间,所以这并不是一个主要的担忧。

我还想说,你刚才提到的表单类型---我需要把它整理成一个代码片段并分享一下,也许我会把它放到节目的备注里。其实我有两个版本的实现。一个是用表单包实现的,它接收一个结构体,并生成一些 HTML,如果你提供一个 HTML 模板的话……而另一个版本则是你描述一个表单类型。我有另一个结构体,比如说“这是我的注册表单结构体”,但我会为它写一个方法,使用我通用的表单类型,然后生成它应该是什么样子……我知道如何使用模板来渲染它的 HTML。所以在这个版本中,我完全没有使用反射……你说得对,这个版本确实更冗长,但在某些方面它肯定更好,因为它更清晰地表明了发生了什么。

不过有时候这也取决于项目的类型……因为对于一些快速的项目,你只是想快速生成一个表单,这时候有一个“这个包可以直接处理”的功能是很不错的。而在其他情况下,如果这是一个需要长期维护的项目,我们可能需要对内容进行更多的定制。这时,选择一个更容易修改、更冗长的方式可能更有意义,但最终你还是能得到相同的结果。

Mat Ryer: 是的,我记得 App Engine 旧的数据存储也使用过它们……通常是用于字段名称,但你也可以指定不希望在某个字段上建立索引,然后将其放入数据存储中。能够以这种方式注解结构体是非常强大的,这也很合理,因为你确实是在讨论该字段的属性,这是非常直接的。所以是的……还有你提到的类型化注解---我记得 C# 中有这个功能,我想 Java 也有。

这种想法是,你在代码中有实际的类型,你可以使用这些类型来注解字段。然后,我想你可以检查这些类型的存在,甚至可以对它们进行程序化处理。这是一种非常酷的元编程方式,同时你可能还能保持较高的类型安全性。

Jaana Dogan: 是的,维护性也更高。你还可以轻松运行查询。你可以让编辑器展示“显示所有使用了此注解的地方”。或者假设你想修改注解中的某个值,你可以轻松搜索到它,然后在所有相关地方进行重构。所以拥有一定的类型安全性可以让你做到这些……但正如我所说,我不认为我们在 Go 中过度使用结构体标签。也许是因为它们没有类型,所以大家都小心翼翼地不去滥用它们……我觉得我们目前保持了一个不错的平衡,它们被使用得很少。但最大的问题是,由于它们是非结构化的,并且需要解析,维护性和潜在的性能问题让人担忧。

Jon Calhoun:  我完全同意 Jaana 的看法,可能正是因为它们难以维护,人们才不常使用它们……而如果我们引入了类型化的注解,我几乎可以肯定人们会比现在更频繁地使用它们,甚至在一些不适合的场景中也使用它们……因为我见过类似的情况,特别是在使用结构体标签时。我认为有一类问题非常适合使用结构体标签。比如编码 JSON,或者几乎任何类似的编码过程……编码是一个很好的例子,因为你的结构体可能与实际需要编码的格式不完全匹配,所以你需要有一种方式来定义它应该如何编码和解码。而 ORM 也是类似的,当你构建一个 ORM 时,你可能只想快速地将数据插入 SQL 数据库中,并指定 SQL 数据库中的字段名称---这很合理。

但还有其他一些库,比如验证库,你会在结构体标签中添加类似“此字段为必填”的信息---我并不是说人们不应该使用这些库,但我确实认为这些库在长期使用中可能会带来问题。它们可能会导致代码中充满各种结构体标签,整个结构体类型变得难以理解,维护起来也很困难。而且没有编译器的安全保障。如果我们加入了类型化的注解,我想人们可能会更倾向于使用它们,而不会考虑其他方法。

Mat Ryer: 是的,我见过一些检查 JSON 标签的 linter。如果你漏掉了一个引号,或者标签的格式不正确,有些 linter 会提醒你“哦,这个标签格式不正确”。虽然不是编译器做的检查,因此没有同样的安全性,但我想这种不太吸引人的 API 可能也是它不被广泛使用的原因之一。此外,它确实有点神秘。对我来说,特别是我听到很多人说,吸引他们使用 Go 的一个原因是它没有太多“魔法”---它是一门非常清晰简单的语言。现在,我可能有点走向了另一个极端,几乎对任何神秘的东西都过敏,尽管有些人告诉我,我的外表看起来像个魔术师(笑)。

Jon Calhoun: 是的,神秘的部分确实让人头疼……我记得第一次使用结构体标签时,我在学习 Go。我当时在做与 MongoDB 相关的事情……你会用 Bison 来定义结构体标签……当时我在设置结构体标签,脑子里在想“我需要导入什么包才能让它工作吗?为什么我的代码没有导入任何东西却还能正常工作?”

Mat Ryer: 哦,是的。

Jon Calhoun: 当时这让我非常困惑,因为我心想“我不明白这段代码是怎么编译通过的。”直到后来我深入研究了一下,才明白过来,但当时真的觉得这像是某种魔法,刚开始学习时这让我有点沮丧……因为我不知道发生了什么。

Mat Ryer: 其实它就是一个字符串,对吧?

Jon Calhoun: 是的,但你会想“肯定是编译器在做什么事情吧?它肯定是在某个地方写了些什么东西”,所以你会想“这到底是怎么回事?”这让我困惑了好一阵子。直到后来我意识到“哦,他们只是解析这个字符串,Bison 包在使用时才会处理它”,这样就说得通了。但当时我真的很困惑。

Mat Ryer: 是啊。其实 reflect API 对结构体标签的解析还不错,API 很简单。因为 reflect 包中的一些功能---它实在是太“元”了。有些功能你可以理解,比如你可以获取某个值,而这个值是一个结构体。在 reflect 包中,它是一个强类型的值,这个值描述了这个值的类型。然后由于这些值可以是许多不同类型的东西,你会看到很多方法,其中大多数情况下这些方法是非法调用的。

比如你试图获取一个整数的长度,当然,reflect 包中有这些方法可以做到这一点。所以在编译时你可以调用它,但只有在运行时你才会发现你不能获取一个整数的长度。类似的例子还有很多,因此你会检查所有东西。当你写防御性代码时会非常冗长,以确保你不会遇到任何这些运行时的奇怪问题。当然,测试也能帮助你避免这些问题,但……

Jaana Dogan: 是的,你提到了测试,但这其实也很难测试。没有一组标准的测试用例。我曾在一些数据库包上工作过,由于 Go 没有泛型---也许我们可以在这个对话的背景下讨论一下这个问题---我们通常会大量依赖接口和类型转换。如果你有一个接口切片,它可以是一个值或一个指针,或者是指针的指针,然后你必须通过 reflect 包来处理所有这些魔法,而 reflect 包本身已经非常冗长了,所以包装和解包所有这些类型非常困难。我找不到一个简单的测试方法,因为没有一组标准的测试用例。比如,如果标准库提供了一些类似“嘿,请考虑测试这些”的东西,或者提供了一份测试的清单,那将会简单得多。

Mat Ryer: 是啊……因为你可能需要测试所有不同的类型之类的东西……当然,还有数组和切片。

Jaana Dogan: 没错。

Jon Calhoun: 很有趣的是……Mat,你提到的一个比较简单的用例是获取某个值……有趣的是,这是我第一次使用 reflect 库时遇到的问题之一。因为当有人传递了一个值,比如一个字符串,你会想“好吧,我要获取这个值。”这很合理,你的代码也能正常运行,一切看起来都没问题。是的,像长度这样的东西可能不适用,但大多数情况下会正常工作。但当有一天有人传递了一个 nil 指针,它有一个类型,但它是一个 nil 指针,你的代码突然就崩溃了,你会想“刚刚发生了什么?” 你就会遇到这些奇怪的情况,如果类型是指针,并且它是 nil,那么你需要使用 reflect.new 来实例化一个新元素。如果它是一个接口,你需要获取它所指向的底层元素类型,因为接口本身并没有多大帮助……

会有很多这些奇怪的情况,表面上看起来很简单,比如“我只是想获取这个值”,但实际上并不是那么简单。所以你最终会遇到很多边缘情况……即使你把它搞定了,并为这些情况写了测试,比如传递了一个空指针,它有一个类型。我们传递了一个空接口,我们传递了设置了实际值的接口---你为所有这些情况写了测试,但到最后你仍然在想“我是不是还遗漏了某些边缘情况”,因为几乎不可能没有遗漏。

Mat Ryer: 是的,从某种意义上说,这就像是在泄露 Go 内部的工作原理。如果你使用 reflect,你确实会学到很多关于类型系统的东西……但坦白说,我经常处于一种“试错”的状态,依赖 TDD(测试驱动开发)来告诉我是否做对了。如果我使用了 reflect 包,我通常会写一些代码,比如调用 Elem() 获取元素,然后出于某种原因(我不确定是什么),我必须再次调用 Elem()……我知道这里面一定有一个很好的解释,但我不知道是什么解释,我也没时间去深究,我只知道如果我调用 elem.Elem(),在这种情况下我就能得到我需要的东西,因为测试通过了……所以当涉及到反射代码时,我往往采取一种非常蛮力的方式,这种感觉不太好。

Jon Calhoun:  是的。我不常使用 TDD,但使用 reflect 时,这是我几乎一定会使用 TDD 的情况,因为我会想“这是我知道我要传入的所有不同类型,它们都需要正常工作”,从这开始会轻松得多。否则你会想“是的,这起作用了”,然后你运行它,结果什么都不工作,你会想“我不知道发生了什么。”

我刚刚看了一些我使用 reflect 写的代码,我看到同样的情况,就是 .type.Elem(),然后你创建了一个 reflect.New(),使用了那个类型,然后又调用了 .Elem(),你会想“看着这段代码,我完全不知道为什么我要这么做。我只知道它能工作”,这感觉真的很奇怪。

Jaana Dogan: 有一件事我意识到的是,我觉得 Go 当前的类型系统在一定程度上加剧了这些问题……因为我们不得不依赖接口作为参数,或者接口切片作为参数,然后就会出现大量的类型转换问题。我们无法限制用户想要做什么,或者用户想要传递什么……你必须处理所有这些情况,才能让你的库正常工作。

Go 中的一个典型例子是,我们有一些库会根据用户传入的参数进行类型转换,比如用户传入了某个接口类型的值……它可能是一个结构体,也可能是一个指向结构体的指针,或者是一个数组之类的东西,但它必须通过类型转换来了解类型,因此你不能传递一个普通的 nil,而是必须传递一个有类型的 nil。所以 Go 有一些奇怪的地方,加上没有泛型,这就导致所有这些复杂的情况需要由库通过 reflect 包来处理,我认为这加剧了我们所经历的所有这些 .Elem() 的问题,而我们并不完全理解它们的原因……整个语言在某种程度上加剧了这个问题。

Jon Calhoun: 你提到的这一点很好---如果你在使用 reflect,你几乎总是要接受空接口作为参数。这几乎总是你的参数类型,而这通常是编写代码时的一个不好的信号。

Mat Ryer: 是的,但就像你说的,在某些情况下这是无法避免的……

Jon Calhoun: 是的。

Mat Ryer: 我们很多人每天都在使用的一个东西就是 JSON 的编解码功能。你可以传递任何类型,因为它可以解码为你编写的结构体类型……或者更常见的是解码为一个 map[string]interface{}。它完全没问题。而且,reflect 包也可以实例化东西,对吧?如果你传递了一个 map,它会为你创建 map……类似的事情。所以它确实变得有点奇怪。我记得以前我想写一个用于 testify 的 mock 库,当时我真的很想在运行时从接口或另一个结构体创建一个 mock 结构体。当时你做不到,但从那以后我见过---我不确定现在是否可以,但我见过一些函数和方法,似乎现在你可以实例化结构体之类的东西;我得再确认一下……但这是非常强大的。如果你考虑到我们没有泛型,确实很有诱惑力去看看能不能通过 reflect 来完成这项艰难的工作,这样你就能得到一个非常智能的动态功能……这将非常有趣。

在测试代码中,你也许可以容忍它不是那么高效---它不会出现在一个紧密的循环中;当然,你不希望测试代码变得慢。但测试代码并不是总是处于低延迟的场景中,尽管我们仍然希望测试代码运行得相对较快……

Jon Calhoun: 是的,就像 Jaana 说的,Go 的类型系统有它的局限性,而你提到了 JSON 编码……我在想,即使在你知道必须传递指针的情况下,你也不能只传递结构体;你必须传递指向结构体的指针来获取返回值……如果有一个类型系统可以让你限制这种情况,那会更好,但由于现有的设置方式,它做不到。相反,你必须依赖错误处理之类的东西……这并不是说 Go 是个糟糕的语言,只是有时你会感到挣扎,特别是在看到这些限制时,我相信这对一些人来说是很困惑的。

Mat Ryer:  是的。如果 JSON 包不使用 reflect,它会是什么样的呢?你几乎肯定会有某种回调机制,但你仍然会有接口,因为你不知道值的类型。


Jon Calhoun: 是的,几乎必须是类似于“编码这个”的方式,然后不是说“传入一个接口”,而是必须说“它必须是一个指针”。必须是类似这样的东西。不过即使这样,也有点让人困惑,因为map并不总是这样工作的,如果我没记错的话。我记得你可以直接传入一个 map,而不一定非得是一个指针,但我不太记得了……它必须是一个指针吗?

Jaana Dogan: 是的……

Jon Calhoun: 我已经很久没有往那里面传入 map 了……我应该去检查一下。

Mat Ryer: 哦,不……那你都传入了什么?

Jon Calhoun: 结构体……

Mat Ryer: 哦,没错。那很合理。

Jon Calhoun: 我大多数时候都是解码到结构体中。

Mat Ryer: 嗯,如果你写的东西是你不知道数据结构的---你知道,很多 API 确实是这样做的。

Jon Calhoun: 是的。

Mat Ryer: 这确实是个危险的领域。但如果你完全不知道实际的类型……我写过一个小工具---它还没完成,虽然它大体上能工作,但绝对还没准备好……它基本上是一个假的 JSON 数据生成器。所以你可以传入任何数据---实际上,你可以传入一个结构体,它会生成很多该结构体的示例,它使用 JSON 来实现,因为至少在 API 里,JSON 的编组和解组是非常简单的事情。所以在那种情况下,是的。如果这是一个托管在网站上的 API,你会希望人们能够传入任何种类的 JSON,包括对象数组以及单个对象……然后它可以根据这些数据生成一些测试示例数据。这就是这个想法。这个工具非常“元”。

所以这种用例并不常见,我想……但我认为 JSON API 在某种程度上非常好用,特别是作为一个用户。如果没有 reflect,你最终会得到一个函数,它给出的键是字符串,值可能是一些字节,然后你必须根据你对具体情况的了解来解组这些字节。所以标准库为我们做这些事情确实很好。顺便说一句,如果没有这些功能,我认为这会损害 Go 的声誉。想象一下,如果有篇 Hacker News 的文章说你必须手动处理 JSON 的编组和解组……

Jaana Dogan: 是的,如果是那样,Go 可能不会被如此广泛地采用。

Mat Ryer: 没错,绝对是这样的。

Jon Calhoun: 即使按照现在的方式,JSON 仍然有一些难点……就像你说的,你有结构体,但---我觉得 Stripe 是个例子,支付来源可以是信用卡或银行账户,所以你会有一个可以不同的对象数组,你需要自己写一个类型来正确地解组它……所以你不得不为此写一些自定义的代码。我想如果你根本没有 JSON 包,那将是一场噩梦,充满了人们的抱怨和“这太糟糕了”的评论。即使在这种情况下,当你不得不写自定义代码,我仍然尽量利用 JSON 包的功能……比如,创建一个只包含我想要的字段的结构体,解组它,弄清楚它是什么,然后传入相应类型的结构体……这让我省去了自己处理“如何解组”的实际开销。

Jon Calhoun: 刚才,Mat,你提到了结构体标签,Jaana 和我在节目开始前讨论了一下。我觉得结构体标签如果能成为一个单独的库,可能会带来一些好处……因为这样你可以把它分离出来……我觉得结构体标签是反射中最安全的部分,你导入 reflect 包后,其他部分可能不一定更糟,但绝对有些吓人……所以有一个边界,你可以只处理结构体标签---把它分离出来,可能会有所帮助,比如“好吧,我这里只是在看结构体标签。”

Mat Ryer: 是的,我懂你的意思,这样你就不必把整个 reflect 包导入到代码中。而且我认为 reflect 包中也包含了 unsafe,尽管很多非常普通的包也确实有 unsafe……但我懂你的意思……你可以只导入一个解析结构体标签的包,比如 reflect/struct tags 之类的。我还挺喜欢这个想法的。你应该告诉别人这个想法。

Jaana Dogan: 我记得 Go 早期的时候,他们说“嘿,如果你导入了 reflect 包,那可不妙。” 那时候几乎认为这是不安全的,因为你也依赖于 unsafe 出于很多其他原因……但你知道,那是你不应该看到的导入行之一,或者你应该非常谨慎;如果你使用它,你应该非常小心地控制它的用法,等等。但你知道,突然之间,大家开始导入 reflect,因为它做了很多基础性工作,比如结构体标签……所以我觉得如果它是一个单独的包,用户的心理上可能会有更多的分离感。这样你可以编写 linter 工具来捕捉 reflect 的导入……但一些基础或更简单的担忧可以存在于不同的包中。

我见过的一些与此相关的做法是,如果人们想依赖 reflect 包,他们不会到处导入它;他们只是把 reflect 的所有用法封装在一个单独的包中,然后从那个包中提供一些工具。你见过这样的做法吗,或者你做过类似的事情吗?

Mat Ryer: 没有,但这对我来说很有道理。至少这样你就把所有的怪异集中在一个地方……但我不确定这是不是一种健康的做法,因为这有点像“厨房水槽”或“工具包”那种……

Jon Calhoun: 我确实做过将所有 reflect 相关的代码放在一个源文件中的做法,但我从来没有在 Go 的反射中做过足够大的东西,需要走到那一步……不过,我确实可以说,我在 Ruby 中确实疯狂使用过一些元编程的东西,但当我转到 Go 时,我并不觉得那是写 Go 代码的正确方式,所以我尽可能避免它。

Mat Ryer: 是的。我见过一个例子,有人为了做一个好公民,他们打算把一些数据放到 map 里,但如果 map 是 nil 的话,程序就会 panic……所以他们实际上使用了(我认为是)JSON 解组器;如果 map 是 nil,它会直接解组---他们在代码中直接写了两个小花括号,表示一个空对象。然后它使用这种技术创建了一个 map……这意味着作为程序员,你可以传入一个 nil map,它仍然能工作。不过,这有点太“魔法”了,而且有时让它 panic 也没什么问题,或者……因为它是一个库,有时我不介意捕获那些会导致 panic 的情况,然后用一个更好的错误信息来 panic,比如“你必须在传入之前创建 map”之类的。

但我确实见过一些不必要使用 reflect 的情况,但人们为了用户尝试多做了一些。这些情况挺有趣的。

另一种反射的形式是 Go 中的 AST 包,和一些实际的代码反射、代码分析包……它们也在不断改进。刚开始的时候,它们非常难用,现在有一些更高层次的包让它变得更容易了。我们有一个项目,我们实际上用 Go 接口描述了我们的 API,我们使用了 AST 包---有一个 packages 包可以让你打开一个包,然后你可以遍历接口,检查接口中的字段之类的东西……所以它做了那种反射;它以自己的结构表示数据,然后使用这些数据从模板生成代码。

所以这很棒,因为我们所有的 API 都是用 Go 接口描述的,作为 Go 开发者,这对我们来说非常容易理解和推理……而且它是真正的 Go 包,因此也是类型安全的。你不能使用无效的类型,所以这是描述 API 的一种很棒的方式。你知道它会工作。我们可以从中生成客户端,生成服务器端代码,以及处理所有那个样板代码的 HTTP 逻辑……任何样板代码都可以生成,我们甚至生成了另一个接口,实际上和原来的接口略有不同,因为它接受一个上下文,并返回一个错误,而我们在定义时省略了这些。

所以我们可以写下我们的定义接口,运行代码生成器,然后实现接口,仅此而已。我们就有了一个新的服务,然后可以在我们的项目中公开了。

Jaana Dogan: 你们什么时候开源这个项目?

Jon Calhoun: 我想他已经开源了。

Mat Ryer: 是的,已经开源了,叫 Oto[5]

Jaana Dogan: 真的吗?!

Mat Ryer: 是的,叫 Oto。

Jaana Dogan: 很棒。

Mat Ryer: 目前它基本上是一个 JSON API,但实际上,因为它只是代码生成,模板也可以修改,所以你可以很容易地为它编写一个二进制协议,或者其他任何类型的协议。是的,挺不错的。有人为它写了一个 Rust 服务器模板……这有点怪,但也挺酷的。我们会把链接放在节目笔记里。它的地址是 github.com/pacedotdev/oto[6],我会把它放在节目笔记里,供有兴趣的人参考。我们在生产环境中使用它,效果非常好。我是说,我们的用例相对简单,但它真的很好用……这其实也是一种反射,因为我们必须以编程方式检查那些接口,然后对它们进行操作。

Jon Calhoun: 所以 Mat,我猜你是用 Oto 来生成代码的,对吧?

Mat Ryer: 是的,基本上就是这样。它接收 Go 接口,把它们和模板混合,然后生成新的代码。

Jon Calhoun: 我想说的是,我们经常说反射不好,或者你应该尽量避免它,因为它很难理解,也很难维护……但我觉得有时候这很难,因为我们没有告诉人们替代的方法……而我认为代码生成是一个非常有用的替代方案。就像你说的,你其实是在做反射的事情,分析代码,然后生成代码,最终得到的东西更容易管理。

我甚至见过一些 ORM 采用这种方法;我记得 SQLBoiler[7] 就是其中之一,它会扫描你的 SQL 数据库,然后从中生成 Go 结构体……所以它不是使用反射,而是直接生成与你的数据库完全匹配的东西,你可以直接使用它们……这是一种完全不同的做法,但我认为限制反射的使用让人们去考虑其他方法,并决定“这是更好的选择吗?这是更容易维护的吗?”

Mat Ryer: 是的。还有 go generate 命令;你可以在代码中加一个注释,一个特殊的注释……这有点像魔法,但你可以写 //go:generate,然后加上一条命令。如果你在项目中输入这个命令,它就会执行这些命令。这对于那种需要在构建前生成代码的情况非常有用……这是一个不错的做法,因为你可以获得类型安全,编译器会帮助你;也许一开始没有,但一旦代码生成出来,它通常就是你项目的一部分了,接着就可以构建了……如果它有问题,你很快就会发现。

Jaana Dogan: 是的,这正是我想说的……我认为 ast 包和 reflect 包的区别在于,ast 包是一种美学上的选择;它并不是在运行时执行的。所以你生成代码后,仍然具有类似的可维护性和类型安全性。你只是用编译器生成了一些代码。如果你能将一些问题交给代码生成来解决,那绝对是值得做的。

Mat Ryer: 是的,这个观点很好。

Jon Calhoun: 我们之前讨论过泛型问题,其实我很想看到一种泛型的实现,基本上在一开始就运行 go generate。你写的代码就像泛型已经存在一样,按照某个提案去写,然后它会在某个预处理步骤中将其编译成 Go 代码,生成需要的内容,然后继续处理……我认为这是可行的,虽然需要一些复杂的工作。

Mat Ryer: 我和我的一个朋友写了一个类似的项目,叫 Jenny。有人在用它。它使用一种特殊的类型,基本上就是一个接口类型,放在一个不同的包里……我想这还是用了 AST 的技术;它会找到这些实例,并查找出你在命令中列出的类型。你运行一个命令,列出你想支持的类型,然后它就是一个复制粘贴的过程,替换掉代码中提到的那些类型。它不是完美的,因为你不能对它进行类型断言;一旦那样做,它就很奇怪了……但在简单的情况下,它是有效的。我想这就是你说的那种东西。

Jon Calhoun: 我用过类似的东西……我在想的是,可能可以把这个想法拓展得更远,像现在的泛型提案那样。让你可以完全按照泛型的方式写代码。因为泛型的一个问题是,它让编译和其他步骤变得更加复杂。所以,与其把它直接集成到编译器中,不如在预编译步骤中处理它,使其看起来像是已经内置在语言中,但实际上并不是,这样在那个步骤进行转换……

Mat Ryer: 对。

Jon Calhoun: 当然,这可能会变得非常麻烦,以至于不值得去做……

Jaana Dogan: 对我来说,泛型一直像是“嘿,这里有个模板,你用它生成一些东西,编译器处理所有这些事情,因为生成的代码复杂得让人难以理解。” 这就是为什么语言需要提供一些语法糖,让你能够与这些类型进行交互。所以我想,如果你暴露出生成的内容,用户会觉得非常可怕。你会有各种不同的---类型,和各种不同的情况,等等……所以我觉得对于很多情况来说,这看起来不会很好。这可能会让人们一开始就不愿意使用泛型。

这就是为什么我在等待真正的泛型提案和实现,我想看看那个语法糖到底是什么样子……即使实际的难题对我来说是不可见的,至少---我对底层生成的东西其实不感兴趣,因为我知道它在很多情况下会非常复杂。

我认为这些代码生成器没有真正流行起来的原因之一是,你需要一种官方认可的泛型解决方案。作为一个库,我不能随便选择一个工具,而不是另一个。实际上没有太多的实验。你不能真的暴露底层的东西;我只想要一个对所有人都有效的东西,这样我们可以达成共识,所有的库系统都能切换到它……我不太关心底层生成的是什么,它们可以随时优化,或者做其他事情……在这个领域已经有很多工作被做了,所以我们不是在第一次尝试解决这个问题。

我认为我们应该找到泛型的解决方案。它应该是官方语言的一部分。我觉得不需要太多的实验……但这对人们来说会很难,因为它肯定会让语言变得更加复杂。

Mat Ryer: 是的,但是你知道,很多 JavaScript 库采用了一种方法,就是使用一个垫片(shim)……我记得最初的 TypeScript 或者 Google 的 Dart 最初只是编译成 JavaScript,虽然看起来很丑,但 Jaana,你只需要不要看它。只要把最后的文件命名为“别看这个.go”之类的。

Jaana Dogan: [笑]

Jon Calhoun: 我觉得你必须训练人们……这有点像你在编译器之上又构建了一个编译器,而它才是给你报错、与你互动的那个……然后无论它最终编译成什么,你都得把它藏在某个捆绑文件夹里之类的……

Mat Ryer: 是的,但你想想 IDE 和所有工具……一旦“语法无效”,所有工具就都坏了。

Jon Calhoun: 现在会更难。但随着他们对不同 IDE 如何使用语言服务器的改进,希望这种实验……

Jaana Dogan: Gopls,是的。

Jon Calhoun: 是的。基本上,因为它们都在使用一个通用的……我忘记叫什么了,但基本上就是语言服务器---有一个通用的规范,所有语言都可以实现……所以希望这种工作能带来更多在现有语言上进行实验的可能性……这会很有趣。

Mat Ryer: 你能简要介绍一下什么是语言服务器吗?我记得它叫 LSP,对吧?语言服务器协议。

Jon Calhoun: 听起来是对的。一般的想法是,与其让每个 IDE 或编辑器都自己实现 Go 的自动补全和 JavaScript 的自动补全,不如标准化它。我记得 VS Code 是第一个这么做的,但现在其他编辑器也在用了。

Jaana Dogan: 是的,我觉得它来自微软,我不太确定……

Mat Ryer: 是的,确实来自微软的 Visual Studio Code。

Jon Calhoun: 这个想法是,他们提出了一个类似于 Go 中接口的东西,或者说“这是一个 LSP 应该为某种语言提供的东西。” 基本上,它应该有一些你需要实现的方法,它可以根据用户所在的位置给出自动补全建议……想法是你可以为任何语言实现这个,然后任何 IDE 或编辑器都可以利用它来实现编辑器中的自动补全。

Mat Ryer: 是的,这太棒了……老实说,我真的无法相信它能工作,因为所有语言差异这么大。我们是怎么找到一个协议,能够描述所有这些的?我觉得这真的挺神奇的,毫无疑问,这个协议肯定不简单。

Jon Calhoun: 这大概是那种1%的极端情况处理得不太好,但对大多数开发者来说,这并不重要,LSP 的好处远远超过了这些问题。

Mat Ryer:  是的。

Jon Calhoun: 但仍然存在一个问题---这不一定是问题,不同的编辑器会用不同的方法来实现这个。我记得 GoLand 是其中一个不使用语言服务器的编辑器;他们完全使用内部的实现。在某些方面,这有好处……因为 Gopls 刚出来时,确实很脆弱。但我记得他们的方案在当时对 Go modules 的支持要更好,不过现在我不确定是否还是这样。

Mat Ryer: 我听说过很多好评。

Jaana Dogan: JetBrains 通常就是这么做的。他们一切都自己做,这是他们的特色。

Mat Ryer: 是的。我唯一的感受是,每次我不得不使用 Java 时,我都接触过 Eclipse IDE,真的……这是一种美学上的感受。我用 Visual Studio Code 是因为它看起来更好看,你花那么多时间在里面……我觉得这确实很重要。我觉得你想要一个美好的使用体验。但我听说 GoLand 编辑器有一些很棒的功能,能做很多事情。我还没试过,但……嗯,我会感兴趣。如果有人想给我发推特,告诉我他们的体验,我可能会读一读。

Jon Calhoun: 可能会。

Mat Ryer: 你猜现在是什么时间……?[笑]

Jon Calhoun: 我想我们都知道,可能是“非主流观点时间”。

Mat Ryer: 是的,非主流观点时间!

Mat Ryer: 那么,有没有人有不太受欢迎的观点呢?我们已经说过的一些事情可能有点不太受欢迎,但……你们有什么特别想分享的吗?

Jaana Dogan: 我有一个……

Jon Calhoun: 继续吧,Jaana。我让她来主导这部分。

Jaana Dogan:  我觉得我们需要泛型。我知道这可能不是一个非常不受欢迎的观点,但……我从这门语言一开始就这么说了,结果大家都讨厌我……但我觉得我们确实需要泛型。

Jon Calhoun: 我完全同意,因为我做过足够多的工作---我觉得一个例子是,如果 Go 想在教育领域表现得好,比如让人们在大学里学习它,他们会接触到数据结构,而没有泛型很难处理数据结构……我认为这是 Java 在学校里被广泛教授的原因之一,因为它在这方面做得很好。

Mat Ryer: 是的。你怎么看最近的泛型提案?

Jon Calhoun: 我看过的最近一个提案我挺喜欢的---我还没深入研究过,但对我来说看起来没问题……我对细节要求不高,我的需求相对简单。

Mat Ryer: 我觉得他们在设计上取得了很大进展……当然,你经常会听到大家真正关心的是如何实现它,以及这对 Go 的类型系统会有什么影响,维护起来会有多难,等等……听到 Go 团队和其他贡献者优先考虑这些问题真是太好了,因为这确实至关重要。我非常不希望看到 Go 变得太复杂,以至于我们再也不能添加新功能了……所以在这一点上,我和你站在同一阵线,Jaana。

Jaana Dogan: 其实,我已经对这个话题感到非常疲惫了,一年前就不再关注这些提案了。

Mat Ryer: 哇。你是因为情绪波动太大吗?

Jaana Dogan: 并不是情绪波动大,而是我对每个提案都有至少 50 个顾虑……

Mat Ryer: 哦,才 50 个顾虑……

Jaana Dogan: 而且没有简单的答案……是的,我是说,有些是宏观层面的……

Mat Ryer: [笑] 你让其他人情绪波动了。

Jaana Dogan: 没错。我觉得我并没有真正为讨论做出贡献……而且我心里所担忧的那些点,你无法真正预见实际情况会是什么样子,因为这真的取决于那些会使用泛型的人……随着时间的推移,我们会看到它对整个库生态系统的实际影响。所以我当时觉得,“嘿,我并没有真正为这次讨论贡献什么。” 我很兴奋它正在发生。那些人---我几乎可以肯定他们非常在意。他们比我更在意,所以我觉得没有必要再试图参与了。

Jon Calhoun: 这让我想起了类型别名,以及它当时受到了多少抵制,大家都说它会毁掉这门语言……但自从它被引入后,我几乎没有看到它被广泛使用---偶尔你会看到它,我也做过一些奇怪的事情,只是为了看看能做些什么……但我觉得我没遇到过滥用它的库,这有点有趣,因为你之前看到的强烈反对意见……我理解他们的顾虑,我并不是说人们不应该表达顾虑,只是有趣的是,这些担忧根本没有成为现实。

Mat Ryer: 是的,但它确实被引入了。

Jaana Dogan: 我刚好想举这个作为例子,因为那是我第一次感到对这个项目感到倦怠的时刻,我当时想去做其他事情……一切都变成了关于各种可能性的无尽讨论……所以对于泛型---我不想再参与,因为已经有太多声音了,而这是一项艰难的工作,你无法预见未来。

我也信任 Go 开发者,因为很多人非常注重简单性,所以他们不会滥用某个特性。我认为 Go 的用户对于自己想使用的语言核心子集非常了解……这门语言并不大,但我也信任更大的生态系统。所以我现在不再那么担心了。

Mat Ryer: 是的,我的意思是,你总是可以选择不用它……坦白说,我直到很晚才意识到---类型别名已经进入了这门语言。我记得当时的提案和大讨论,但……另一个提案是关于错误处理的 Try 提案;对我来说,我对它过敏,因为它感觉太像“魔法”了……而且我觉得它不符合 Go 的哲学。我们必须小心,不要因为喜欢 Go 现在的样子而过于僵化,不允许任何演变,但……我觉得你是对的,Jaana---我们确实意识到简单性和反对“魔法”的重要性。我们作为一个社区,对此非常清楚。而且,是的,我想你总是可以选择不使用它,如果你不喜欢的话……

Jon Calhoun: 我觉得这可能是为什么反射是一个如此奇怪的话题的原因,因为人们来自其他语言,在那些语言中使用反射是完全正常的。我想我在我们开始录音之前已经说过了,但我不认为如果没有反射和元编程,Ruby 会如此受欢迎。Rails 和你能用它做的所有疯狂的事情,都是元编程的副产品,而在很多方面,它让那门语言更具生产力……但所有这些在 Go 中完全没有意义。

所以当人们从那样的语言转到 Go 时,他们会想,“为什么这个 reflect 库这么难用?为什么每个人都告诉我不要用它?”,这是一个很难的心理转变,我觉得这是因为解决问题的方法不同,优先级也不同。

Mat Ryer: 是的,我觉得你说得对。有时我会---与其维护……比如有一段代码用了反射,与其维护它,我会重写它,因为对我来说,重写的过程是我弄清楚发生了什么的方式。这就是问题所在---对我来说,维护它其实很难,我会放弃,转而选择重写它……坦白说,我如果可以的话,通常都会这么做,因为我总是发现重写是获得更好版本的方式……你在重写过程中学到了很多,第二次写的代码总是好得多。但我不知道,这确实是一个有趣的问题。

好吧,我想今天的时间就到这里了……下周我们有一个非常有趣的节目。我们会邀请一个刚刚得到 Go 开发工作的人,还会邀请一个正在学习 Go、还在高中里的学生。我们会看看人们是如何进入我们称之为编程的这个疯狂世界的。

Jon,非常感谢你。Jaana,总是很愉快。我们下次见!

Mat Ryer: 就这样。

Jon Calhoun: 我本来想弹吉他的……

Mat Ryer: 我得做个空气吉他,对吧……

Jon Calhoun: 你背景里有吉他,但你却说:“不弹……”

Mat Ryer: 是的,但我不会在 [听不清 01:01:02.08] 上弹。Jaana,你曾同意过我看起来像个魔术师。你还记得吗?

Jaana Dogan: 是的,你看起来像个魔术师。

Mat Ryer: 是的。你知道这有多难吗?你觉得理所当然,但我不得不跟我父母坦白。我说:“妈妈,爸爸,坐下。选一张牌吧。”

Jaana Dogan: [笑] 但你知道吗,这种胡子造型,这种风格……非常魔术师的感觉。

Mat Ryer: 确实很像魔术师的。太荒谬了。我应该换个造型,但……

Jaana Dogan: [笑]

Jon Calhoun: 我可以想象……你对父母坦白自己是个魔术师,他们说:“我们得给他买台电脑,或者别的什么……让他去编程吧。”

Mat Ryer: 是的,让他成为一个 Go 程序员,因为这里没有魔法。我不知道一个家庭为什么会反对魔法,但……也许有一些隐藏的背景故事。

Jon Calhoun: 也许你父母是觉得:“他得搬出去住了。这行不挣钱。”

Mat Ryer: [笑]

Jon Calhoun: 我其实不知道魔术师挣多少钱,但我猜应该很难入行……

Mat Ryer: 是的,我觉得很困难……嗯,我想不出笑话……真可惜,因为这里肯定有很多笑话等着让我从空气中抓出来。嗯,差不多够了……我喜欢讲完笑话后的尴尬沉默,那是我最喜欢的部分。

参考资料
[1]

Mat: https://github.com/matryer

[2]

Jon: https://github.com/joncalhoun

[3]

Jaana: https://github.com/rakyll

[4]

#133 Reflection and meta programming: https://changelog.com/gotime/133

[5]

Oto: https://github.com/pacedotdev/oto

[6]

github.com/pacedotdev/oto: https://github.com/pacedotdev/oto

[7]

SQLBoiler: https://github.com/volatiletech/sqlboiler


旅途散记
记录技术提升