草庐IT

小心golang中的无类型常量

apocelipes 2023-03-28 原文

对于无类型常量,可能大家是第一次听说,但这篇我就不放进拾遗系列里了。

因为虽然名字很陌生,但我们每天都在用,每天都有无数潜在的坑被埋下。包括我本人也犯过同样的错误,当时代码已经合并并发布了,当我意识到出了什么问题的时候为时已晚,最后不得不多了个合并请求留下了丢人的黑历史。

为什么我要提这种尘封往事呢,因为最近有朋友遇到了一样的问题,于是勾起了上面的那些“美好”回忆。于是我决定记录一下,一来备忘,二来帮大家避坑。

由于涉及各种隐私,朋友提问的代码没法放出来,但我可以给一个简单的复现代码,正如我所说,这个问题是很常见的:

package main

import "fmt"

type S string

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

func output(s S) {
    fmt.Println(s)
}

func main() {
    output(A)
    output(B)
    output(C)
}

这段代码能正常编译并运行,能有什么问题?这里我就要提示你一下了,BC的类型是什么?

你会说他们都是S类型,那你就犯了第一个错误,我们用发射看看:

fmt.Println(reflect.TypeOf(any(A)))
fmt.Println(reflect.TypeOf(any(B)))
fmt.Println(reflect.TypeOf(any(C)))

输出是:

main.S
string
string

惊不惊喜意不意外,常量的类型是由等号右边的值推导出来的(iota是例外,但只能处理整型相关的),除非你显式指定了类型。

所以在这里B和C都是string。

那真正的问题来了,正如我在这篇所说的,从原类型新定义的类型是独立的类型,不能隐式转换和赋值给原类型。

所以这样的代码就是错的:

func output(s S) {
    fmt.Println(s)
}

func main() {
    var a S = "a" 
    output(a)
}

编译器会报错。然而我们最开始的复现代码是没有报错的:

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

func output(s S) {
    fmt.Println(s)
}

output函数只接受S类型的值,但我们的BC都是string类型的,为什么这里可以编译通过还正常运行了呢?

这就要说到golang的坑点之一——无类型常量了

什么是无类型常量

这个好理解,定义常量时没指定类型,那就是无类型常量,比如:

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

这里A显式指定了类型,所以不是无类型常量;而B和C没有显式指定类型,所以就是无类型常量(untyped constant)。

无类型常量的特性

无类型常量有一些特性和其他有类型的常量以及变量不一样,得单独讲讲。

默认的隐式类型

正如下面的代码里我们看到的:

const (
    A = "a"
    B = 1
    C = 1.0
)

func main() {
    fmt.Println(reflect.TypeOf(any(A))) // string
    fmt.Println(reflect.TypeOf(any(B))) // int
    fmt.Println(reflect.TypeOf(any(C))) // float64
}

虽说我们没给这些常量指定某个类型,但他们还是有自己的类型,和初始化他们的字面量的默认类型相应,比如整数字面量是int,字符串字面量是string等等。

但只有一种情况下他们才会表现出自己的默认类型,也就是在上下文中没法推断出这个常量现在应该是什么类型的时候,比如赋值给空接口。

类型自动匹配

这个名字不好,是我根据它的表现起的,官方的名字叫Representability,直译过来是“代表性”。

看下这个例子:

const delta = 1 // untyped constant, default type is int
var num int64
num += delta

如果我们把const换成var,代码无法编译,会爆出这种错误:invalid operation: num + delta (mismatched types int64 and int)

但为什么常量可以呢?这就是Representability或者说类型自动匹配在捣鬼。

按照官方的解释:如果一个无类型常量的值是一个类型T的有效值,那么这个常量的类型就可以是类型T

举个例子,int8类型的所有合法的值是[-128, 127),那么只要值在这个范围内的整数常量,都可以被转换成int8。

字符串类型同理,所有用字符串初始化的无类型常量都可以转换成字符串以及那些基于字符串创建的新类型

这就解释了开头那段代码为什么没问题:

type S string

const (
    A S = "a"
    B   = "b"
    C   = "c"
)

func output(s S) {
    fmt.Println(s)
}

func main() {
    output(A) // A 本来就是 S,自然没问题
    output(B) // B 是无类型常量,默认类型string,可以表示成 S,没问题
    output(C) // C 是无类型常量,默认类型string,可以表示成 S,没问题
    // 下面的是有问题的,因为类型自动匹配不会发生在无类型常量和字面量以外的地方
    // s := "string"
    // output(s)
}

也就是说,在有明确给出类型的上下文里,无类型常量会尝试去匹配那个目标类型T,如果常量的值符合目标类型的要求,常量的类型就会变成目标类型T。例子里的delta的类型就会自动变成int64类型。

我没有去找为什么golang会这么设计,在c++、rust和Java里常量的类型就是从初始化表达式推导或显式指定的那个类型。

一个猜测是golang的设计初衷想让常量的行为表现和字面量一样。除了两者都有的类型自动匹配,另一个有力证据是golang里能作为常量的只有那些能做字面类型的类型(字符串、整数、浮点数、复数)。

无类型常量的类型自动匹配会带来很有限的好处,以及很恶心的坑。

无类型常量带来的便利

便利只有一个,可以少些几次类型转换,考虑下面的例子:

const factor = 2

var result int64 = int64(num) * factor / ( (a + b + c) / factor )

这样复杂的计算表达式在数据分析和图像处理的代码里是很常见的,如果我们没有自动类型匹配,那么就需要显式转换factor的类型,光是想想就觉得烦人,所以我也就不写显式类型转换的例子了。

有了无类型常量,这种表达式的书写就没那么折磨了。

无类型常量的坑

说完聊胜于无的好处,下面来看看坑。

一种常见的在golang中模拟enum的方法如下:

type ConfigType string

const (
    CONFIG_XML ConfigType = "XML"
    CONFIG_JSON = "JSON"
)

发现上面的问题了吗,没错,只有CONFIG_XMLConfigType类型的!

但因为无类型常量有自动类型匹配,所以你的代码目前为止运行起来一点问题也没有,这也导致你没发现这个缺陷,直到:

// 给enum加个方法,现在要能获取常量的名字,以及他们在配置数组里的index
type ConfigType string

func (c ConfigType) Name() string {
    switch c {
    case CONFIG_XML:
        return "XML"
    case CONFIG_JSON:
        return "JSON"
    }
    return "invalid"
}

func (c ConfigType) Index() int {
    switch c {
    case CONFIG_XML:
        return 0
    case CONFIG_JSON:
        return 1
    }
    return -1
}

目前为止一切安好,然后代码炸了:

fmt.Println(CONFIG_XML.Name())
fmt.Println(CONFIG_JSON.Name()) // !!! error

编译器不乐意,它说:CONFIG_JSON.Name undefined (type untyped string has no field or method Name)

为什么呢,因为上下文里没明确指定类型,fmt.Println的参数要求都是any,所以这里用了无类型常量的默认类型。当然在其他地方也一样,CONFIG_JSON.Name()这个表达式是无法推断出CONFIG_JSON要匹配成什么类型的。

这一切只是因为你少写了一个类型。

这还只是第一个坑,实际上因为只要是目标类型可以接受的值,就可以赋值给目标类型,那么出现这种代码也不奇怪:

const NET_ERR_MESSAGE = "site is unreachable"

func doWithConfigType(t ConfigType)

doWithConfigType(CONFIG_JSON)
doWithConfigType(NET_ERR_MESSAGE) // WTF???

一不小心就能把错得离谱的参数传进去,如果你没想到这点而做好防御的话,生产事故就理你不远了。

第一个坑还可以通过把常量定义写全每个都加上类型来避免,第二个就只能靠防御式编程凑活了。

看到这里,你也应该猜到我当年闯的是什么祸了。好在及时发现,最后补全声明 + 防御式编程在出事故前把问题解决了。

最后也许有人会问,golang实现enum这么折磨?没有别的办法了吗?

当然有,而且有不少,其中一个比较著名的是stringer: https://pkg.go.dev/golang.org/x/tools/cmd/stringer

这个工具也只能解决一部分问题,但以及比什么都做不了要强太多了。

总结

无类型常量会自动转换到匹配的类型,这会带来意想不到的麻烦。

一点建议:

  1. 如果可以的话,尽量在定义常量时给出类型,尤其是你自定义的类型,int这种看情况可以不写
  2. 尝试用工具去生成enum,一定要自己写过过瘾的话记得处理必然存在的例外情况。

这就是golang的大道至简,简单它自己,坑都留给你。

参考

https://go.dev/ref/spec#Representability

有关小心golang中的无类型常量的更多相关文章

  1. ruby - 如何从 ruby​​ 中的字符串运行任意对象方法? - 2

    总的来说,我对ruby​​还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用

  2. ruby - 其他文件中的 Rake 任务 - 2

    我试图在一个项目中使用rake,如果我把所有东西都放到Rakefile中,它会很大并且很难读取/找到东西,所以我试着将每个命名空间放在lib/rake中它自己的文件中,我添加了这个到我的rake文件的顶部:Dir['#{File.dirname(__FILE__)}/lib/rake/*.rake'].map{|f|requiref}它加载文件没问题,但没有任务。我现在只有一个.rake文件作为测试,名为“servers.rake”,它看起来像这样:namespace:serverdotask:testdoputs"test"endend所以当我运行rakeserver:testid时

  3. ruby-on-rails - Ruby net/ldap 模块中的内存泄漏 - 2

    作为我的Rails应用程序的一部分,我编写了一个小导入程序,它从我们的LDAP系统中吸取数据并将其塞入一个用户表中。不幸的是,与LDAP相关的代码在遍历我们的32K用户时泄漏了大量内存,我一直无法弄清楚如何解决这个问题。这个问题似乎在某种程度上与LDAP库有关,因为当我删除对LDAP内容的调用时,内存使用情况会很好地稳定下来。此外,不断增加的对象是Net::BER::BerIdentifiedString和Net::BER::BerIdentifiedArray,它们都是LDAP库的一部分。当我运行导入时,内存使用量最终达到超过1GB的峰值。如果问题存在,我需要找到一些方法来更正我的代

  4. ruby-on-rails - Rails 3 中的多个路由文件 - 2

    Rails2.3可以选择随时使用RouteSet#add_configuration_file添加更多路由。是否可以在Rails3项目中做同样的事情? 最佳答案 在config/application.rb中:config.paths.config.routes在Rails3.2(也可能是Rails3.1)中,使用:config.paths["config/routes"] 关于ruby-on-rails-Rails3中的多个路由文件,我们在StackOverflow上找到一个类似的问题

  5. ruby-on-rails - 未初始化的常量 Psych::Syck (NameError) - 2

    在我的gem中,我需要yaml并且在我的本地计算机上运行良好。但是在将我的gem推送到ruby​​gems.org之后,当我尝试使用我的gem时,我收到一条错误消息=>"uninitializedconstantPsych::Syck(NameError)"谁能帮我解决这个问题?附言RubyVersion=>ruby1.9.2,GemVersion=>1.6.2,Bundlerversion=>1.0.15 最佳答案 经过几个小时的研究,我发现=>“YAML使用未维护的Syck库,而Psych使用现代的LibYAML”因此,为了解决

  6. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  7. ruby-on-rails - Rails 3.2.1 中 ActionMailer 中的未定义方法 'default_content_type=' - 2

    我在我的项目中添加了一个系统来重置用户密码并通过电子邮件将密码发送给他,以防他忘记密码。昨天它运行良好(当我实现它时)。当我今天尝试启动服务器时,出现以下错误。=>BootingWEBrick=>Rails3.2.1applicationstartingindevelopmentonhttp://0.0.0.0:3000=>Callwith-dtodetach=>Ctrl-CtoshutdownserverExiting/Users/vinayshenoy/.rvm/gems/ruby-1.9.3-p0/gems/actionmailer-3.2.1/lib/action_mailer

  8. ruby-on-rails - Rails 应用程序中的 Rails : How are you using application_controller. rb 是新手吗? - 2

    刚入门rails,开始慢慢理解。有人可以解释或给我一些关于在application_controller中编码的好处或时间和原因的想法吗?有哪些用例。您如何为Rails应用程序使用应用程序Controller?我不想在那里放太多代码,因为据我了解,每个请求都会调用此Controller。这是真的? 最佳答案 ApplicationController实际上是您应用程序中的每个其他Controller都将从中继承的类(尽管这不是强制性的)。我同意不要用太多代码弄乱它并保持干净整洁的态度,尽管在某些情况下ApplicationContr

  9. ruby-on-rails - form_for 中不在模型中的自定义字段 - 2

    我想向我的Controller传递一个参数,它是一个简单的复选框,但我不知道如何在模型的form_for中引入它,这是我的观点:{:id=>'go_finance'}do|f|%>Transferirde:para:Entrada:"input",:placeholder=>"Quantofoiganho?"%>Saída:"output",:placeholder=>"Quantofoigasto?"%>Nota:我想做一个额外的复选框,但我该怎么做,模型中没有一个对象,而是一个要检查的对象,以便在Controller中创建一个ifelse,如果没有检查,请帮助我,非常感谢,谢谢

  10. ruby - rspec 需要 .rspec 文件中的 spec_helper - 2

    我注意到像bundler这样的项目在每个specfile中执行requirespec_helper我还注意到rspec使用选项--require,它允许您在引导rspec时要求一个文件。您还可以将其添加到.rspec文件中,因此只要您运行不带参数的rspec就会添加它。使用上述方法有什么缺点可以解释为什么像bundler这样的项目选择在每个规范文件中都需要spec_helper吗? 最佳答案 我不在Bundler上工作,所以我不能直接谈论他们的做法。并非所有项目都checkin.rspec文件。原因是这个文件,通常按照当前的惯例,只

随机推荐