C++ 加 QT5 的历史遗留项目。彻底重写不实际,很多坑要重新踩。原作者找不着人,连问都没人可以问。只能重构梳理之后 fix bug 先凑合用。
琐碎的重构不值一提,唯独版本号的改造,可以记录一下,给以后参考。
之前的版本号是写死在代码里的。想起来改一下,有时就忘了。
这样会有一些弊端:
首先是增加记忆负担。
修改版本号动作很小。但开发的粒度跟发布的粒度并不一致。提交的是最小可运行粒度。发布则还要考虑业务和运营。一次发布往往包含多个提交。提交的时候,未必能预计到发布的时机并修改版本号。
相对可行的办法,是每次发布完,马上改掉版本号,作为下次发布的预备。
但这样提交历史里,会有大量版本号的提交,稀释了真正有用的提交。
何况即使这样,还是无法跟踪两次正式发布之间的测试版本。
一般完整的版本号,会同时包含 供用户关注的、粒度较大的『发布版本号』,和 内部跟踪的、粒度更小的『内部版本号』。git 的 commit hash (SHA-1) 就是一个天然的内部版本号,帮我们把程序唯一对应到一个提交。有些时候还要加上系统平台、架构或编译器的信息。可一旦加上这些信息,上面的问题就更严重了。
要么不记得,要么改不对,还污染历史。
这些矛盾的根源,在于 版本号主要作用于 测试、发布、运维,帮我们跟踪代码版本、构建信息,其内容和代码逻辑本身几乎是正交的,是在代码提交完之后才真正确定下来的;却为了后续的跟踪,偏偏需要内置在程序里 。
在代码里修改版本号,就成了当预言家,要预测版本什么时候发布,能不能通过测试,能不能正常发布,注定吃力不讨好。
如果版本号里还需要包含 commit hash,在提交前就预测并写入代码,则几乎是不可能的任务。信息摘要有雪崩效应,hash 由提交的所有信息共同确定;但是在这个提交里,却要先把 hash 写入。这就陷入了 鸡-蛋悖论。
所以合理的做法是,代码里预留这些信息的位置和相关的代码,值等到代码提交后的某一刻再决定。一般来说,这个时刻就是编译的时候。
对于 Go 而言,就是在编译时,给 linker 传递 -ldflags "-X '<包名>.<变量名>=<字符串>'"
,就可以给代码里对应的(string)变量赋值。
举例说,如果在 main.go
里这样写
|
|
编译时加上 -ldflags "-X 'main.version=v1.2.3'"
,那么我们最后运行程序得到的输出就是 v1.2.3
。
更具体地,我一般会在 Makefile
里这样写:
|
|
主版本号通过命令行通过环境变量 TAG
传入,其它的信息则通过 shell 自动获取。
我需要在 C++ 里做到类似的事情。
特别强调,我本来用 C++ 就用得很浅,再加上已经有十年不怎么写,下面的内容仅仅对我而言值得记录一笔,对 C++ 高手来说可能完全不值一提。
项目用的是 g++ 编译器,可以很容易查到,最接近的做法,是给编译器传递 -D<宏>[=值]
来定义宏。
举例说,-DDEBUG_LOG
相当于 #define DEBUG_LOG
;-DVERSION=v1.2.3
则相当于 #define VERSION v1.2.3
。注意 -D
和后续内容中间没有任何间隔或者符号。
时间关系,我没有去确认其它编译器是否也有这个参数。
然而我们并不会手动调用 g++
,甚至都不会手写 Makefile
。项目下确实是有一个 Makefile
,但它是 qmake
自动生成的,任何修改都会在后续被覆盖。qmake
从 <项目名>.pro
生成 Makefile
。
结合现有的 .pro
文件和网上的信息,很容易确定 DEFINES
的值,最终会变成 Makefile
里的 -D
参数。
于是我在 .pro
加了一行
|
|
(暂时版本号里不想添加太多东西,只传入了 commit hash;甚至完整的 hash 都略嫌太长,只保留了前 10 位。)
迫不及待地开始编译,得到的 Makefile
里却是这样的
|
|
以空格为分隔符,每一段都成了一个参数……
不过这个很好解决,加个引号就行
|
|
引号也可以加在别的地方,只要把空格放在引号里,保证不会断开就行
|
|
无论是哪一种,最后得到的 Makefile
里都是
|
|
有效信息已经从 qmake
和 .pro
传到了 make
和 Makefile
手里。接下来 make
执行时,会执行 $(shell ...)
里的内容,并将结果原地展开。那么从 make
传递给 g++
的时候,-DCOMMIT_HASH=
后面就已经是具体的值了。
相当于 #define COMMIT_HASH 具体的hash
宏有了,我们终于来到代码部分。
由于宏是外部定义的,代码里没有,直接用会报错。只是不同 IDE 具体报的错不同。(因为 QtCreator 不好用,我在 VS Code 里写代码,QtCreator 只用来编译。)
即使不考虑这些报错,也可能存在后续忘了传对应参数的情况,从而导致代码有不可预料的结果。最好的办法还是加上 #ifdef
|
|
加了之后,VS Code 不再报错了(其实是因为它认为这个宏没有定义,直接忽略了这段代码)。但 QtCreator 很聪明地意识到还有问题,报了一个 expected expression
(这里需要一个表达式)。而如果改为将 COMMIT_HASH
当做字符串参数用,会提示少了参数。
究其原因,是 COMMIT_HASH
只是一个宏,而且是一个外部定义的宏,不是字符串。
到这里,对 C++ 已经很生疏的我有点懵,然后就开始胡乱地尝试。(接下来的病急乱投医让各位见笑了)
如果改为 version = "COMMIT_HASH";
,倒是不报错了,但是 version
得到的,也确实只有一个 "COMMIT_HASH"
的字符串。这是因为
宏参数不会在字符串常量内部替换(parameters are not replaced inside string constants)。
宏是预处理器负责处理的,早在开始编译之前就已经被替换。如果在 .pro
里再额外加了引号,试图让宏的值带上引号呢:
|
|
相当于 #define COMMIT_HASH "具体的hash"
。然而没有用,仍然报错。
因为这种外部定义的宏,跟真实写在代码里的宏,还不一样。
实际在代码里定义一个 COMMIT_HASH
对比一下效果(以下报错都来自 VS Code):
#define COMMIT_HASH 1234
,引用处报错 no suitable constructor exists to convert from "int" to "QString" : C/C++(415)
;#define COMMIT_HASH 123aef
(没有 0x
前缀的 16进制数,模拟真实的 commit hash 值),报错 user-defined literal operator not found : C/C++(2486)
;#define COMMIT_HASH "123aef"
,可以正常编译。有这些差别,是因为写在代码里的宏定义,就在编译器和工具链 眼皮底下 ,完全可以先展开,再做静态分析。
可对于外部定义的宏,这些分析就没法做。工具链无法确定这是什么东西,也就无法确定究竟是以上哪种情况。
我们需要给工具链一个保证:在不知道宏的值的情况下,拿到的仍然是一个字符串。
对 C++ 来说,我们需要预处理运算符(stringizing operator):#
,它会把参数对应的字面文本转换为字符串。
是的,#
不仅是所有预处理指令(preprocessing directive)的开头,单独的 #
也是一个预处理运算符(preprocessing operator)。
为此我找了两个参考文档,分别来自
两边结合着理解。
上来直接把代码改为 version = #COMMIT_HASH;
,得到的错误是 '#' not expected here : C/C++(10)
。
结合例子理解一下,哦,貌似 #
只能在宏里面生效。好,改成这样
|
|
再试一下。这回可以正常编译。可是运行之后发现,得到的还是一个 "COMMIT_HASH"
。
还是着急了,只看了文档开头就动手。还是要把文档看完。
咦,不对啊,为什么 GNU 的例子展开了。你看
|
|
等价于
|
|
这 EXP
不是展开成 x == 0
了吗?
真的吗?
这里暂停让读者自己理一下。
1
2
3
4
5
哎呀,把自己绕进去了。让我们一一对应一下:
EXP
对应 S
;x == 0
对应 COMMIT_HASH
;x
没有进一步展开,COMMIT_HASH
也没有。好吧,确实没错。
EXP
的参数会 原封不动 替换到 if 语句中,并且被字符串化成为fprintf
的参数。如果x
是一个宏,它会在 if 语句中继续展开,但在字符串中不会。(The argument for EXP is substituted once, as-is, into the if statement, and once, stringized, into the argument to fprintf. If x were a macro, it would be expanded in the if statement, but not in the string.)
看到这里我懂了,#
的字符串化 先起作用。然后 双引号阻止了里面的宏进一步展开。
那我就是需要继续展开怎么办?
其实两边文档后面都给出了例子。还是以 GNU 的来参考(毕竟我们用的 g++):
|
|
str (foo)
对应的展开是 "foo"
, xstr (foo)
则经历了两次展开:
str(4)
,"4"
。其实就是多展开一次,让 #
不那么快生效,好让传进去的宏有机会展开。
果然,我的代码改为
|
|
就达到了目的。到这里,运行之后,version
输出的就是具体的 git commit hash 了。
这里有了一个问题,如果传进去的宏,里面还有一层宏呢?
以 GNU 的例子为例,如果变成这样
|
|
会得到什么?
是 "bar"
还是 "4"
?
函数宏的层数需要总是比参数宏多一层吗?
需要再定义一层 #define ystr(s) xstr(s)
吗?
还说说两层的函数宏就能应对所有情况?
这个问题就留给读者自己尝试吧。
欢迎在留言说说你的看法。
我有十年不怎么写过 C++ 了。
C++ 性能好,可以访问底层接口,可以精确控制代码行为和内存分配,支持多范式,开发自由度大,优点很多。所以在很多领域是首选。
但真的太复杂了,新手容易玩脱,老手把代码写对也不算轻松。心智负担大,历史包袱重。
要获得它那些优点,我甚至宁可写 C。其它场景,也优先 选择 Java,或者 Go。反正我是不可能选 C++ 作为新项目的开发语言了。何况现在还多了 Rust 这个选项。
偏偏碰上这么一个遗留项目。
只好把 C++ 临时捡起来,一顿重构。
原作者大概水平不高,或者写得不用心,又或者两者都有。代码质量很差,大量面条代码,动辄几百行的函数,宁可拷贝也不肯抽取函数,前后打脸的变量命名和代码逻辑,想当然的并发代码 …… 反正就一个编程规范的反面教材,吐槽不完。他大概也发现自己维护不下去,在我接手之前就撂挑子了。
重构过程按下不表,反正是一边猜测代码的意图,一边看运行效果去验证,等理解了,再去修正逻辑错误。
慢慢算是改出一点效果。但是还是有很多地方不太确定意图,为了保持跟服务端的兼容性,需要进行灰度更新。
出问题时,我需要能区分具体版本。
于是就有了上面的一出。
关于 hash 部分,之所以说预测 hash 值只是 几乎 不可能,是因为还有一种办法:随机碰撞。
随机构造一个合法的 hash,尝试写入之后求真正的 hash,不断重试,直到构造的 hash 刚好和生成的 hash 一致为止。
理论上这是可以做到的。但这种做法会消耗大量的算力和时间。比特币挖矿的 POW 跟这个接近,但只是限定得到的 hash 的开头的零的数量,就已经耗费如此巨大的算力。如果要求算出的 hash 跟 构造的值 完全一致,需要的碰撞次数可能是天文数字。我不是这方面的专业人员,估算得可能不准确。但要说对普通开发电脑来说不可能,应该还是中肯的。(Google 破解 SHA-1 时,动用了大量的计算机集群,才构造出一个 PDF,获得预先构造的 hash)
而这,只是一次提交。难道每次提交都要动用超算,还要跑半天?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
眼看 2021 年就要过去了,跟大伙聊聊。
(是不是觉得这句话怪怪的。是的,太忙了,就这点字儿也是断断续续写,跨年了才发出来。)
一句话总结:装修、陪考、带娃、搬家、适应新岗位。
你要是问,事情就真的安排得那么满,每天抽点时间写两个字都抽不出来?那倒不至于。
那些励志故事里,主人公同时身兼数职,还各种见缝插针阅读、学习、写作。跟他们相比,我的时间海绵真是水得可以,根本就没有用力挤。
但关键不在时间,而在精力。这半年多里,我一直觉得自己在玩一个大型的华容道。赤壁的火马上烧到眉毛,前面还有一堆大块头挡道,要小心翼翼腾挪,曲线救国。一个人考虑这些,哪怕没在干活,心里也一直在琢磨,精力处于亏空状态。
之前的文章说过,年岁渐长,我越是理解并接受,自己是个普通人,能力和精力有限。我与自己和解,不再幻想通过自我施压实现目标。不把自己逼疯,持续输出比较重要。
新房很早就装修好了,剩一些细节收尾,通风散味。出于健康方面的考虑,原计划并不着急搬家。
直到租的房子,各种设施突然开始集中坏掉,像在催促我们走。
房东很好说话,就是太忙,疏于打理房子。这些年坏的东西,都是跟房东商量好,自己找人修。
但这回老化坏掉的设施,好几处修起来都比较麻烦。算一下折腾完也该搬走了。与其折腾修,不如凑合住着,赶紧收拾新房。
在那个时间点,队友还在为换工作学习和考证,我妈帮忙带着孩子,我预定的工作还没开始。
干活的只能是我。而且只有我。
一个人吭哧吭哧在新家收尾和搞清洁,赶在队友去新单位上班前,把一家人挪过来。再回到租的房子,一个人吭哧吭哧地收拾旧东西。中间还要给不敢开车的队友当司机,送她去各个考场。有时队友闭关复习,还要一个人带娃。
别问我为什么不雇人。最后退房前的清洁就雇的保洁,快准狠。但更多的活,是自己才能做的细致活:
譬如说,新房有个墙面没找平,装好定制柜子才发现有很大空隙,想填腻子重新刷漆;找了很多师傅,活太小没人愿意接。又譬如,租的房子为了给娃腾空间,储物那个乱,偏偏蟑螂还很多;要是交给搬家公司直接搬,会夹杂垃圾和蟑螂一起带过来;想直接扔,里面又有很多重要的东西。诸如此类让人为难的活,只好自己干。
这样的活,一个接一个,直干到人崩溃。
但最后,还是庆幸选择亲力亲为,翻出了重要证件和回忆,过滤了数量庞大的垃圾和蟑螂。最后保洁的干活速度之快,来不及交代的东西都成了垃圾——幸好已经没有重要的东西了。
晚上搬到新家,第二天早上队友就去了十几公里外的单位上班。
这个距离,按闲时半小时车程,多数人会选择开车。可队友刚到新岗位,需要时间熟悉业务。高峰动辄50分钟起步通勤时间,加上她生疏的车技,让她决定先住在单位宿舍。
我这边,原定的项目,因为疫情和政策没了下文。等忙完搬家,正准备寻找机会的时候,两位老朋友联系到我。
简单说,业务上升期,技术严重缺人,承诺很高的技术权限和较大的时间自由。
看到技术上有可作为的地方,也考虑到队友的情况需要我兼顾家庭,稍微考虑之后我就加入了。
接下来是另外一个大型华容道。
业务快速上升和技术缺人,带来的是大量的技术债,到处是坑,狼烟四起。
而招聘、还有管理,这些跟人打交道的事情,比想象中更消耗精力。
我内向且慢热,上下文切换开销大,却被多个线程撕扯着。招聘准备不足,忙活了一星期,没有合适的,赶紧先停掉;重写基础组件,写到一半,被别的事情拽走。最终还是救火占了上风,各种琐碎的事都得亲力亲为。毕竟要是大本营都烧没了,其它事情无从谈起。
我并不介意干琐碎的技术活,甚至乐意保持对技术细节的掌控感。可后面还有一堆事情等着处理时,一个人在不同技术角色来回切换,硬着头皮干一些生疏的活,这种低效率其实是一种奢侈的浪费,却没有别的办法。
人少、身兼多职的低效、对遗留代码不够熟悉,加上一个晚上不肯睡早上不肯起的娃,尽管除了睡觉和带娃的时间都扑在工作上,各种日程还是常常 delay。
连续工作,还有连续 delay 带来的压力,在慢慢侵蚀我的状态乃至自信心。
多亏有其他同事一起坚持,才看到一点摆脱这种状态的希望。
长期处于这种状态下,突然发现自己喜欢上开车。
我本来不喜欢开车。汽车只是移动工具,没有必要非得自己掌控。本可以利用移动时间处理事情,或者干脆补觉,一旦坐在主驾位置上,自己倒成了车子的人肉 CPU,不仅不能干自己的事,还得全程把注意力搭进去。如果碰上堵车或者长途,就更累人了。
这样看当司机实在很亏。我总是乐意优先选择公共交通,或者坐在副驾。虽然偶尔也会有开车兜风的冲动,但是对于当义务司机一向都不积极。所以总是催促家里的本上老司机,重新把开车练熟,我好坐副驾睡觉。
最近我突然积极起来。
一次送队友去单位的路上,我突然明白过来:开车的过程,以『交通安全』的名义,把我从日常的思虑和压力中,拉了出来。
交通安全,或者说生命安全,有着天然的道德优势。在这段时间里,我可以暂时不去想系统的架构,不考虑开发任务的优先级,不接听救火或者开会的电话,而不必有心理上的负担。与之相比,已经熟练到可以下意识进行的驾驶行为,反而是比较舒服的。
这样的日子里,一个晚上电力充沛的娃,把睡前学习码字的路也堵了。
等把他哄睡,再起来收拾一下,就已经太晚了。
这还算是好的,赶上娃感冒咳嗽,晚上基本就不可能睡踏实了。也有些时候,白天太累,娃还没睡着我先打瞌睡,等娃睡着,起来收拾完,反而精神了。这时知道明天是不可能早起了,干脆起来把明天上午的任务先处理一些。
日程压力在那,大块时间优先干活,小块时间优先休息。除此以外的事情,都有不务正业的愧疚感——哪怕是跟业务结合,提前给同事写技术教程。毕竟一时还用不到。
不怕大家笑话,太久不更新,我甚至有点害怕更新,害怕大家发现还关注了这个鸽子公众号,然后顺手取关。所以心里攒着劲,复更要更点有用的东西,好留住大家。
没有想到半年里,因为大佬们的转载,读者数居然还涨了一点。
没有更新,自然想不起来去看后台,因此错过了一些留言。
错过了土拨鼠大佬(公众号:Go招聘)的转载请求。错过了网友的加好友请求。
这里向被我放鸽子的读者,向留言但没有得到回复的朋友,一并说一声抱歉。
(微信为了避免公众号骚扰读者,只能读者主动找公众号。留言必须 48 小时内回复。等我看到留言的时候,早过了时限,不是我耍大牌。)
我不是大佬,如果大家不嫌弃,可以加微信『MrArchive』一起交流。加好友请写明来意,类似『公众号读者,Go 语言交流』这样。不然卖课程、卖房子、卖各种广告的人太多了。
这就是我忙碌又混乱的 2021 (下半年)。
回头想想,混乱从 19 年就开始了。彼时我们全家都在期待新生命的到来,阿公阿婆(以及另外几位亲人)都还健在,大家还不知道什么是新冠。
接下来的生活逐渐失控,保胎、孩子出生、房子维修和装修、工作调整 …… 中间伴随着影响全球的疫情,以及好几场送别。不过跟全球经济寒冬相比,跟某些行业、跟感染者和医护人员比,我们受到的影响算是很小的了。
或者说,其实在我几年前从外企离职时,在买下这套房子时,在我们决定要一个孩子时,就注定了路不会太好走。
这些年下来,我的感受是,混乱并不可怕,没有希望才可怕。
毕竟把时间和空间的尺度拉大,混沌才是常态,秩序则更像是偶然。
未来的日子,可能适应混乱,拥抱变化,才是唯一的出路。
虽然这对一个强迫症来说并不容易。
那我还更新吗?
更。
一定会更。
只会因为忙而鸽,但绝不会停。哪怕将来有一天微信公众号这个平台下线了,也会找到另外的地方继续更新。
这首先是为了自己记录。
忘了在哪里看过一句话:没有纳入版本控制(VCS)的代码,就跟没有写过一样(大意)。
同理,所有没有记录下来的灵光一闪,最终都会遗忘在时间长河里,不再属于你。只有成为文字,才是你的。
这个道理过去已经验证过很多次。解决过的问题,过几年遇到依旧生疏,甚至看到自己留下的文档的第一感觉:如看天书。
人菜,还不存档,怎么通关。何况人生这个游戏,通关之路漫长。
而且这几年,还发现,有别的需要记录:人和事。
如果说,之前是一个初出社会的年轻人,试图积累手里的技,和记录心里的悟。
那么现在,过了而立之年的人,还想尝试留住一些过往。
小时候觉得理所当然一直都会在的人,一一与我道别。
不,更多时候根本来不及告别。
《寻梦环游记》说,人会死去三次,最后一次是被世人遗忘之时。作为一个受过高中物理训练的人,觉得这种说法过于文艺——无论你是否记得,他们本人都不再有任何感知。
你我皆是星辰之子,从星尘中来,终究回到星尘中去。
饶是明白这些道理,我仍愿意记得他们。那些音容笑貌,今天在脑海里依然清晰,梦中还偶尔遇见,但不知道哪天就再也想不起。
我愿意借助互联网,让他们存在过的痕迹,稍微多保留一会,甚至超过我存在的时间。(愿公众号,或者未来可能换的托管服务,基业长青)
不去后悔过去与他们相处太少,因为总的时间有限,生命再来一次,不见得会分配得更好,无非蝴蝶效应、厚此薄彼。记住,是能做的最后一件事。
不过,也不光为自己记录。我也需要读者。
不然写私密日记好了。
我需要来自读者的监督和鼓励,需要交流和指正。
那些纪念,也希望有更多人知道,他们来过,一些事发生过。
如果碰巧博得些名气,顺便,换点钱,或者给工作带来助力,更好。想要名利,改善生活,不寒碜。
可我的读者,隔着网络,面目模糊。
这是大多数内容输出者早期都会遇到的问题。
在跨过某个临界点之前,会有三个问题会被反复提起:
然后为了找到答案,在不同尝试之间,反复横跳。
随便找一个已经实现稳定输出、有固定受众的博主(包括做视频的),翻到最早期的作品,大概率可以看到内容和风格在不断调整。
直到,在那三个问题的答案里,找到交集。
又或者,在不可能三角里,放弃掉一端或两端。
全职博主要吃饭,受众优先,个人志趣做一定让步,技能树不妨现点。
个人表达优先,就要有缺乏受众的心里准备,耐得了寂寞。
大 V 也有过摸索的阶段。
读者面目模糊的直接结果,是选题和深度都拿不准。
一直想写的,像 Go 语言 和 粤语 的内容,一定会写下去。区别在于写多细、多深。
因为不想糊弄,目标往往定得略超出自己的能力范围——费曼技巧,借输出以输入——写完自己也得到提升。
这样输出速度就慢。空闲时间不多时,自然更慢、甚至暂停。
而这样艰难的输出,反响平平。找身边的朋友问,“感觉有点硬,先收藏,以后慢慢看” 。
不见得内容真有多深奥,只是个人储备有限,勉强深入之后,就没有余裕浅出了。
主食硬菜毕竟有限,之间的空档,需要开胃小菜。一边跟读者保持联系,另一边保持更新习惯和码字手感。
上面是个人爱好,这部分总得讨好一下受众。
但我就是不会选题。
本来码字时间就难得。
本来写东西就拧巴,跟自己较劲。
好不容易写出来的东西,大家没兴趣。
怎能不打击积极性。
也想过蹭热点,但不想当标题党,要借热点展开聊点啥。结果一深入写,热点凉了。
改善大概没有捷径。
无非,内容质量,输出技巧,贴近受众。
废话,这恰恰最难,大家都在上面使劲。
虽然慢,知识水平还是在努力提升的(特别是工作相关内容);表达也有反复修改,刻意练习。还好暂时不靠输出吃饭,心态不至于失衡。
一直没进步的,就剩下与读者的交流。不知道你们是谁,听不见你们的声音(难得的回复还让我错过了),不知道哪些内容对你们有用,不知道哪些地方写得不好……
既然努力也没用,干脆暂时『放弃』读者交流。
大家关注,更多只是偶然,愿意试试看内容是不是对口。我自己都尚未形成风格和节奏,没办法给大家稳定的预期,又怎能期待大家给我清晰的反馈。
考虑到关注者里有现实中的亲友,关注是支持,并不一定内容对口,谈不上给意见;考虑到很多陌生人更愿意安静地看,没有动力发表意见——不合适大不了不看(我作为读者时何尝不是)。
以目前的关注数,没有声音、面目模糊再正常不过。
我只看到了别人的冰山露了尖,却不知道别人水下看不见的冰山有多大。现在想这些,纯属庸人自扰。
其实就是太贪心。试图一下子从『为自己写』,跳到『为广大读者写』。结果不知道读者是谁,也忘了自己是谁。
明明连身边的人的需求,都还没服务好。那些身边人提的问题,都还没好好回答。
那就从『为自己写』,先往外扩一小圈,变成『为身边人写』。
这样一来,读者就一下子有了名字和面孔,如何确定选题和深度也一下子清晰起来。
卷子答得好不好,总归可以问问出题人吧。
我身边有哪些读者呢。
家人:
技术同事:
非技术同事和客户:
“这技术好像很厉害,但我听不懂。” 跟非技术人员打交道,沟通不到位,容易对技术方案的效果、实现难度、工期 等很多细节理解有偏差。那么对于工作中高频出现的、比较关键的内容,也有写科普的必要。
然后是一群信任我的朋友们,遇事不决会问我意见。那些我能回答、或者感兴趣愿意一起研究的,思考讨论的过程也可以分享出来,姑且叫 #万能的朋友圈# 。
能坚持看唠叨看到这里的,想必是忠实读者了。
前面说了,我们现在很缺人。所以我想,有没有可能,正好有年轻读者也在寻找工作机会呢。
我们做无人零售设备和系统,研发团队 base 广州黄埔。主要是自己的业务,但也有客户定制化的开发。
软件技术栈方面
各个岗位(Java / Go / PHP / 前端 / Android)都需要人,有相关语言经验的朋友,可以加微信私聊。考虑到现阶段有经验的 Go 开发者不多,而且主要被大厂垄断,我们对直接招不抱太大希望,可以你先来,我们教。(至少需要一门其它语言的开发经验,和基本功过关。)
虽然上面抱怨了那么多,而且小公司的薪资也比不上大厂的诱人。但选对了,小公司也有好处。首先是技术的参与度高,成长快;万一公司真的做大做强了,能享受到早期员工的红利;然后是人际关系更简单,没那么多办公室政治。
当然,不是所有小公司都这样,也有很多公司,人没几个,就管理混乱,毛病一堆。大公司也有大公司病,不是所有岗位都重要,一些边缘岗位,特别消磨人的热情。还是要具体接触了解。
我就说两点:我们不缺业务量和客制化订单,反而因为人手不足,不得不拒绝一些订单;我们的人员整体都非常年轻,氛围很好,内部互相都喊名字——不仅不带职位敬称,连『哥』、『姐』也免掉,直接喊名字。
合适的人不好招,估计未来一段时间内都会缺人,暂时不方便离岗的,也可以先聊着。不在公众号展开太多,以免打扰到不需要的读者。
本文为本人原创,采用知识共享 “署名-非商业性使用-禁止演绎” 4.0 (CC BY-NC-ND 4.0)”许可协议进行许可。
本作品可自由复制、传播。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,原文引用(不可发布基于本作品的二次创作),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
相信实际写过一些代码之后,会更容易理解。
原计划这期聊 数组和切片。考虑到聊切片时,无论如何绕不开指针和引用的话题,干脆提到前面来。
[TOC]
指针(Pointer)本质上是一个指向某块计算机内存的地址。就像日常的门牌地址一样。只不过内存地址是一个数字编号,对应的是一个个字节(byte)。
当然,高级语言能访问到的内存,经过了操作系统内存管理的抽象,并不是连续的物理内存,而是映射得到的虚拟内存。但现在不必关注这些细节,当它是连续内存就好。
出于内存安全和屏蔽底层细节的考虑,C++ 以后的高级语言大多不再支持指针,而是改为使用『引用』。引用和指针的差别,我们后面说。
Go 作为 C 的『嫡亲』后继,为了性能和灵活性,保留了指针,而且用法基本一样。但 Go 增加了 逃逸分析 和 垃圾回收(GC),一定程度上解决掉了 悬挂指针 和 内存泄漏 的问题,降低了开发者的认知负担。(注意,Go 还是可能发生内存泄漏,只是需要特定的条件,发生概率大大降低了。)
先上代码,来点直观认识
|
|
关于取址运算符
&
和 解引用运算符*
的详细介绍(优先级、可寻址等内容),请参考第 4 期的《运算符》。解引用 dereference:取址 address 的反操作,意味根据类型,从地址中取出对应的值。
上面的代码输出
|
|
指针的零值是 nil
,对一个 nil
指针解引用会引起运行时错误,引发一个 panic。
通过下图,可以清晰看到4 个变量之间的关系。
注1:
int
类型在 64 位机器上是 64 位,占据 8 个字节。注2:两个指针实际上也是保存在内存上,但是为了特意区分,也为了避免内存的图示画得太长,所以把它们单独放在左边示意。
指针允许程序以简洁的方式引用另一个(较大的)值而不必拷贝它,允许在不同的地方之间共享一个值,可以简化很多数据结构的实现。保留指针,让 Go 的代码更灵活,以及更好的性能表现。
指针是派生类型,派生自其它类型。类型 *Type
表示『指向 Type 类型变量的指针』,常常简称『Type 类型的指针』,其中 Type
可以为任意类型,被称作指针的 基类型(base type)。换言之,从 Type
类型,派生出 *Type
类型。
前面说到,内存地址是一个编号,指针的底层类型(underlying type)相当于是整型数(uintptr
),宽度与平台相关,保证可以存下内存地址。
但指针又不仅仅是一个整型数,上面还附加了类型信息。指针指向的类型不同,派生出的指针类型也不同。所以指针不是一个类型,而是一类类型;类型有无数多种,对应的指针(包括指向指针的指针)的类型也有无数种。
*int16
跟 *int8
就是不同类型。它们虽然存了同样长度的地址,但 基类型 不同,解引用时会有不同的行为。不同类型的指针之间无法进行转换。(除非通过 unsafe
包进行强制转换。包名 unsafe 道出风险,这个包里的都是危险操作,后果自负。)
|
|
输出
|
|
可以看到,两个指针保存了同样的地址,按理说解引用取出的内容应该是一样的。但事实是,解引用还跟类型相关:地址只指明了取内容的起点,基类型指定取多少个字节,以及如何解释取出来的比特。在这里,对 *uint16
解引用取出了两个字节,按整型数解释为 796
;对 *uint8
解引用则取了一个字节,解释为 1
。
这里还得知了一个额外的信息:我的电脑是小端字节序,换句话说,数字是从低字节到高字节存储的,也就是 00000001 00000011
,跟手写的习惯是相反的,所以才会在只取一个字节时,取到了低字节。
在 C/C++ 里面使用指针,容易发生两类问题:
悬空指针(dangling pointer):又叫野指针(wild pointer),是指非空的指针没能指向相应类型的有效对象,或者换句话说,不能解析到一个有效的值。这有可能是对指针做了错误的运算,或者目标内存被意外回收了。
内存泄漏(memory leak):是指因为疏忽或者错误,没有释放已经不再使用的内存,造成内存的浪费。在 C/C++ 这类没有内存管理的语言里,常见的泄漏原因是在释放动态分配的内存之前,就失去了对这些内存的控制。
Go 里面不允许对指针做算术运算,基本排除对指针运算错误导致的问题。剩下还能出问题的,就是释放内存的时机:释放早了,悬空指针;释放晚了或者干脆没释放,内存泄漏。来看看 C 的例子:
|
|
Go 的解决方案是
逃逸分析:由编译器对变量进行逃逸分析,判断变量的作用域是否超出函数的作用域,以此决定将内存分配在栈上还是堆上,不需要人工指定。这就解决了第一个问题,函数内部声明的变量,其内存可以在函数返回后继续使用。
垃圾回收:由运行时(runtime)负责不再引用的内存的回收。回收算法一直在改进,这里不展开。这就解决了第二个问题,当内存不再使用的时候,只要不引用即可(指针置零,或者指向别的内存),不需要手动释放。
因为这些改进,Go 里面的指针看起来跟 C/C++ 差不多,实际使用的负担却小很多。
需要注意的是,垃圾回收无法解决『逻辑上』的内存泄漏。这是指程序逻辑已经不再用到某些内存,但是仍然持有这些内存的引用,导致垃圾回收无法识别并回收这些内存。这就好比清洁工只能保证地上和垃圾桶的干净,却无法判断办公桌上有哪些东西是没用的。
对于操作数 x
,如果想访问它的成员字段或者方法,可以使用字段选择器(field selector),实际上就是一个句点 .
加上字段名。
举例说 p
是 Person
类型的变量,而 Person
有一个 Name
字段和 Run()
方法,就可以通过 p.Name
和 p.Run()
访问。
这部分的详细内容,要等到结构体和方法部分再展开。这里只提一点与 C/C++ 的区别。
还是以 p
和 Person
为例。在 C/C++ 里,只有 p
是一个 Person
类型变量的时候(相当于Go 语言的 var p Person
),才能用句点访问成员字段。如果 p
是一个 Person
类型的指针(相当于 Go 的 var p *Person
),则要用箭头操作符 ->
访问成员。p->Name
跟 (*p).Name
等价。
Go 里没有箭头操作符。两种操作都用字段选择器 .
表示。实际上这是 Go 提供的一个语法糖,当Go 发现 p
是一个指针而且没有相应名字的成员时,会自动在 *p
里寻找对应的成员。
这样做,好处是省了一个操作符(Go 真的很省操作符和关键字),并且将值变量和指针变量的使用统一起来,在很多场景中可以不必关心使用的是一个值还是一个指针。而坏处也在于,在一些场景混淆了这两者。这个也是到结构体和方法时再细说。这里给一个直观的例子:
|
|
从 **Person
的角度看,会觉得很不讲理:明明 *Person
也没有 Name
这个字段啊,为什么 pd
不报错?
因为编译器识别到它是一个指针,自动从 *pd
里找字段。但是这个忙只帮忙向下找一层,对于 ppd
,ppd.Name
不存在,(*ppd).Name
也没有,就放弃了。
不像在 C/C++ 里很多操作都依赖指针,指针的指针并不少见,Go 里很少用到多级指针,所以这种语法糖只包一层大部分情况够用。
这三个概念既存在包含关系,又存在对比,解释起来非常拗口。如果你看完之后还是云里雾里,请耐心再多看几遍,或者实际写代码感受一下。如果还是不能理解,一定是我水平的问题,请先跳过这一部分。欢迎留言告知你的想法。
在第 2 期《常量与变量》里,有提到值的定义:『无法进一步求值的表达式(expression)』,例如 4 + 3 * 2 / 1
的值是 10
。而常量和变量,则可以理解为值的容器。(尽管常量在具体实现上,往往是编译期直接替换为目标值。)
这个定义,强调与量并列。
值也可以理解为『可以被程序操作的实体的表示』。这时不强调与量的区别,如果一个变量保存了一个值,出于方便,有时也称这个变量为一个值。
虽然标题将指针、引用和值并列,其实引用和指针,本身也是值。它们都用来表示『可被程序操作的实体』。
同时指针是引用的一种,是最简单的透明引用。
换言之,三者之间构成这样一种包含关系:引用是值的一种特例,是一类可以间接访问其它值的值,区别于直接使用的值;指针是引用的一种特例,是一类简单的透明引用,区别于不透明的引用。
先对比指针和值。
如果不考虑实际使用,从理论上说,指针类型跟别的整型一样,也是一个『可操作实体』,所以它也是值。在Go 里,指针跟所有值一样,赋值和参数传递的时候发生了拷贝。
但在使用中,大部分情况下,指针只是改善性能(避免拷贝)、提高代码灵活性(共享对象)、实现复杂数据结构的工具。我们并不关心指针的值本身,而是关心指针指向的值。为了方便讨论,指针变量跟它指向的值,常常会被等同看待。就像送礼或者颁奖时,不会有人举着汽车交给对方,而是会递交车钥匙;我们会将拿到车钥匙等同于拿到了车。(特别是 Go 取消了箭头操作符 ->
,值和指针都用同样的方式访问成员,更是弱化了这个区分。)
几乎没有人会关心指针保存的地址值是多少,只会关心它是否有效,两个地址是否相等。地址的大小对于程序逻辑几乎没有影响。
当强调 指针 和 值 的区别时,这里的值,就是指我们关心的,可以直接使用的值。
实际上,这些区别同样存在于 引用 和 值 之间。只是指针的机制更简单透明,所以用了指针作为讨论的对象。
引用(reference)是指可以让程序间接访问其它值的值。指针是最简单的、透明的引用,也因为其机制透明和自由使用,是最强大有效的引用。
但透明和自由,也要求使用者更了解底层细节,程序更容易出错。想降低使用难度,避免出错,就加上限制,屏蔽底层细节,变成不透明引用。例如,无法获取引用真实的值,无法控制引用的解释,强制的类型安全,禁止类型转换,甚至让它看起来像一个直接访问的值,不像引用。
当我们将 指针 和 引用 并列时,指的就是不透明引用。
来看看其它语言的情况:
C++ 既有指针也有引用。C++ 的引用更接近别名(alias),是受限的指针(不能读取或修改地址值,也不需要显式的解引用,所有操作都作用于指向的值)。
Python 和 Java 都取消了指针,只保留了引用。Java 的基本类型是直接值,除此以外都是引用。Python 更彻底,一切皆对象,所有变量都是对象的引用。所以它们在赋值和传递时,没有拷贝对象,只拷贝引用。如果需要拷贝对象,就需要显式地调用拷贝函数或者克隆方法。一些 Python 教程很形象地称这种引用为『贴标签』。
Go 语言的引用,不像一般意义上的引用。
其它语言的不透明引用,是一种语言级别的统一机制,是作为指针的替代方案出现的。
Go 的引用,则是在已经有了 直接值 和 指针 的前提下,针对特定类型的优化:为了兼顾易用性和性能,针对具体类型,在 值 和 指针 之间折中。每种引用类型,有自己独特的机制。一般是由一个结构体负责管理元数据,结构体里有一个指针,指向真正要使用的目标数据。
这种东西,如果在 C++ 或者 Java 里,就是一个官方提供的类(如 Java 的 String
类),可以看到它的内部机制。而 Go 引用的实现逻辑却内置在 runtime 里,不仅无法直接访问元数据,还表现得像在直接操作目标数据。你会以为它是个普通的值,直到某些行为跟想象中不一样,才想起了解它的底层结构。如果不去看 runtime 的源码,这些元数据结构体仿佛不存在。
Go 的引用类型有:
字符串 string
:底层的数据结构为 stringStruct
,里面有一个指针指向实际存放数据的字节数组,另外还记录着字符串的长度。不过由于 string
是只读类型(所有看起来对 string
变量的修改,实际上都是生成了新的实例),在使用上常常把它当做值类型看待。由于做了特殊处理,它甚至可以作为常量。string
也是唯一零值不为 nil
的引用类型。
切片(slice):底层数据结构为 slice
结构体 ,整体结构跟 stringStruct
接近,只是多了一个容量(capacity)字段。数据存放在指针指向的底层数组里。
映射(map):底层数据结构为 hmap
,数据存放在数据桶(buckets)中,桶对应的数据结构为 bmap
。
函数(func):底层数据结构为 funcval
,有一个指向真正函数的指针,指向另外的 _func
或者 funcinl
结构体(funcinl
代表被行内优化之后的函数)。
接口(interface):底层数据结构为 iface
或 eface
(专门为空接口优化的结构体),里面持有动态值和值对应的真实类型。
通道(chan):底层数据结构为 hchan
,分别持有一个数据缓冲区,一个发送者队列和一个接收者队列。
这些类型在直接赋值拷贝的时候,都只会拷贝它们的直接值,也就是元数据结构体;间接指向的底层数据,是在各个拷贝值之间共享的。除非是发生了类型转换这样的特殊情况。
如果觉得不好记忆,有一个识别引用类型的快捷办法:凡是零值是 nil
的,都是引用类型。指针作为特殊的透明引用,一般单独讨论。而 字符串 string
因为做了特殊处理,零值为 ""
,需要额外记住。除了引用类型和指针,剩下的类型都是直接值类型。
那些说引用类型只有需要 make()
的切片、映射、通道 三种的说法,是错误的!
如果不记得都有哪些类型,零值是什么,可以看第 3 期《类型》。或者看下图的整理:
由于每一个类型的实现机制都有所不同,具体细节留到介绍这些类型时再讨论,不在这里展开。感兴趣可以到 go目录/src/runtime
下看源码(每个类型有自己单独的文件,如 string.go
,个别没有单独源码的,在 runtime2.go
里面)。
需要注意的是,Go 通过封装,刻意隐藏引用类型的内部细节。隐藏细节,意味着没有对这些细节作出承诺,这些细节完全可能在后续版本中变更。实际上这样的变更已经发生过。了解这些细节,是为了更好理解类型的一些特殊行为,而不是要依赖于这些细节。(考虑到海勒姆定律,这些细节最终还是会被一些程序依赖。)
由于『引用类型』这个术语边界不明,特别是 Go 的实现方式跟其它语言存在差异,在表述上常常会造成混乱和误解,go101 的作者老貘推荐在 Go 里改为使用『指针持有者类型』来代替。新术语是指一个类型要么本身就是一个指针,要么是一个包裹着指针的结构体,它的变量本身是一个直接值,这个值另外指向间接的值。当赋值或传参发生拷贝时,只拷贝了直接值部分,间接值被多个直接值共享。
这种提法提供了新的理解角度。但我仍然使用『引用类型』这个术语,是想强调这些类型的不透明属性。它们由 runtime 内置,其元数据和实现机制被封装隐藏。按照『指针持有者类型』的定义,我们也可以自行实现一个包裹指针的结构体。但这种结构体跟普通结构体没有什么区别,runtime 不会对它做特殊处理。
因为指针和引用本质上也是值,字面意义上,Go 里面所有传递都是值传递。这句话正确却没有指导意义。
Go 里的赋值和传参,总是会把传递的值本身拷贝一份。但如果这个(直接)值指向别的(间接)值,它所指向的(间接)值不会发生递归拷贝。就好比把大门钥匙多配一把交出去,而不是新建一模一样的房子。
因为这个特性,加上前面介绍的 直接值 、不透明引用 和 指针 的区别,这三种传递在使用上是有区别的。区分也很简单,赋值和参数的类型是什么类型,就是对应的传递方式。
(直接)值传递:值发生了拷贝。对新值的任何修改,都不会影响原来的值。
除非这个值是一个结构体,结构体成员字段里有引用类型或者指针,那么对这个字段而言,则是引用传递/指针传递。
引用传递:元数据发生了拷贝,但底层的间接值没有拷贝,仍然共享。
对间接值的修改,会影响所有副本。(如,修改切片里的某个元素,就是修改了底层数组里的某个元素)
但对元数据的修改则不会影响其它副本。(如,对切片提取子切片,实际上修改了切片的访问范围)
有一种特殊的情况,就是修改元数据时改变了指向的间接值的指针,这之后对间接值的修改,都不再会影响其它副本。因为不再共享间接值。(如,对切片追加元素时,促发了底层数组的重新分配,指向了新的底层数组)
指针传递:指针值(地址)发生了拷贝,共享指向的值。对间接值的修改,会影响所有副本。由于 Go 不允许对指针进行运算,不存在意外改变指针的情况。而如果是给指针赋新的值,后续的修改当然不再影响旧值指向的值。由于指针的机制透明,这点很好理解。
因为指针本身也是一种引用,本来指针和引用可以合并讨论。但由于引用屏蔽了实现细节,使得程序员不一定知道对引用的操作,作用的具体是哪一部分,也就比透明的指针多了更多的意外情况需要指出。
以下代码有 8 个真假判断,请在不运行的情况下,判断 true 还是 false,并说出理由。
|
|
满分:无需运行代码,全部判断正确。
优秀:有个别判断不确定,但看到运行结果可以推断出原因。
及格:有比较多的判断不确定,但在输出数组/切片元素(注释掉的代码行)之后能说出原因。
加把劲:即使看到元素输出,还是云里雾里。
对于从头开始学习的朋友来说,即使感觉云里雾里也不要紧,因为练习题不可避免地涉及到下一期要讨论的 数组 和 切片。如果之前没有了解,判断不了也是正常。这道题既是这期的课后练习,也可以理解为下期的课前预习。
答案和解析会在下期公布。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
本文写于 3 月,只是因为各种原因一直没写完发表。
太长不看
- 凤梨就是菠萝,没有任何区别。叫法的区分更多是商业上的品牌营销。
- 菠萝扎嘴可能是蛋白酶、草酸钙结晶和粗纤维划伤综合后的结果。
- 但无论是哪种因素,泡盐水都起不了什么作用。
- 除了买熟透的菠萝,最好的办法是加热。除了做成佳肴,还可以选择炙烤,给菠萝增加一道焦糖风味。
事情发生之后,大陆这边影响不大,因为本身海南和广东很多地方都产菠萝。其中徐闻占据国内约 40% 的产量。徐闻菠萝还趁热度做了一波营销,知名度和价格都打了翻身仗。
但对台湾地区来说,影响就比较大。销往大陆的菠萝,虽然只占产量的10%,占总贸易量的比例也不大,却占据菠萝外销量的 97%。被禁会对菠萝价格产生很大的冲击,首当其冲受到影响的就是果农的收入。岛内媒体讨论得热火朝天,然后就有『名嘴』说出了『每人每天18公斤,四五天就解决了』的知名言论。
这种言论当然不能当真。稍微了解过对岸媒体言论的人都知道,因为媒体竞争激烈,加上没有相应的管治措施,那些看似正经的电视节目上都总是语不惊人誓不休,为收视率什么话都敢说。这些节目更接近大陆这边的朋友圈标题党,而不是看起来对标的卫视节目,明白这点就好理解多了。
虽然言论不靠谱,但作为一个菠萝爱好者,就让我也标题党一把,蹭蹭这(过时的)热度,聊聊菠萝该怎么吃。
你可能会说,等等,台湾的不是凤梨吗?
台湾产的是凤梨,也是菠萝。因为凤梨就是菠萝,两者没有本质区别。
你说不对啊,我看网上的文章说,菠萝有『钉』,凤梨『钉』很小或没有;菠萝『扎嘴』要泡盐水,凤梨不用泡……
说来话长,让我『稍微』解释一下。
菠萝是属于禾本目(Poales) 凤梨科(Bromeliaceae) 凤梨属(Ananas) 菠萝种(Ananas comosus) 植物的果实,原产南美洲。在欧洲人踏足南美洲之前,菠萝已经有多个世纪的种植历史。据《拉鲁斯美食大全》所说,菠萝在 15 世纪被哥伦布带回欧洲。也有说法菠萝在 16 世纪之后才传到欧洲(哥伦布死于 1506 年,也就是16 世纪初)。
无论菠萝是不是由哥伦布本人或他的船队带回欧洲,菠萝传播到欧洲大概率还是可以归功于哥伦布的航行。自哥伦布发现新大陆之后,地球上各个大陆之间的交流剧增,这里面包括自然生物、农作物、人种、文化、传染病乃至思想观念,对世界历史的走向产生了深远的影响,史称『哥伦布大交换』。
很多我们早已习以为常的作物,包括玉米、辣椒、番茄、番薯(地瓜)、棉花、花生、(番)木瓜、土豆、南瓜、草莓、烟草……(名单太长不列了),就是在这个过程里传到欧亚大陆。(我看宋代的剧为什么会看到玉米地啊(╯‵□′)╯︵┻━┻)
由于欧洲的气候并不适宜种植菠萝,在很长一段时间里,菠萝是只有皇室贵族才有机会接触到的水果,代表着异国情调、权力与财富。这也是菠萝在欧洲传播非常缓慢的原因,使得菠萝在欧洲不同地方的记载,传入时间差距很大。即使后面好不容易发明了温室种植法,菠萝的种植成本依然很高(温室内要一直烧炭火保温保湿),只是从贵族扩展到了富人阶层。
这就导致了一些现在看来匪夷所思的行为。各种绘画、建筑、装饰里出现菠萝作为权力和财富的象征,还好理解;皇室和贵族在宴席上放几个菠萝,但往往只是装饰,并一定会吃掉,也就算了。为了模仿皇室的宴会,普通人开始租(对,你没看错,租)菠萝在宴席上撑场面,吃是不可能吃的,吃掉了拿什么还。
直到现在,新鲜菠萝在欧洲很多地方还是少而贵,属于小奢侈的水果。日本的情况也类似。小时候看动画,主人公往往要碰上重要场合收到菠萝当礼物(很多年没见的叔叔来看我),才有机会吃到一个菠萝,吃的过程非常郑重,充满了仪式感。
我生长于广东十八线小县城,菠萝成熟的时候,街上到处有小摊贩在卖,几块钱一个,几毛钱一块,即使当时家里比较拮据,偶尔几块菠萝还是吃得起的。怎么发达国家反而吃不起的样子?对此非常疑惑。
按网上找到的说法,菠萝大概是跟随葡萄牙人,在明末 16 到 17 世纪之间传到广东。流传最广的说法,是先到澳门,再往不同方向传播,在清朝康熙年间传到台湾。
因为传播路径复杂,又是舶来品,在结合当地人的认知和方言之后,菠萝有了一大堆的名字。抛开那些故纸堆里的(疑似)曾用名,现在还在用的名字有 菠萝(粤语)、番梨(潮州话)、黄梨(客家话、东南亚华人)、王梨(闽南地区,谐音旺来)、凤梨(台湾地区)。
这些名字可以很容易地分成两类:菠萝,以及 X梨。对于 某梨的叫法,虽然第一个字不同,但在各自的方言里,发音都非常接近。我们可以合理地推测,这两类命名是两个不同传播路径产生的。
在粤语(更准确说是广府话)地区以及往西传播的过程中,明显是因为长得有点像,参考了同样是舶来品的波罗蜜(隋唐时从印度传入中国,叫『婆那娑』,宋改称波罗蜜,这明显是受到了佛教的影响),称作波罗,后来强调是植物,加上了草字头。有趣的是,后来菠萝在广东变得更为普遍,波罗蜜反而少见一些,有些地方开始称波罗蜜为『树菠萝』。不过波罗蜜仍然是正式名称。
而在一系列 X梨的路径里,无论从传播顺序,还是含义的演变看,番梨都更像是开始的命名。外来的事物,称番,像番茄、番石榴、番木瓜。但这个番字,在天朝上国华夷之辩的思想里,暗含贬义。在继续传播的路上,越来越本土化,大家会有意无意回避『番』字,给出相近发音下,其它『合理化』的雅称:果肉是黄色、吃了旺来、叶子像凤尾……
当然,也可能不是这样的顺序。但相邻的方言区,不约而同地用相近的发音称呼,不管是谁影响谁,无疑是一个来源。另一个证据是,包括台湾的地方府志在内,历史上这些地区的记录里,菠萝的称呼并不固定,几个叫法都有出现过。台湾到了后面才逐渐固定使用凤梨这个名字。
大陆这边,在罐头生产和生鲜运输把菠萝送出两广(包括建省前的海南)和福建地区之前,其他大部分地区对这个水果是没有概念的。到后来全国各地可以吃到菠萝的时候,一方面粤语区的产量比较大,另一方面珠三角的经济发达影响力强,菠萝就逐渐成为通用的中文名。
菠萝在分类学上的科和属,是直接借用日本学者定的科名、属名。而对于日本来说,凤梨科的植物都是外来物种,命名明显受到台湾地区的影响。
如果只是到这里,那么菠萝、番梨、黄梨、王梨和凤梨,都只是同一种水果在不同地方的俗称。此时的称呼差别,更多与民俗方言相关,并非以海峡为界,更没有没有涉及品种差异。除了粤语区比较固定地称为菠萝,粤东、闽南和台湾整个广义的闽南方言区,几个 X梨 的称呼很长一段时间都有混用。引种东南亚品种,还有各种品种改良,是在不同称呼之后的事情。
潮汕-闽南地区,同时受到两边的影响,既有跟粤语区叫菠萝的地方,也有叫王梨、凤梨的时候;连海峡对岸,也有称呼菠萝的时候。而脱胎于闽南式婚嫁礼饼的凤梨酥,无论在岛内还是在闽南地区,无论馅料用的凤梨还是菠萝,都不妨碍它按照习惯叫凤梨酥。
直到后来,台湾经过多年的农业技术研究,培育出多个高甜少刺的特色菠萝品种,才开始专门区分,将这些品种称为凤梨,而将进口菠萝和纤维较粗的『开英种』和『本岛仔凤梨』称为菠萝。这更多是一种出于商业考量的品牌打造,无可厚非。早年台湾地区比大陆发达,是大陆羡慕的对象,将『凤梨』跟高甜少刺、发达地区的高大上绑定,以售出高价,是成功的营销。但为了保持这种高大上,刻意区分,否认凤梨也是菠萝,就属于混淆视听了。
这跟『樱桃』和『车厘子』还不完全一样。因为它们两者是同为李属(Prunus) 下的不同种(species),我们一般所说的樱桃指『中国樱桃』(Prunus pseudocerasus) ,而车厘子指『欧洲甜樱桃』(Prunus avium)。它们分别都是当地的原生物种,刻意区分可以理解,不光是翻译的差异。
菠萝和凤梨更接近『猕猴桃』和『奇异果』的关系。中国就是猕猴桃的原生地。奇异果则是新西兰人用湖北带回的种子,一代代培育而成的。经过培育之后,奇异果的差异已经比较大,商业上当然可以专称以示区分,但不可以否认奇异果就是猕猴桃的一个品种(breed)。
相信你已经明白,凤梨就是菠萝。只是叫凤梨的,大概率是台湾的品种。大陆也有自己的改良品种,也有引种台湾的优良品种,视乎宣传需要,有叫菠萝的,也有叫凤梨的。
那为什么有些菠萝吃之前要泡盐水,有些不用呢?
大家的回答普遍是:『扎嘴』,泡盐水可以缓解。
要是你继续追问,为什么会扎嘴,又为什么泡盐水可以缓解,大家就不怎么答得上来了。
实际上,就这么『简单』的问题,也没有一个非常确切的答案。根据可以查到的资料,扎嘴的原因按出现频率从高到低分别是:
这是最广泛的说法。大部分情况下也是唯一的解答。
菠萝含有多种蛋白酶,可以分解蛋白质。我们的细胞组织也是由蛋白质构成,其中包括黏膜细胞。所以吃完菠萝之后,蛋白酶会分解一小部分接触到的蛋白质,造成我们的舌面和口腔黏膜损伤。形象地说,就是我在吃菠萝的时候,菠萝也在吃我。
菠萝本身糖分和有机酸含量很高,又会进一步刺激到这些小伤口,于是就会产生刺痛感。
除了菠萝以外,很多水果都含有蛋白酶,包括木瓜、猕猴桃等。利用这个特性,用果汁腌肉有嫩肉的效果。市售的嫩肉粉,一部分的原材料就是木瓜粉。
如果腌肉时放几块新鲜的菠萝,一不小心忘冰箱里过夜,那么第二天很可能会得到一份糨糊。
有人说,不对啊,酶反应需要时间,但我吃菠萝是入口就感觉到刺痛。另外,有蛋白酶的水果不在少数,为什么很少听说别的水果扎嘴呢?
于是有人提出了另外一种可能:菠萝中含有的草酸钙(针状)结晶造成了刺痛。与此类似还有菠菜和芋头。我没生吃过菠菜和芋头,但是削过芋头后那手确实又痒又痛。
可以划伤刺痛黏膜的,除了草酸钙针晶,还有可能是一些菠萝品种的粗纤维。
以上三点,究竟哪个是真正/主要原因,至少我没有看到一个决定性的结论。我和稀泥地倾向于,都起了一定的作用,但是所占比例未知。
毕竟扎嘴是一个很主观的感觉,每个人对不同因素造成的扎嘴耐受程度也不同。
受益于良种选育和保鲜技术的发展,现在的菠萝含有更少的蛋白酶和草酸钙,果肉纤维更细,扎嘴的困扰就会少一些。
想不扎嘴,首先就要挑选这些不扎嘴的名优品种。这样看,贵价的凤梨貌似还是有一些价值的。我也见过网上有卖主打不用泡盐水直接吃的手撕菠萝。不过我没试过,大家自己决定要不要相信。
其次,尽量挑选成熟的菠萝,蛋白酶、草酸钙和粗纤维会少一些。一个技巧是,其它条件一致的前提下,尽量挑选产地比较近,运输时间短的产品,因为运输时间越长,果农越是需要提前采摘,让果实在运输途中放熟而不是放烂。放熟和自然成熟差别还是比较明显的。
但如果说因为条件限制,没得挑,菠萝已经买好了,还有没有办法降低扎嘴的程度呢?泡盐水吗?
很遗憾,盐水的作用可能只是聊胜于无。
酶首先是一种蛋白质(但它不会分解自己)。要想蛋白酶失活,可以是加热到一定温度,可以是加入重金属,或者其他毒素、强辐射等。
单靠氯化钠溶液的话,首先需要浓度很高,至少需要 7% 以上,这样的盐水需要非常咸。其次需要泡很长时间,让盐水渗透到果肉内部,而不是停留在表面。
最关键的是,蛋白酶仅仅是因为盐析作用溶解度降低而无法作用,这个过程是可逆的。只要盐水的浓度下降(例如被你分泌的唾液稀释),蛋白酶又可以重新起作用。
而对于草酸钙结晶和粗纤维,我完全看不出盐水能起什么作用。可能就是一个生理盐水缓解不适的作用。
于是乎就出现了,盐水不是用来防扎嘴,而是用来凸显甜度的言论。你看,玄学开始出现了。
既然盐水不靠谱,又不能往食物里下毒,最简单有效的方法其实是:加热!
大部分蛋白加热到 70 度以上 5 分钟(或者更高温度并缩短时间),都会发生不可逆的变性。这里面当然包括娇贵得很的蛋白酶。
草酸钙也会在高温下失去结晶水,变成无水草酸。(不过这个温度要高一些,可能需要达到 100 度)
至于粗纤维,一般的加热是没什么效果的。如果不巧买了一个像甘蔗一般的菠萝,又不想丢弃,可以试着用高压锅压一下。
一旦打开了加热这道大门,路子就宽多了。
加热过后不仅不必再担心扎嘴问题,原本硬邦邦的果肉也会变得柔软,味道得以浓缩,甚至还能在烹饪过程中加入各种风味,乃至成为一道甜品或者佳肴。
直接吃的情况,考虑到水煮会稀释菠萝的风味,推荐你直接切片炙烤。
家用电烤箱温度往往不够高,导致加热时间过长,没办法产生焦糖风味不说,还会出很多水。
条件允许还是更建议用铸铁锅烤(grilled),没有的话,用平底锅大火煎也可以。
不仅不会稀释,还会在烤的过程中让菠萝失水,让风味浓缩;如果温度合适,甚至还会产生迷人的焦糖香气。
汉堡王就曾经推出一系列烤菠萝片的产品,风味非常独特。
需要提醒的是,舌头在高温下对甜味比较迟钝。菠萝有机酸含量很高,烤过的菠萝趁热吃,会因为甜味变得不明显,而显得非常酸。耐酸的朋友不妨趁热尝试一下这独特的风味。怕酸的朋友则不妨等放凉乃至冷藏之后再吃,并不会折损它浓缩过的风味。
直接炙烤只是入门。如果你能接受西式甜品里肉桂和糖分碰撞的味道,那不妨在高温的烤架或者铸铁锅上,再撒上一小撮肉桂粉。
除此以外,其它西式甜点里会用到的香料,都不妨拿来试一下。其实就是参考菜谱,把菠萝当成甜品的主角去装扮。
如果觉得西式甜品的香料过于黑暗无法接受,那不妨试试粤菜里对菠萝的用法。
随便一搜菜谱,菠萝炒饭、菠萝咕噜肉、菠萝炒肉、菠萝糯米饭、菠萝派……总有一款适合你。
菠萝酸甜可口,香味浓郁,又有那么多膳食纤维、维生素和微量元素,我真的非常喜欢吃,直接秒掉一整个都毫无压力。
但是如果让我一天吃掉 18 公斤,那是不可能的,这辈子都不可能一下子吃 18 公斤的,加上焦糖风味也不行。
首先是蛋白酶的问题。少量蛋白酶问题不大,到消化道碰上胃酸就变性失活了,只是嘴巴遭点罪。可是如果是连续几公斤菠萝的蛋白酶,就不是一回事了,吃得太多可能会引起消化道溃疡。
你说看完这篇文章,学会了加热再吃,不怕蛋白酶。草酸钙仍然是一个问题,加热只是破坏了结晶,草酸本身还在。一下子摄入过多草酸,会引起各种健康问题。一个最直接的后果,是可能引起高草酸尿症,继而引起急性肾损伤,严重时可以致命。
除此以外,菠萝的含糖量非常高,每 100g 菠萝含糖量可以达到 12g 以上,比很多水果都要高,只是因为有机酸含量也高,让它尝起来没那么甜。短时间内吃太多菠萝,等于摄入大量糖分,会引起各种健康问题。
所以,菠萝虽好,不能贪吃啊。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。 本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。 请点击查看协议的中文摘要。
]]>它不在原本的规划之内。
但随着内容的深入,程序变得越来越复杂,我们将不可避免地会遇到 bug,需要调试,需要(往 console 或 日志)输出调试信息。这时数据的格式化输出变得尤为重要。
实际上,前面已经多次用到了格式化。与其每次用到零碎地介绍,不如集中一期整理好。
介绍、翻译、注释、举例,内容有点多,不必全篇记忆。记住常用部分,剩下的留个印象,需要时回来翻阅就好。
[TOC]
格式化的功能,主要在 fmt
包内,fmt
是 format 的略写。
当然,除了临时的简单调试,直接用 fmt
输出到终端(terminal)来调试不太规范。标准输出的内容非常容易丢失,还是写入日志文件方便事后对比分析。
更多的时候,我们会用各种日志库来输出。但这些日志库,要么底层还是调用了 fmt
,要么自己实现的格式化也会尽量和 fmt
兼容。所以学习格式化仍然是必要的。下面主要的内容均来自 fmt
包。
注:print 对应的中文翻译应为 印刷、打印。
但在当前上下文中,print 并非指将内容打印到纸张等介质。而是指的是将各种数据,按照某种格式,转换为字符序列(并输出到抽象文件)的过程。
所以为了方便理解,我将其替换成了『输出』,请读者知悉。
fmt
包中名字里带 Print
的函数很多,但无非是两个选项的排列组合。理解了每个部分的含义,一眼就能明白函数的用途。
前缀代表输出目标:
Fprint
中前缀 F
代表 file ,表示内容 输出到文件。
当然这里的文件是抽象的概念,实际对应的是 io.Writer
接口。Fprint
开头的函数,第一个参数总是 io.Writer
。通过传递不同的文件给函数,可以把内容输出到不同的地方。
常见的用法,是打开一个文件,将文件对象作为第一个参数,将内容输出到该文件。当然,不要被 文件 这个词误导了,抽象的文件可以是任意的字节流(stream)。具体到这里,只要是可写入的对象(带 Write([]byte)(int, error)
方法),都满足 io.Writer
接口。
Print
(没有前缀)表示内容 输出到标准输出,也就是 控制台(console)或者叫终端(terminal)。
实际上调用的是 Fprint(os.Stdout, a...)
,换言之背后指定输出的文件为标准输出。
Sprint
中前缀 S
表示 string ,表示内容 输出到字符串 ,然后将字符串返回。
后缀表示格式:
Print
(没有后缀),表示输出时格式不进行额外的处理。
也就是按参数的 默认格式 ,顺序输出。
Println
的后缀 ln
代表 line ,表示按行输出。
实际上它只是比 Print
的多做两件事:所有参数之间增加一个空格;输出的最后会追加一个换行符。
Printf
的后缀 f
代表 format ,表示格式化输出。
第一个参数是 格式化字符串 ,通过里面的 格式化动词(verb) 来控制后续参数值的输出格式。
直接看代码:
|
|
输出:
|
|
给三个函数都输入 5 个参数
Print
将 5 个参数的值以默认格式依次输出,每个值中间没有加分隔符,末尾也没有换行。(因为没有换行,这里特意加了一个句点 .
方便区分不同函数的输出)Println
同样以默认格式输出,只是增加了空格分隔不同的值,并且末尾增加了换行。Printf
的第一个参数跟其它参数有所区别,必须是格式化字符串(format specifier)。后续参数跟字符串里的格式化动词一一对应,按照动词指定的方式,格式化后填入对应的位置,再一起输出。接下来,重点就是这些结尾带 f
的函数里面,格式化动词的使用。为了跟格式化字符串里一般的内容区分开来,格式化动词以百分号 %
开头,后面接一个字母表示。有时候为了更精确地控制格式,在百分号和字母之间还会可能会有标志选项(如整型数填充前导零,浮点数控制小数点的位数)。
在不是特别严谨的语境,动词 可以是指由 百分号(%)、标志选项(可选)、字母 这三者组合的整体。但更严谨地说,动词特指后面的字母。理解这一点有助于读懂下面的文档。
下面直接 选译/注释 文档中关于格式化动词的部分:
(部分格式与 Go 的版本有关,这里选译的是当下最新的 1.16 版本)
fmt
包实现了格式化输入输出(I/O),其功能类似于 C 语言的 printf
和 scanf
。格式化 动词(verbs) 是从 C 语言的动词中衍生出来的,但更简单。
动词:
|
|
注:只看介绍,所谓输出 “ Go 的语法表示” 并不直观。实际上这是指一个值在代码里的字面量形式。
对于输出值和字面量一样的类型(布尔类型、数字类型),没有差别;对于字符串,“语法表示意味着带上引号;对于剩下的派生类型,意味着语法表示需要包含类型信息。
看几个例子:
|
|
|
|
|
|
|
|
注:特别说明一下
%c
和%q
。首先需要注意到,自 1.9 以后,
byte
类型实际上是uint8
的别名(alias),rune
则是int32
的别名。这意味着如果以
%v
输出,这两个类型都会被当做数字输出。想要输出对应的字符,就要考虑使用
%c
。
%q
也是输出字符,只是有两点区别:
- 带单引号
- 对于不可打印字符(non-printable characters,不过叫『不可见字符』更容易理解),会按 Go 语法进行转义。
举例说,对于字母 A,
%c
输出A
,%q
输出'A'
;中文也是类似效果。而对于换行符,对应一个换行的动作,而不是一个可以看得见的字符,用%c
输出会得到一个换行,用%q
输出则得到'\n'
(得到一个转义)。两者的区别跟
%v
与%#v
的区别比较类似。
(Floating-point and complex constituents)
|
|
注:这部分的个别动词,在输出时可能同时混用 二进制、十进制和十六进制,记忆起来会比较混乱。如
%x
,实数(又叫尾数)为十六进制,底数为 2,指数却又是十进制。建议大家自己在代码里实际尝试,加深印象。还好如果不是涉及特殊数值的运算和表示,特殊的动词一般用得不多。日常表示浮点数,掌握
%f
,%e
和%g
就够了。关于浮点数的多种字面量表示方法,可以参考往期的内容 Go 语言实战(2): 常量与变量 中,浮点数字面量部分。
(对以下动词而言两者等价)
|
|
注:想理解何为 uninterpreted,先要理解何为 interpreted。
对于脚本语言,解释器就叫 interpreter;分析或执行读入的内容,得到结果的过程,就是解释 interpret。如解释
1 + 2
,得到3
。在这里,对于字符串(字符序列)而言,解释主要是指字符转义。
%s
动词不会对字符序列的内容进行转义。但这里有一个非常容易让人迷惑的点,看下面例子:
|
|
输出
|
|
第一个例子很容易让人以为
%s
还是发生了转义。实际上转义发生在源码编译阶段,而不是输出阶段。也就是对于双引号字符串,编译器已经对其完成了转义。
str1
储存在内存里的内容,是 [‘1’, 9, ‘2’, 10, ‘3’] ,其中 9 就是制表符的 ascii 码,10 是 换行符的 ascii 码。这里已经找不到 反斜杠、字母 t 和 n 了。再看接下来的两个例子就很好理解了。反引号字符串告诉编译器不要转义,字节切片则直接逐个指定每个字节的内容,所以
str2
和str3
的字节序列里,储存的就是字面意义的 “\t” 和 “\n” 。当然还有更直观的方式,可以看出字节序列的不同:
|
|
输出:
(具体每个十六进制数对应的字符,这里就不再解释了,反正不同是非常直观的)
|
|
|
|
|
|
|
|
对于复合对象,将根据这些规则,递归地打印出元素,像下面这样展开:
|
|
宽度由紧接在动词前的一个可选的十进制数指定。如果没有指定,则宽度为表示数值所需的任何值。
精度是在(可选的)宽度之后,由一个句点(.
,也就是小数点)和一个十进制数指定。如果没有句点,则表示使用默认精度。如果有句点,句点后面却没有数字,则表示精度为零。例如:
|
|
宽度和精度以 Unicode 码点为单位,也就是 runes。(这与 C 语言的 printf
不同,后者总是以字节为单位。)标志中的任意一个或两个都可以用字符 *
代替,从而使它们的值从下一个操作数获得(在要格式化的操作数之前),这个操作数的类型必须是 int
。
注:
*
的用法并不直观,举个例子就很好理解。
fmt.Printf("%*.*f", 6, 3, 4.5)
输出
4.500
(注意 4 前面有一个并不明显的空格,加上数字和小数点,宽度正好为 6 )
对于大多数的值来说,宽度是要输出的最小符号(rune)数,必要时用空格填充。
然而,对于 字符串、字节切片 和 字节数组 来说,精度限制了要格式化的输入长度(而不是输出的大小),必要时会进行截断。通常它是以符号(rune) 为单位的,但当这些类型以 %x
或 %X
格式进行格式化时,以字节(byte)为单位。
对于浮点值,宽度设置字段的最小宽度,精度设置小数点后的位数;但对于 %g
/ %G
,精度设置最大的有意义数字(去掉尾部的零)。例如,给定 12.345
,格式 %6.3f
打印 12.345
,而 %.3g
打印 12.3
。%e
、%f
和 %#g
的默认精度是 6 ;对于 %g
,默认精度是唯一识别数值所需的最少数字个数。
注:关于如何精确控制浮点值的宽度和精度,这段说明看似说清楚了,实际执行中却常常让人迷惑。看网上的讨论,已经有很多人在诟病这一点。跟更早的文档相比,现在的版本好像已经调整过表述,但是帮助有限。
如果你需要精确控制以达到排版对齐一类的目的,可以参考这个讨论 https://stackoverflow.com/questions/36464068/fmt-printf-with-width-and-precision-fields-in-g-behaves-unexpectedly
讨论篇幅过长且拗口,不再翻译。总的来说,精度控制有效数字,但因为有效数字不包括小数点和前导零,带前导零和小数点的数会更长;宽度控制最小宽度,在长度不足时会填充到指定宽度,但超出时并不会截断,总位数仍然可能超出。最后你可能需要制表符
\t
来帮助对齐。
对于复数,宽度和精度分别应用于两个分量(均为浮点数),结果用小括号包围。所以 %f
应用于 1.2+3.4i
输出 (1.200000+3.400000i)
。
|
|
动词会忽略它不需要的标志。例如十进制没有备选格式,所以 %#d
和 %d
的行为是一样的。
对于每个类似 Printf
的函数,都有一个对应的 Print
函数,它不接受格式,相当于对每个操作数都应用 %v
。另一个变体 Println
在操作数之间插入空格,并在结尾追加一个换行。(注:这个我们在开头就已经讨论过)
无论用什么动词,如果操作数是一个接口值,则使用内部的具体值,而不是接口本身。因此:
|
|
会输出 23
。
除了使用动词 %T
和 %p
输出时,对于实现特定接口的操作数,需要考虑特殊格式化。以下规则按应用顺序排列:
如果操作数是 reflect.Value
,则操作数被它所持有的具体值所代替,然后继续按下一条规则输出。
如果操作数实现了 Formatter
接口,则会被调用。在这种情况下,动词和标志的解释由该实现控制。
如果 %v
动词与 #
标志 (%#v
) 一起使用,并且操作数实现了 GoStringer
接口,则该接口将被调用。
如果格式 (注意 Println
等函数隐含 %v
动词)对字符串有效 (%s
, %q
, %v
, %x
, %X
),则适用以下两条规则:
error
接口,将调用 Error
方法将对象转换为字符串,然后按照动词(如果有的话)的要求进行格式化。String() string
方法,则调用该方法将对象转换为字符串,然后按照动词(如果有的话)的要求进行格式化。对于复合操作数,如 切片 和 结构体,格式递归地应用于每个操作数的元素,而不是把操作数当作一个整体。因此,%q
将引用字符串切片中的每个元素,而 %6.2f
将控制浮点数组中每个元素的格式。
然而,当以适用于字符串的动词(%s
, %q
, %x
, %X
),输出一个字节切片时,它将被视为一个字符串,作为一个单独的个体。
为了避免在以下情况出现递归死循环:
|
|
在触发递归之前先转换类型:
|
|
无限递归也可以由自引用的数据结构触发,例如一个包含自己作为元素的切片,然后该类型还要有一个 String
方法。然而,这种异常的情况是非常罕见的,所以 fmt
包并没有对这种情况进行保护。
在输出一个结构体时,fmt
不能,也不会,对未导出字段调用 Error
或 String
等格式化方法。
在 Printf
, Sprintf
和 Fprintf
中,默认的行为是,每个格式化动词对调用中传递的连续参数进行格式化。然而,紧接在动词前的符号 [n]
表示第 n 个单一索引参数将被格式化。在宽度或精度的 *
前同样的记号,表示选择对应参数索引的值。在处理完括号内的表达式 [n]
后,除非另有指示,否则后续的动词将依次使用 n+1、n+2等参数。
举例:
|
|
将输出 22 11
。 而
|
|
等价于
|
|
将输出 12.00
(注意 12 前有一个空格)。
因为显式索引会影响后续的动词,所以这个记号可以通过重置索引为第一个参数,达到重复的目的,来多次打印相同的数值:
|
|
将输出 16 17 0x10 0x11
。
如果给一个动词提供了无效的参数,比如给 %d
提供了一个字符串,生成的字符串将包含对问题的描述,像以下这些例子:
|
|
所有的错误都以字符串 %!
开头,有时后面跟着一个字符(动词),最后以括号内的描述结尾。
如果一个 Error
或 String
方法在被输出例程调用时触发了 panic ,那么 fmt
包会重新格式化来自 panic 的错误消息,并在其上注明它是通过 fmt
包发出的。例如,如果一个 String
方法调用 panic("bad")
,则产生的格式化消息看起来会是这样的
|
|
%!s
只是显示失败发生时使用的打印动词。然而,如果 panic 是由 Error
或 String
方法的 nil 接收者(receiver)引起的,则输出的是未修饰的字符串 <nil>
。
实际上,这一套函数的命名规则和格式化动词,基本继承自 C 语言,只是做了少量的调整和改进。有 C/C++ 经验的朋友应该非常熟悉。没有写过 C 的朋友,经过整理,也会有助于记忆和理解。
上述内容涉及到类型方面的知识,如果有朋友还不熟悉,可以参考往期的内容:Go 语言实战(3): 类型
Go 在 1.13 中专门为 fmt.Errorf()
新增了一个动词 %w
。文档是这样介绍的:
如果格式化字符串包含一个
%w
动词,并且该动词对应一个error
操作数,Errorf
返回的error
将实现一个Unwrap
方法,会返回前面传入的error
。包含一个以上的%w
动词 或 提供一个没有实现error
接口的操作数是无效的。无效的%w
动词是%v
的同义词。
文档的说明严谨但拗口。好在这部分代码不长,直接贴出来看看:
|
|
传入的参数,实际上通过 p.doPrintf
(一系列 Printf
函数的内部实现) 变成了字符串 s
。此时 %w
是 %v
的同义词,参数里即使有 error
,也是取 Error()
方法返回的字符串。
然后再看是否有需要包裹(wrap)的 error
。这需要一个 %w
动词并对应的操作数满足 error
接口,仅有其中之一,或者参数顺序不对应,都不算。如无,则通过 errors.New(s)
返回一个只有字符串的最基本的 error
;否则返回一个同时包含 格式化字符串 和 内部错误的 wrapError
。跟基本的 error
相比,它多了一个获取内部错误的 Unwrap
方法。
除了输出(Printing),fmt
包还提供了一系列类似的函数负责输入,将特定格式的文本(formated text)解析为对应的值。
与 Printing 类似,通过前后缀的组合来区分读取的来源和格式化方式:
Fscan
表示从文件(io.Reader
)读取;Scan
(无前缀)表示从标准输入 os.Stdin
读取;Sscan
表示从字符串读取;Scan
(无后缀)表示把换行当成普通空白字符,遇到换行不停止;Scanln
表示遇到换行或者 EOF
停止;Scanf
表示根据格式化字符串里的动词控制读取。Scanning 使用几乎一样的一系列动词(除了没有 %p
, %T
动词,没有 #
和 +
标志),这里不再重复介绍这些动词。动词的含义也基本一致,只是在非常细微的地方,为方便输入做了变通:
%v
时依靠前缀判断进制。123456
,如果用 %3d%d
来解析,会被理解为 123
和 456
两个数;精度不再有意义。其它更细致的差别(包括与 C 语言的差别),像符号的消耗,空白字符串的匹配,就不再展开。建议大家自己尝试,遇到问题直接去看文档。
fmt
官方文档,翻译整理难免有理解偏差,以文档为准
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
我本来也比较关心保险话题,顺着读者的提问和讨论,计划接着聊聊穗岁康和重疾新规相关的话题。
如果你留意了我的推送,就会发现,最终还是没能好好聊。眼下这个时间点,可能已经不及了。
随便敲两个字,算是交代一下这个话题。
作为一个外行,虽然都是个人看法,强调仅供参考,也想好好查查资料好好写,也是给自己做调研。
结果一忙,拖到去年年底。眼看穗岁康第一批就要截止,差一天,同样的保费却少了一个月的保障期。仓促写了一下穗岁康,并提醒有需要的朋友赶紧投保。
作为一个文笔不行的强迫症,写完排完版都到了 12 月 31 日晚上,如果当晚大家没有看到,推送的意义就大打折扣了,所以用了一个比较抢眼球的标题:《还剩不到两小时,错过这批白白损失一个月》。
到这里为止,只能说抢眼球,还不能说标题党——如果大家看完文章,确实能买到穗岁康的话。结果推送完我自己试了一下,发现医保系统调整,早在下午就关闭了购买入口。换言之,文章还没发出,第一批就买不到了。标题党实锤了。
朋友调侃我标题党,我也无从辩解,只好苦笑着认了。
心里计划着,后面好好写一下重疾新规。到时顺便提醒大家,对穗岁康有需求的人群,少一个月保障期也还是可以考虑买的。
然后到今天,一个月又过去了。
我去试了一下,穗岁康居然没有像上次那样提前关闭,不知道文章推送时还有没有。感兴趣的读者朋友,可以点进去穗岁康的文章看看,决定要不要买。就是要赶紧。
然后是重疾新规。
时间关系,不展开说,反正这些内容,给定了关键词,文章一搜一大把。
简单说,2020 年 11 月 5 日,保险行业协会发布了新的重疾险规范。新规范在 2021 年 2 月 1 日实施,不影响在这之前已经成立的保单。旧定义的保险最晚要在 1 月 31 日下架。已经有很多旧定义产品提前下架了。所以说,这个醒,本来早该提。
新规范完善了很多细节定义,变得更细致也更科学了。多数调整是对投保人有利的,例如增加了 3 种重疾,例如明确了很多疾病的定义,减少了保险公司自行解释的余地。但也有『不利』的,最明显的就是原本直接按重疾赔付的甲状腺癌,现在按照分期,可能按轻症赔付。
长远来说,这是好事,会促进重疾险的健康发展。哪怕是看起来『不利』的调整,也是在平衡保险公司和投保人的关系。要知道,现在甲状腺癌甚至被称作喜癌:早期发现的甲状腺癌可能花不了多少钱就能治愈,还能按重疾赔一大笔钱。
按照旧定义,保司当然只能赔。为了减少理赔,很多产品只好用更严的健康告知,阻挡那些有潜在甲状腺癌风险的人投保。于是,有甲状腺结节的人群,可能因此而失去其它重疾的保障。要知道,随着超声技术的发展,普通人群里甲状腺结节的发现率已经在 4% 以上,近年可能还一直在攀升。
但是,视角转换到个人。我们不去考虑整个行业和整个社会,单从个人的利益最大化出发,旧定义和新定义,怎么选?
这时不得不提择优理赔。
如果光比较新旧定义,可以说孰优孰劣,没有定论,要具体案例具体分析。尤其新定义的产品,上市得还不多,特别是未来究竟是降价还是涨价,也说不定,带来了很多的不确定性。
保险公司可能看出了大家的犹豫,于是纷纷提出『择优理赔』,促销了一波。
简单说,如果买了旧定义的保险,同时支持择优理赔,将来理赔时,投保人可以在新旧定义中,选择对自己有利的定义进行理赔。
在未来新产品尚不明确的时候,先把保单确定下来,同时可以择优理赔,确实是对投保人的福利。
何况还有犹豫期。如果先买了旧定义产品,将来在犹豫期内看到更好的新定义产品,完全可以退保。而如果将来发现新产品都不如旧产品,就没有后悔药可以吃了。
可是这文章写得太晚了。这个点,又是最后两小时,不知道还有多少产品还没下架?还没下架的产品,不知道业务员还扛不扛得住涌进的保单,能够赶在下架前投保?
何况还有健康告知的问题。择优理赔看的是重疾定义,但是健康告知是不会择优的。如果不符合投保时的健康告知,无论如何都不会理赔的。如果早半个月,发现有健康告知的疑点,还能去医院做个检查,给结节分个级。
所以现在这个时间点,如果你非常明确自己需要重疾险,而且也认同旧定义+择优理赔是个保底的选择,并且身体没有不符健康告知的毛病,如果还有合适的产品没有下架,买吧。然后犹豫期内再好好考虑考虑。
但这么多个如果,估计很难都符合,那就不要赶这趟车了。便宜总不能都让你占了,等后续新产品出来慢慢看吧。
我们家在 16 年底买的预售房,本应 18 年底收楼。按计划应该简单收拾精装的房子,置点家具电器,通风个半年,在 19 年下半年入住。
可因为房子的质量问题,我们拒收了房子,跟开发商扯皮了一年多。直到 20 年的 315 晚会(你懂的),开发商才作出了部分让步。期间为了保留证据,房子一直空置。直到19 年下半年,实在觉得不能等下去了,才妥协签字收楼,并开始装修,决定自行修复房子的问题。
这中间经历生娃、经历了疫情,装修进程不得不多次中断。
不得不说,装修真是这世界上少有的同时费脑、费钱、费时间、还得出体力的活动。特别是我们这样带『精装修』的,想省钱,不得不小心翼翼保护原有装修的。特别是没有经验,一开始师傅说啥就是啥的。
到最后收尾时回顾,发现投入了那么多心血,却换来处处遗憾。
孩子逐渐长大,租住的房子越来越捉襟见肘。我现在连一张书桌都无处安放,只能在厅的一堆杂物旁,就着落地支架用笔记本。眼看马上就要入住新房,无论花时间换租,还是花钱买新家具改善租住环境,都显得不再划算。
一堆事情环环相扣,变成了时间和空间上的大型华容道与九连环。
眼下最快速能够腾出时间和空间的,显然就是赶紧了结这已经拖了太久的装修。
所以过去的一个月里,我疯狂地缺啥买啥,不断地约各种安装师傅,尽量在新年放假前赶进度。简单的安装我甚至都自己上阵。
去年过年前,原本说不回家过年的工头有事回了老家。等到小区重新对外开放,工头做完别处的单回来继续,已经是 20 年下半年。
现在仿佛又在重现。所以我一有空就跑新房,一直忙到晚饭时间。躺下之前要看着孩子睡着,然后躺在床上继续下单需要的东西,直到睏到睁不开眼。
一不小心发了这么长的牢骚。其实就是想狡辩一下,我为什么这么长时间不发文章。
装修真是一个不断地接受不完美的过程,特别是市面上有那么多二把刀的师傅。而那些手艺好的师傅,审美却又迷一般守旧顽固。
有机会也许能聊聊装修。给不了什么好的建议,起码可以躲开我们踩过的坑。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
即使遇到一些困难,建立直观认识和了解关键词之后,在网络上搜索答案也变得相对容易。
接下来介绍 CLI 框架。
命令行程序的前两期:
本系列完整目录:
对于简单的功能,单个 go 文件,几个函数,完全是足够的。没有必要为了像那么回事,硬要分很多个包,每个文件就两行代码。为了框架而框架,属于过早优化。
但反过来说,随着往项目里不断添加特性,代码越来越多,如何更好地组织代码,达到解耦和复用,就成了必须要考虑的问题。
我们当然可以把自己的思考,体现在项目的代码组织上,乃至从中抽取一套框架。但一个深思熟虑,适应各种场景变化的框架,还是有门槛、需要技术和经验积累的。
更便捷的做法,是引入社区热门的框架,利用里面提供的脚手架减少重复劳动,并从中学习它的设计。
对于 CLI 程序而言,我知道的最流行的框架有两个,分别是:
cobra 的功能会更强大完善。它的作者 Steve Francia(spf13)是 Google 里面 go 语言的 product lead,同时也是 gohugo、viper 等知名项目的作者。
但强大的同时,也意味着框架更大更复杂,在实现一些小规模的工具时,反而会觉得杀鸡牛刀。所以这里只介绍 cli 这个框架,有兴趣的朋友可以自行了解 cobra ,原理大同小异。
cli 目前已经开发到了 v2.0+。推荐使用最新的稳定版本。
这里使用 go module 模式,那么引入 cli
包只需要在代码开头
|
|
如果还不熟悉 go module,或者不知道最后面的 v2
代表什么,请看这篇文章:《golang 1.13 - module VS package》。
简单说,go module 使用语义化版本(semver),认为主版本号变更是『不兼容变更(breaking changes)』,需要体现在导入路径上。 v0.x
(不稳定版本,可以不兼容)和 v1.x
(默认)不需要标,v2.0
及以上的版本,都需要把主版本号标在 module 路径的最后。
但是注意,这个 v2
既不对应实际的文件目录,也不影响包名。在这里,包名仍然是 cli
。
根据作者提供的例子,实现一个最小的 CLI 程序看看:
|
|
这段代码实现了一个叫 boom
的程序,执行的时候会输出 “boom! I say!”:
|
|
另外,框架已经自动生成了默认的帮助信息。在调用 help
子命令,或者发生错误时,会输出:
|
|
这段代码做的事情很简单。初始化一个 cli.App
,设置三个字段:
运行部分,将命令行参数 os.Args
作为参数传递给 cli.App
的 Run()
方法,框架就会接管参数的解析和后续的命令执行。
如果是跟着教程一路过来,那么很可能这里是第一次引入第三方包。IDE 可以会同时提示好几个关于 “github.com/urfave/cli/v2” 的错误,例如:”github.com/urfave/cli/v2 is not in your go.mod file” 。
可以根据 IDE 的提示修复,或者执行 go mod tidy
,或者直接等 go build
时自动解决依赖。无论选择哪一种,最终都会往 go.mod
里添加一行 require github.com/urfave/cli/v2
。
当然,实现这么简单的功能,除了帮忙生成帮助信息,框架也没什么用武之地。
接下来我们用框架把 gosrot
改造一下,在基本不改变功能的前提下,把 cli
包用上。
因为有了 cli
包处理参数,我们就不用 flag
包了。(其实 cli
里面用到了 flag
包。)
|
|
cli
的 Flag
跟 flag
包类似,有两种设置方法。既可以设置以后通过 cli.Context
的方法读取值:ctx.Bool("lex")
(string
等其它类型以此类推)。也可以直接把变量地址设置到 Destination
字段,解析后直接访问对应的变量。
这里为减少函数传参,用了后者,把参数值存储到全局(包级)变量。
程序入口改为 cli.App
之后,原来的 main()
函数就改为 sortCmd
,作为 app
的 Action
字段。
|
|
由于程序被封装成了 cli.App
,程序的执行交给框架处理, sortCmd
内部不再自行调用 os.Exit(1)
退出,而是通过返回 error
类型,将错误信息传递给上层处理。
这里主要使用 fmt.Errorf()
格式化错误信息然后返回。从 1.13
开始,fmt.Errorf()
提供了一个新的格式化动词 %w
,允许将底层的错误信息,包装在新的错误信息里面,形成错误信息链。后续可以通过 errors
包的三个函数 Is()
, As()
和 Unwrap()
,对错误信息进行进一步分析处理。
接下来编译执行
|
|
如果完全照着教程的思路重构,到这一步,你可能会发现,代码可以编译和运行,却没有输出。这是因为有一个地方很容易忘记修改。 请尝试自行找到问题所在,并解决。
框架除了解析参数,自动生成规范的帮助信息,还有一个主要的作用,是子命令(subcommand)的组织和管理。
gosort
主要围绕一个目的(提交号的排序去重)展开,各项功能是组合而不是并列的关系,更适合作为参数,而不是拆分成多个子命令。而且之前的开发容易形成思维定势,下面我们另举一例,不在 gosort
基础上修改。
为了容易理解,接下来用大家比较熟悉的 git
做例子。篇幅关系,只展示项目可能的结构,不(可能)涉及具体的代码实现。
首先,我们看一下 git
有哪些命令:
|
|
总的来说,就是有一系列的全局选项(global options,跟在 git 后面,command 之前),一系列子命令(subcommand),每个命令下面还有一些专属的参数。
这样的工具,有几个特点:
为了更好地组织程序,项目结构可以是这样子的:
|
|
main.go
是程序入口,为了保持结构清晰,这里只是初始化并运行 cli.App
:
|
|
具体的代码实现放到 cmd
包,基本上一个子命令对应一个源文件,代码查找起来非常清晰。
common.go
存放 cmd
包的公共内容:
|
|
除了业务相关的公共逻辑放在 common.go
,还有一些业务中立的底层公共类库,就可以放在 pkg
下面,例如 hash.go
:
|
|
看一下其中一个子命令 add
的代码:
|
|
拥有相同 Category
字段的命令会自动分组。这里在 common.go
预定义了一系列的分组,然后直接引用。之所以不是直接用字面量,是因为在多处引用字面量,非常容易出错,也不利于后续修改。
举例说,如果不小心在组名里输入多了一个 “s” ,就会变成下面这样:
|
|
好了,一个连低仿都不算的 git
算是搭出一个空架子,编译执行看看:
|
|
光看帮助信息是不是感觉还挺像回事。
希望通过这个粗糙的例子,能让大家对 urfave/cli
这个框架建立一点直观的印象。
更多的例子、更详细的字段用法,可以参考
到这里,鸽了很久的 CLI (命令行程序)部分暂告一段落。
在实际写过几个 go 程序之后,相信大家对于 go 已经有一些直观的认识。与此同时,前面只介绍了很少一部分语言特性,在实际编程中可能会产生各种疑惑。后面几期回归基础知识讲解,希望能解开其中一部分疑惑。
最后的最后,关于 CLI 程序,推荐一篇文章:《做一个命令行工具,是一件挺酷的事儿》
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
一开始先把初步的回答发在知识星球上。整理成文章发出来之前,有朋友问了新的问题,加上我写东西总想写全面的老毛病,追加了两个相关话题。
不过最近忙于装修,文章已经拖了一段时间,最后就没要追加的部分直接发了。追加的话题也就搁置了。
突然发现,2020 年马上就要过去了,话题就要失去时效性了,赶紧整理一下也发出来。
时间仓促,先简单提一下惠民保。不追求严谨,能引起大家注意有这么一件事就好。
[TOC]
除了上一篇文章里提到的常见的保险,关注保险的朋友最近一两年大概会注意到一个新品种:惠民保。
惠民保并不是某一个保险的名称,而是一类保险的泛称。在不同地方、不同上下文,可能还会被称为全民保、市民保。
它本质上是一类由地方政府主导设计,经公开竞价招标后由商业保险公司承保的普惠型商业补充医疗险。
5 年前,深圳最早试水『惠民保』,但很快又陷入沉寂。最近一两年,以城市为单位,各种惠民保遍地开花,以『低保费、低门槛、高保障』的姿态回归。像北京的『京惠保』、重庆的『渝惠保』、东莞的『市民保』,其它还有类似『八闽保』、『蚌惠保』、『浙丽保』、『皖惠保』、『佛医保』,从名字就能看出是哪个地方的。
而这个话题进入我朋友圈的讨论范围,是因为今年广州一下子推出了『广州惠民保』和『穗岁康』两款保险。
总的来说,惠民保主打的就是:价格便宜、投保门槛低、适用人群广泛。
不绕弯,直接回答核心问题:
简单说,只要你买得到、买得起市面上主流的大病医疗险,惠民保对你来说就比较鸡肋。
而如果你因为各种原因被拦在了商业大病医疗的门外,那么惠民保就是你的救星。
下面给一个快速自查列表:
需要注意的是,穗岁康虽然没有等待期,但分批次限时购买。
2020 年 12 月 1 日至 31 日 第一批购买的,保障期为 2021 年 1 月 1 日至 12 月 31 日。
而 2021 年 1 月 1 日 至 31 日 第二批购买的,保障期为 2021 年 2 月 1 日至 12 月 31 日。
换言之,如果你是需要购买的,最好快一点购买。同样的价格,第二批的保障期少了一个月。
这里以广州的保险考虑,并没有去研究其它城市的情况。其它惠民保未必符合同样的结论。
惠民保根据其保障范围来看,仍然是一款大病医疗险。
既然是大病医疗,我们拿穗岁康跟最容易买到的微信微医保对比一下。(注意,不是推荐微医保。每个人都有微信,容易购买,方便拿出来比较。)
穗岁康:
穗岁康不限年龄、户籍、职业,没有等待期,也不限制既往病史。保费与年龄无关,均为 180 一年,可以用医保个人账户支付。
唯一的限制,是被保险人必须是广州市医保的参保人。
年度支付限额 | 免赔额 | 赔付比例 | |
---|---|---|---|
住院门特医疗费用 | 100 万 | 1.8 万 | 80% |
住院合规药品和检验检查费用 | 100 万 | 1.8 万 | 70% |
门诊合规药品费用 | 30 万 | 国谈、创新药 1.8 万 / 其它 5 万 | 60% / 50% |
特殊医用耗材 | 胰岛素 4.2 万 三年限报一泵 | 无 | 70% |
18 岁以下 I 型糖尿病 | 1.3 万/年,3250 元/季 | 无 | 70% |
指定病种筛查费用 | 100 元 | 无 | 低于 100 元按 80% |
宣传时,保额一般写 235 万。这个 235 万并非通用的保额,而是分成上面几个部分。单项超出了,另一项没动过,也不能拿过来用。所以分项当然不如直接通用的保额来得好。
微医保(2020版):
微医保刚好相反,跟绝大多数的商业医疗险一样,对年龄(30天到65岁)、职业(排除高危职业)有限制,有等待期,需要健康告知(所以对既往病史限制比较严格)。保费跟年龄直接相关,下面给出费率表(有医保,按年缴费):
年度支付限额 | 免赔额 | 赔付比例 | |
---|---|---|---|
100种重大疾病医疗 | 600 万 | 无 | 质子、重离子放疗 60%,其他100% |
一般疾病及意外医疗 | 300 万 | 1 万 | 100% |
恶性肿瘤院外特定药物费用 | 600 万 | 无 | 100% |
重大疾病住院津贴 | 100 元/天,不超过 180天/年 | - | - |
新冠肺炎保障 | 5 万危重,10 万身故 | - | - |
可以看到,除了 4 周岁以前的婴幼儿,和年龄较大的中老年人,穗岁康的价格并无优势。
青少年就不用说了。即使对于 30 岁上下的人群(读者主要集中在这个年龄段),虽然保费翻倍了,但是保障更足。
一般而言,即使得了重大疾病,一年内花掉几百万也很少见,所以姑且认为穗岁康的 100 万保额够用。即使如此,光靠免赔额和赔付比例,两者还是拉开了很大差距。
假设患 100 种重大疾病以外的病,但是还是比较严重(如意外重伤需要手术),花掉了 30 万的医疗费,微医保可以赔付 29 万 (30 - 1),而穗岁康只能赔付 22.56 万((30 - 1.8) x 0.8)。两者差距 6 万多。
如果是门诊(穗岁康限 30 万),或者目录内疾病(微医保 0 免赔),或者花费更多的医疗费,两者差距更大。
即使是老年人,如果已有大病医疗保单(保证续保的一年期,或者定期保单),只要不是穷到交不起保费,也建议继续续费保障更足的商业医疗险。
但对于因为各种原因被拒保的朋友,没有任何门槛的穗岁康当然香。打折扣的保障也是保障,总比裸奔强。何况保费一共才 180 元,还可以用医保个人账户支付。
像我这样比较少看病的,个账里钱本来就剩得比较多。
既然这么便宜,我医保个账剩的钱也多,在原有商业大病医疗的基础上,把穗岁康也买了行不行?
不建议。
医疗保险是报销险,花出去的钱可以分割后在多张保单理赔,但不会重复理赔。由于一般都允许将其它地方的报销纳入免赔额,所以搭配保单需要搭配不同的免赔额和保额。
一般情况是低免赔+低保额的保单,搭配高免赔+高保额的保单。
举例说,一个补充医疗险,免赔 1 千,保额 2 万;搭配大病医疗,免赔 1 万,保额 300 万。然后产生了两个保险理赔范围内的医疗费 10 万,就可以先找第一张保单理赔,免赔 1 千后赔付 2 万;这 2 万 1 千可以算入第二张保单的免赔额,剩余的 7 万 9 千全赔。
最终的效果就是,两张保单加起来赔付了 9 万 9 千,只有 1 千免赔。
我们可以看到,穗岁康相对市面的大病医疗来说,免赔高、保额低,赔付范围是大病医疗的子集,完全重叠,所以基本上没有用得上的情况。那如果没有用,180 的钱也是钱,用来干点啥不好。
同样的保险,为什么对不同人群会得出截然相反的结论呢?
惠民保跟一般商业医疗险相比,多了政府引导的政策性,有普惠性质。类似药品的批量采购,企业在这里更多是保本微利,甚至是赔本赚吆喝。当作一次品牌宣传、新客开发,或者当搞好政府关系,或者通过走量获取用户数据,等等。
既然保险公司都没钱赚了,那得到好处的肯定是消费者。别急,不一定。
保险公司可以不赚钱。甚至可以微亏当做营销,却不可能长期赔钱。
羊毛出在羊身上,长远来说,保费和理赔一定是基本打平。理赔的钱,就是从你交的钱里面出。
我们看商业保险的条款特别严格,往往觉得它在针对我们。如果我们被拒保了,(对我们而言)这款产品可能太严格了。但反过来说,如果你满足条件投保了,这些严格的条款就是在保护你的钱。
只有理赔少,保费才可能便宜。不同人群的身体状况不同,理赔概率不同,就理应收取不同的保费。
如果不区分年龄和身体状况,均一收费,实际上就是年轻人在补贴老年人、健康人群在补贴亚健康人群。
从民生工程的角度来说,这种转移支付不一定是错的。所以如果你手里资金充裕,也不妨投保,当成做公益。
但从经济理性来说,身体健康的人不建议买。而且长远来说,当大家都意识到这个问题,会造成逆向选择,来投保的都是身体状况有瑕疵的人群。这会导致理赔率进一步上升,最后有可能会无法维持。
我家里人都配置了大病医疗险。在对比过条款之后,首先我们年轻这一代不考虑穗岁康。
而即使考虑到父母的保费很贵,在已有保单的情况下也不考虑切换。何况我的父母没有在广州参加医保。
最后我给孩子买了一份。孩子因为早产,尽管后来发育得很好,2 岁之前还是被多数大病医疗险拒保。给孩子买一份,正好填补他 2 岁之前的空档。刚好他的医保也是在广州。
因为篇幅的关系,没有提及『广州惠民保』。你可以简单地认为它是更低配的穗岁康。保费更低只需要 49 一年,但是理赔的限制更多,包括更高的免赔额,更低的保额,更多的除外(既往症不影响投保,但重要既往症除外)。穗岁康已经是被拒保后的备选,『广州惠民保』就更加不考虑了。相信大家不会连一年 131 元的差价都掏不出。
最后的最后,必须强调我不是保险专业人士。我只是自己有需求,做了对比之后把我不靠谱的结论分享出来。如果有专业人士的意见,请优先以他们的结论为准。
因为是第一年推出,无法预估未来的政策走向。考虑到逆向选择的问题,未来是否会增加新投保的限制呢?为此是否需要先投保呢(因为一般对续保不作限制)。所以以上的结论,局限性非常大,请谨慎参考。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
数组怎么样展开?
问题描述比较模糊,进一步沟通之后得知,他需要的是将一个数组(其实是切片)展开,来作为函数的可变参数。
关于可变参数,之前在这里(函数签名部分)介绍过。考虑到那篇文章内容比较多,这里再介绍一下。
简单来说,可变参数就是函数里以 x ...T
这种形式声明的参数。举例说 f(s ...int)
,参数 s
接受零到多个 int
型的参数,可以像这样调用 f(a, b, c)
(a
,b
,c
都是 int
型的值)。逐个传入的参数(实参)会装包成一个切片 s
,传递给函数。
从函数内部的角度,这跟 f(s []int)
是等价的。而从调用方的角度看则有差别:可变参数接受多个 int
型参数,而后者只能接受一个 []int
类型参数。
如果有多个同类型参数,遇到第二种函数定义(参数类型是切片),就只能自己先创建一个切片,再直接传递切片。不过相信你也明白了,可变参数不过是把创建切片过程省略的语法糖:
|
|
反过来,有一个 []int
变量 b
,需要传递给可变参数怎么办?难道要 f(b[0], b[1], b[2])
这样一个个用下标访问?如果切片很长,又或者直接不确定长度怎么办?
在其它语言,例如 Python 里,对于可迭代类型对象(Iterator Types),可以用装包和拆包(解包)解决这个问题,使用上非常灵活。
Go (看起来)也可以解包:
|
|
注意 ...
的位置,声明时在前,调用时在后。
但,这是一个假的解包。这只是又一个语法糖,背后把 b
直接赋值给 s
。把 b
拆分成逐个参数传递,然后重新打包成切片 s
这件事,根本没有发生。
你以为的解包:
(图中的细箭头表示指针,粗箭头表示拷贝)
或者至少是这样的:
其实是这样的:
切片是引用类型,变量本身保存的是头信息(元数据),里面有一个指向底层数组的指针,元素数据保存在数组里。在赋值和传参时,拷贝的只是切片头(slice header),底层数组并不会递归拷贝。新旧切片共享同一个底层数组。
...
只是表示 b
是一组参数,而不是一个参数。如果缺少 ...
,直接 f(b)
,会把 b
直接当成一个参数(也就是 s
切片的一个元素),参数的 []int
类型和元素的 int
不匹配。
好消息是,没有额外开销。坏消息是,因此使用上多了很多限制。
b
必须是相同类型的切片。[]string
传递给 []int
固然不行;因为没有经过解包后重新装包,数组传递给切片也不行。...
(姑且还是叫解包)不能跟其它参数或者其它解包参数一起使用。f(x, b...)
或者 f(b..., c...)
都会报错。因为没有经过解包后重新装包,元素 x
和切片 b
,或者b
和 c
两个切片,都不会组成一个新切片。s
的元素,会影响到 b
。(因为它们共享一个底层数组)由于没有看到具体代码,根据对方的描述,猜测问题出在没有理解『伪解包』上。所以我对这部分进行了解释。
然而问题并没有解决,第二天提问者又来了。
这次提问者给了更详细的信息。
他需要调用 gorm
包的 Having
方法,方法签名是:
|
|
看起来跟我的猜测差不多。还有什么该注意的我忘了说?
我正想要代码和具体的报错信息,对方说了一句:
为什么 []string 不能转为 []interface{}?
我一下子明白了问题所在:解包的实参是一个 []string
而不是 []interface{}
。
如果是多个 string
变量作为 values
参数,反而没有问题。但是把 []string
解包,就报错了。
当然,提问者自己也意识到问题出在这里了,只是不明白原因。而我过分关注可变参数,忘了留意类型。
这个现象很容易重现,完全没必要用到 gorm
包。下面的代码就报同样的错误:
|
|
注意是 fmt.Print(...interface{})
,内置函数 print(...Type)
的原理不在今天的讨论范围。
当然理解可变参数也很必要。我们还是需要先理解(伪)解包,知道解包的背后是直接传递切片。如果是语言做了真实的解包和重新装包,这个问题也就不存在了(见 ifaces2
部分代码)。
一旦了解这些,提问者很自然地发现问题变成了:既然任意类型都可以转换为空接口 interface{}
,为什么 []string
(或者任意别的类型的切片)不能转为空接口切片 []interface{}
?
是的,不可以。其它强类型语言也不可以。其它容器也不可以。
简单粗暴的结论就是:
子类型变量可以向父类型变量转换;但存放子类型的容器跟存放父类型的容器没有关系,不能转换。(为了方便理解,父子类型借用的 Java 的概念,Go 没有继承机制。)
Go 里面没有继承,只有接口和实现;同时(暂时)没有泛型,只有内置派生类型(slice, map, chan 等)可以指定元素的类型。Go 版本的表述是,即使类型 T
满足接口 I
,各自的派生类型也没有任何关系(例如 []T
和 []I
)。
在 Java 里,Integer
是 Number
的子类,ArrayList<Integer>
是 List<Integer>
的子类。但是,List<Integer>
跟 List<Number>
没有继承关系,不能转换,只能创建新容器,然后拷贝元素。
对应到 Go 里,string
满足 interface{}
,string
变量可以转换为 interface{}
变量;但对应的切片 []string
却不能转换为 []interface{}
。map
和 chan
同理。
设计成这样的理由,稍微解释就很容易理解。
无论 Java 的类继承和接口实现,还是 Go 的鸭子类型接口,都是为了实现多态。
关于多态(特别是不同语言下的多态)这里不展开。一句话来形容的话,Java 的多态是『代父从军』,『龙生九子,各有不同』;Go 的多态则是『如果它跑起来像鸭子,叫起来像鸭子,那它就是一只鸭子』,但是每一只『鸭子』可以有自己不同的行为。
具体的实现只要满足相同的约束,就可以赋值给上层抽象类型(父类型或者接口),当作该类型使用;与此同时,不同的实现有不同的行为。调用代码只需要认准上层类型的约束,不必关心具体实现的行为,达到调用和实现的松耦合。这样可以做到在不修改调用的情况下,替换掉具体实现。
Integer
完全可以当作 Number
使用,因为 Number
有的行为 Integer
都有;日后也可以根据需要替换成 Float
或者 Double
。ArrayList<T>
和 List<T>
也类似(注意,T
是同一个类型)。Go 的空接口 interface{}
对类型没有任何约束,可以接受任何类型。
可一旦涉及容器,情况就变了。如果一个 ArrayList<Integer>
可以当作 ArrayList<Number>
,意味着调用方可以往里面添加任何 Number
类型(及子类型),有可能是 Integer
,也可能是 Float
或者 Double
。
背后的具体实现 ArrayList<Integer>
可以放别的 Number
类型吗?不行。
同样的,[]string
不能存放 string
以外的元素。如果允许 []string
转换成 []interface{}
变量,意味着需要接受任意类型的元素。
总结:
父类或者接口作为上层抽象类型,在运行时可能会被替换为任意子类型,其可接受的行为应该是子类型的子集 。(父亲会的技能,孩子们都要会。父亲不能接孩子们不会的活,否则这个活就无法在运行时分派给孩子们干。)
[]interface{}
可以接受的元素类型,比任意具体类型的切片都要多,显然不满足上述条件。从『空接口是任意类型的抽象』,得出空接口切片(或者其它容器)也是上层抽象,就属于想当然了。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
如果是帮小朋友买保险,需要买哪些保险?教育基金之类的有没有必要?
这个提问非常典型。典型就典型在,这个问题的提法,本身就有问题。
小朋友是家人的心头肉,特别关注,人之常情。但是,有必要优先为小朋友买保险吗? 要优先考虑教育基金吗?
目录
[TOC]
问题很大,展开说够写几篇文章。
我尽量挑重点说。能问到我这个外行这里来,说明起码比我外行,尽量一篇文章先建立基本的认识。更具体的分析公众号和知乎里多的是专业人士。我这个外行可能会说错,不要当作最终答案。重要的是读者先有了基本概念,后续进一步了解时知道搜索关键词。
有些重要的话放到了最后,希望你能看完。
问的是帮小孩子买。对于婴幼儿,我个人推荐的购买优先级是,医保 > 意外 > 大病医疗 > 重疾。小额医疗、寿险和教育基金可能不是普遍的需求。
全家必买,基础保障。
医保(社会医疗保险)的保障有些高不成低不就,却仍然是非常重要的基础保障。不要指望医保解决全部问题,『高不成』的部分可以由商业保险来补足。
由于意外险只赔意外引起的后果,除特殊职业,不同人发生意外的概率差别有限。所以便宜,规则不复杂。
成人约 300 保费可以保一百万(一年,下同)。小孩限保额,10 岁以下限 20 万,18 岁以下限 50 万(儿童大保额有道德风险),根据不同年龄不同的附加项,百元左右一年。
这两项越早买越好,一天没买上,孩子就暴露在无保障的风险中。孩子出生后应该尽快办完出生证、户口,然后马上买。
医保正常情况是前一年年底扣费,所以出生的第一年算补办,是有时间限制的,一般是出生后 90 天内。各地规定有不同。
意外险产品都比较接近,很少有特殊规定,不值得太费心,挑热销产品,保障足保费低的,怎么方便怎么买。像直接微信买『护身福』就挺好。(可以扫码直接购买)
注意,这里并非特别推荐护身福。只是对于多数人,研究、比较、管理保单都太麻烦了,门槛一高容易忘了买(或者买完忘了保单在哪)。这点上说,因为每个人都有微信,腾讯旗下产品会便捷一些。如果你愿意去花时间了解,可能会找到更好的选择。
对于一年一买的必买险种,多做功课货比三家固然好,看着差不多直接买问题也不大。直接买热销比纠结没买强。
直接买大病医疗,保障大风险。
医疗可以分小额(门诊)医疗和大病医疗(俗称百万医疗)。
区别是前者保额和免赔低,小病也能用。后者保额和免赔高,像几百万保额、免赔一万,绝大多数病用不上。赔付概率低了,明明保额那么高,价格可能反而还更低。一年期的大病医疗,年轻人大约几百块就能保几百万,老人也不过一千多点。定期险再贵一点。
小额医疗有点鸡肋。保额低,保费不低,导致杠杆很小(一般只有 10+,也就是花一百的保费,大约只能得一千多的保额)。身体健康的人,一年下来看几次感冒的钱,医保报销之后就没多少了,看病的钱不一定有保费多。关键小病费用太琐碎,很多人未必愿意走报销流程。这就导致逆向选择,体质好的不会买,买的都经常看病。
这反过来让理赔率变高,保险公司也需要发工资需要盈利,自然会把保费调高,这又进一步降低了性价比,强化了逆向选择。保险是用来对冲风险的,把不确定的大风险转换成确定的小支出。不要指望回避所有风险,更不要指望占保险公司便宜。除非是医院常客,不建议考虑小额医疗。
大病就不同,概率很低,但是一旦出现就能把家庭财务击穿。这种反而很适合用保险兜底。高免赔额挡住了大多数小毛病,理赔概率非常低。理赔的人少,保费才能便宜。
医疗险是报销险,保额内报销超出免赔的部分。以 200 万保额免赔一万为例,如果癌症花了 61 万,能报销 60 万。它的存在,是保证『有钱治』。但既然是报销,总报销额一定小于等于总开销,不会让你有钱赚的。有多张保单时,可以把医疗费用拆分成几个部分在不同保单理赔,但不会对同一块支出重复理赔。
不过在其它保单报销过的费用,一般可以算在免赔额度里。据此搭配不同保额和免赔额的保单来实现更好的保障,已经属于高阶操作,读者可以自行搜索了解。
作为一个外行,从医疗险开始往下的保险,都不敢推荐了。因为不同人的需求不同,需要的保额不同,健康状况不同,预算不同。
只能说:尽量以小保费买大保额(高杠杆),核保一定要符合健康告知(否则将来保险公司可以拒赔),实在有小毛病无法通过核保,就要线下找保险公司制定方案,要么除外要么加钱。
尽量买定期,一份保单保障多少年,或者保障到多少岁。选尽量长的缴费年限,降低每年保费负担。在中国,只要有保单在,即使保险公司破产这种低概率事件发生,保监会也会指定其它保险公司接收保单继续承保,必要时会启动保险保障基金。
一年期产品,每年都是新保单,即使保险公司承诺可以续保,也未必有法律效力。一旦老产品下线,买新产品就得重新核保,可能会因为小毛病被拒保。这条建议基本对任何与健康状况相关的保险都有效。
如果太头疼,腾讯微医保或阿里好医保两个一年期闭着眼买比不买强(健康告知必须符合,这点不允许闭眼)。可以先买,继续找更好的产品,以后有更好的换。换保险需要重新核保(健康告知)和计算等待期,记得把这两点考虑进去。这条建议对其它定期险也有效。
优先级靠后。对于收入低,保费预算紧张的家庭,先配齐其它保障。
重疾险(重大疾病保险)是给付型,是对『可能患重疾』做对赌。保险公司押你没事,你押会中招。没事,保险公司赚保费。中招,保险公司直接赔全额。注意,不管你治不治,花了多少钱,保险公司不过问,只要确诊目录上的病,直接赔全额。
大病医疗哪怕动不动几百万保费,但其实很多病没那么严重,就算癌症也很少会一年内把保额报销完。还有太严重的情况,没怎么治就挂了没怎么花钱。所以平均下来,实际的赔付额可能就几万。
但是重疾看着保额低,直接全额赔。所以 30 万的保额,(重症)理赔必赔 30 万,这就导致重疾健康告知很严,还比较贵。
重疾的最大作用,是患病后的经济补偿。治病花钱,找大病医疗报销;病人不能上班断了收入,重疾的赔付可支撑一段时间的家庭开支。
小孩和老人,没有劳动收入,必要性就低一些。对于经济紧张的家庭,重疾保费也是一笔不小的支出。保费预算不多的话,建议优先配置大病医疗险。
当然保费宽裕的话,还是建议尽量买,特别是家里劳动力非常有限,孩子患病需要主要劳动力停工照顾的情况。
买重疾除了注意保额,还要留意包含的疾病目录,确诊标准,还有赔付次数等。
代替离开的家庭支柱照顾家人。我认为这要买,但不是给孩子买。
寿险跟重疾险类似,只是押的不是患病,而是死亡。也就是被保险人死亡即赔付,是一个被保险人自己永远看不见理赔的保险。作用就像第一句说的,是为了在家庭支柱离世后,获得一笔钱,避免因为失去收入导致生活质量断崖式下降。
小孩子没有收入,他的离开给家庭带来的主要是精神上的痛苦,此时赔一笔钱对家庭的改善意义有限。
对于收入来源单一,特别背负债务的家庭(如房贷),建议给收入主力买寿险。
以养老和教育之名的储蓄。看具体情况。
前面介绍的,都是消费型保险。钱花出去买了保险,就跟其它消费一样,钱花了就花了。只不过一般消费买商品,保险买保障。
买保险的一个原则是,消费型和储蓄型尽量分开买:
消费型就消费型,直接算得出来杠杆率,用多少保费换了多少保额。各种条件差不多,选杠杆高的。
投资(包括储蓄)的就是投资的,看回报率,而且要看内部回报率买(IRR,可以用工具或者excel算,不展开)。
极少数情况下,某些公司的好产品,不卖给一般人,一定要你买了理财才能买。如果保险确实好,理财收益也过得去,可以考虑套餐。
但大多数的组合产品,都是为了让你算不清上面两笔账,你说保额低时他谈有回报,你说回报率低时他又说有保障。其实你分别买两个产品还更便宜。
回到要不要买养老险和教育险:看你的投资能力和自制力。
如果这些条件达不到,手里又有余钱,可以给老人或孩子存点钱。认真对比过 IRR 再买。如果送各种天花乱坠的权益,把对你有用的折算成钱一起对比,用不上的忽略。
这种保险不是对冲风险,只是存钱给未来花,优先级最低。最怕买保险不认真看条款,买了组合的保险,等到需要赔付时才发现很贵的保费只换来很低的保额根本不够用,剩下的保费都拿去储蓄了,又还没到取出时间,那就非常尴尬了。
(你回去家里问问,很可能家里老人禁不住亲戚的推荐,已经买了这些鸡肋的产品。别问我怎么知道的。)
你会发现上面顺便把成人和老人也提到了。
提问者跳过了其它家庭成员,直接问孩子,这个顺序本来就是不对的!应该把家庭看作抵抗外来风险的一个整体,优先给家庭的收入主力配置保障。
家庭支柱不给自己配置保障,光给老人小孩买一堆保险,看着孝顺慈爱,但没有用。你倒了他们可以靠着(他们的)保单很好地活下去吗?保费都没人帮他们交好吧。
极端地假设,如果家里的钱只够为一个人配置保险,那就给賺钱最多的人配。小孩病了没有保险,起码还有人照顾和筹钱。只给小孩买保险,大人却病倒了,断了收入,连每年续交保费也无法保证,小孩的保单也失去意义。
当然最好的办法,还是趁全家都健康时,把必要的保障都配齐。
健康相关的保险尽量买长期(定期)险,避免以后健康恶化被拒保。
前面在聊到医疗险的后面提到了,这里再啰嗦一遍:
如果预算紧张,买一年期比不买强。一年期里,又尽量选官方有续保保证的(虽然这个保证视乎公布方式,不一定有法律效力)。一年期的风险在于未来产品下架,或者大幅调高保费。这时想买新产品,需要重新核保,有可能因为小毛病被拒保。
如果预算够,尽量买定期!一份保单保障多少年,或者保障到多少岁。选尽量长的缴费年限,降低每年保费负担。只要有保单在,即使保险公司破产这种低概率事件发生,保监会也会指定其它保险公司接收保单继续承保,必要时会启动保险保障基金。
当然还有一种办法,先买一年期的产品,等到预算充裕或者遇到好的产品,再改买定期产品。记得换保险需要重新核保和计算等待期,要留意当前的身体状况,以及要提前买避免等待期没有保障。
如果觉得长期险保费太贵,也许是因为保终身,可以考虑保固定年限(如30年)或到固定岁数(如70岁),把保费降下来。只要是人就一定会死,保终身意味着患病概率大增(医疗/重疾)或者一定会赔(寿险),保险公司为了不亏只能多收钱。
记住羊毛出在羊身上,千万不要指望薅保险公司的羊毛。也不要心疼钱花了,觉得没事钱就白费了,为此特意去买保终身和返还型保险。要知道返还的保费本质上就你多交的保费存下来的,不用等到死了才领回来,一开始就少交不好吗。
当然,如果你是现在不差钱,但是花钱大手大脚,担心年老(70 岁以后)患病会没钱,或者不能留一笔钱给家人,保终身也许是个不错的选择。
买保险是个技术活,需要根据家庭的财务状况以及家庭成员的身体状况规划。所以除了意外险可以随便一点选,全文下来几乎没有推荐具体的保险。
除了努力一点自己学习保险知识,也可以寻求专业的保险规划服务。但这个以我的半桶水,也不敢推荐。只能说要么挑有名的大平台,要么挑背景过硬的保险人出来单干创业的。无论哪种,都必须中立,不受雇于某个保险公司。
理论上收费的咨询更好,这种服务更专业细致,一次过的咨询费用能买到更合理的配置,还是值得的。有咨询费收入也可以让顾问更关注口碑,不受销售佣金左右。但还是无法排除有败类两头吃。
免费的咨询除了是推广期搞促销,更多的是以销售佣金作为主要收入。理论上这也算中立,赚哪家佣金不是赚,长远的客户口碑更重要。但禁不住某些产品的佣金特别高,可能会撬动『中立』。个别产品新推出时为了冲销量,佣金达到首年保费的一半,可能有大几千块钱,很难不让人心动。
所以可能真的没有一劳永逸的解决办法,哪怕找了顾问,自己也要多上心,多问几个问题,自己也考虑一下对方的建议是否合理。
那些突击培训上岗、一知半解的保险代理人,即使是自家亲戚也不要信——大概率他们自己本身就是韭菜。为了完成业绩,他们自己会买,会动员身边所有亲戚朋友买。但多数半路出家的代理,销售能力也就那样,都卖一圈之后开始卖不动,也就只能改行,然后保险公司又开始高薪招下一批代理……
最后的最后,这只是一个消费者的视角。非专业人士,不能作为购买建议。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
像之前在安全座椅的文章里说的,同龄人大多到了婚育阶段,进度有快有慢:有孩子已经上学的,有还在相亲的。我在向前辈们取经的同时,居然也有人向我取经。
让我们从备孕聊起。
年轻读者如果不想泄漏你们的计划,建议收藏,不用转发。
已为人父母的读者没这个顾忌,可以分享给身边需要的朋友。
对没了解过的话题,我们很难提出有价值的问题。没有概念,自然也不知道『该问什么』。互联网发达的今天,只要是公开的知识,知道自己『不知道什么』,很容易获得答案。提正确的问题,比答案重要;建立概念,先于详尽的内容。
我只是一个孩子的父亲,不是医学工作者,本来没有我的事。只是被问多了,发现问题常常没问到点上,需要反复介绍一些基础概念,翻查并转发一系列文章,顺带补充解释。这非常琐碎,琐碎到让我决定写概括性的文章,来避免重复劳动。
本文不能作为医学建议。主要目的,是希望引起对相关话题的关注,并提供部分参考。只能尽量保证,内容要么是亲身经历和体会,要么是经过资料查证。
只是我和身边人的经历,样本量很小;非生物医学相关专业、没有科研经历,很多内容超出我的知识储备。可想而知,本文一定会出现错漏,欢迎指正。
大家知道有这些关键词,后续会主动了解相关内容,目的就达到了。
本文假设读者相信现代医学、遗传学和营养学,这是后续讨论的基础。
今天在系统分类学、解剖学、生物地理学、古生物学、分子生物学、遗传学等学科压倒性的证据支持下,学术界普遍认为进化是事实(fact),而不仅仅是理论(theory)。只是细节上尚存争议,还需更多研究。我国现代推崇唯物主义,又没有宗教观念的阻碍,大家对进化论和遗传学的接受度应该比较高。
目录
[TOC]
接下来是要讲鼓掌的事吗?并不是。鼓掌远远不是故事的开始。
最早提出进化学说的拉马克,提出了『用进废退』和『获得性遗传』两个法则。这两个法则后来被达尔文提出的『自然选择』所推翻和代替。孟德尔的经典遗传学为此提供了理论基础。
简而言之,后天获得的性状没有改变生殖细胞的 DNA 序列,无法遗传。如果说母亲的身体素质关系到胎儿的生长环境,那父亲只是纯粹的 DNA 提供者。他的身体状况只影响小蝌蚪数量和活力,进而影响命中率;他的身体和精神状态,不影响胎儿。
真的是这样吗?
经典遗传学会回答『是的』。30 年前遗传学家或许都这样认为,讨论父亲后天状态与胎儿关系的论文很大可能会被当成胡闹。
但最近几十年发展起来的表观遗传学发现,还存在很多 DNA 序列以外的机制会影响遗传,像 DNA甲基化、RNA干扰、组蛋白修饰等,能在不改变 DNA 序列的前提下,调节基因的表达。尽管相关的研究还不够充分,越来越多的证据显示,生殖细胞(小蝌蚪和卵子)里不仅仅保存了 DNA,还有别的信息,记录着细胞主人的状态。
大白话就是:受孕前父母的状态,会影响到胎儿。所以,男女双方都需要备孕,而且是从身体状况到精神状态全面的准备。父母有熬夜、不运动、抽烟喝酒、暴饮暴食、精神压力大、长期处于污染环境 等状况,可能会通过现在尚未明确的途径,影响到孩子。
这些因素短则影响胎儿发育,长的甚至会影响孩子一辈子。例如,长期暴露在橙剂中的美国越战老兵,他们的后代患有脊柱裂的概率就比普通人更高。
橙剂是一种除草剂,曾用在越战中实施落叶计划,使丛林中的越南游击队无处藏身。橙剂的生产过程会产生有毒的二噁英,可能致癌。
如果做不到一直远离污染并保持良好的生活习惯,起码要在准备要宝宝前的一段时间内做到。至于『一段时间』究竟是多长,我也不知道生活习惯影响到表观遗传需要多长时间,只能说『越长越好』。
戒掉所有不良习惯,远离污染源,规律作息,坚持锻炼,保持营养均衡,注意调节精神状态。里面随便一条都是一个专业领域,够写一堆文章,不详细展开。有这个概念,目的就达到了。
嗯,还是忍不住强调几点。
不良习惯和环境污染里面,特别点名强调一类致癌物。这个清单大家可以在网上搜到。日常最容易接触到的是这几样:
你应该去请教专业人士。但我猜大多数人不会去健身房也不会请教练。只提醒一点:有氧和无氧结合(或者说心肺训练和抗阻训练结合),不要一味只做一种你喜欢的运动。
营养学也是非常专业的学科,且部分理论还存在争议。如果不能咨询专业的营养师,可以参考最新版《中国居民膳食指南》。这个基础上,根据个人身体状况和口味习惯,再做一些调整。身体有特殊问题的,还是得以医生和专业营养师意见为准。
具体到备孕期和孕期,一些营养素被认为作用比较关键,挑部分有代表性的说:
钙和维生素D:
钙是骨骼和牙齿的主要元素,在人的整个生命周期都起着很重要的作用。少年儿童需要长个子,成年人需要防止骨质流失,老年人则直接面临骨质疏松的威胁,都需要持续摄入钙。
女性由于生理特点,又比男性更容易缺钙。尤其是怀孕和哺乳期间,母体的钙大量供应给孩子。要知道孩子从一颗受精卵一直长到戒奶为止,身上的钙都是从母亲身上『夺取』的。在备孕时,一定要提高『钙储备』。
维生素 D 则是可以促进钙元素的吸收、重吸收和沉积。儿童缺乏维生素 D 会引起佝偻病(软骨症)。所以光补钙没有效果,还得保证有足够的维生素 D。
维生素 E:
一类脂溶性维生素,包括四种生育酚和四种生育三烯酚。从名字(生育酚)就能看出来,维生素 E 可以促进生殖系统的活性,使男子小蝌蚪活力和数量增加;使女子雌性激素浓度增高,提高生育能力,预防流产。
新生儿维生素 E 不足会使血球容易破裂而发生溶血性贫血。
维生素 B 族:
一系列水溶性维生素,在细胞代谢中有着重要的作用。虽然有着相似的编号,但在化学上是差别较大的不同化合物。
孕妇缺乏叶酸(B9)会引起婴儿先天缺陷,这点得到了世界卫生组织和国家卫健委的认可。世卫建议围孕期(从孕前到哺乳期结束)应该补充叶酸,特别是孕前到孕 12 周。而在我国很多地方,满足一定条件能在社区免费领取叶酸。这两点可见对叶酸重视程度之高。有研究指出,男性补充叶酸也有助于提高小蝌蚪质量。
除此以外,烟酸(B3)和 钴胺素(B12)也比较重要。
微量元素:
镁、锌、铁、硒、碘等。
必需脂肪酸:
二十二碳六烯酸(DHA)、α-次亚麻油酸(ALA)等。
……
就举几例,再列就太长了。具体摄入量大家自己查或咨询医生,不敢推荐。
总的来说,不挑食,荤素搭配,食物来源尽量种类多而杂,大部分营养成分可以从食物中获得。必需的营养素较多地存在于蔬菜水果、未经精加工的谷物、动物肝脏、牛奶、鱼肉等食物里。
民间传说的饮食禁忌大多不靠谱,反而容易造成营养不均衡。
真正需要警惕的是上述提到的有毒物质:烟酒、咸鱼、某些药物。不要吃未彻底煮熟的食物,特别是生肉,有可能得寄生虫,影响胎儿的发育。
还要警惕所谓的大补,像老火汤,溶解的营养有限,维生素被高温破坏,摄入的主要是脂肪和嘌呤,除了长胖和升高尿酸,没有别的作用;孕期发胖会增加妊娠糖尿病和妊娠高血压的风险。各种补药也是风险大于好处。
饮食均衡的情况下,不建议依赖营养补充剂,性价比低,个别营养素容易过量摄入。
不过有些营养素来源单一,一旦偏食就容易缺乏。
像维生素 D ,除了吃蛋黄和动物肝脏,主要靠日光(特别是 UVB)照射皮肤时合成。如果少吃相应的食物,加上接触日光的时间太短,就需要考虑服用维生素 D 补剂。
叶酸倒是广泛存在于叶菜、水果、肝脏和肉类当中,但叶酸非常容易在烹饪中被破坏,食物中的摄入量不足以满足需要,所以围孕期补充叶酸补剂是相当必要的。
除了这两样,考虑到现代人的饮食特点(吃得过于精细、单一,容易挑食,不爱吃素、内脏或者鱼),很多营养素还是可能摄入不足。起码在围孕期,要考虑服用补充剂——叶酸只是最低限度。因为逐样购买服用不仅麻烦,还非常难根据不同阶段调节用量,我们直接购买了专门围孕期配方的产品。
当时综合网上的评价,选择了英国 Vitabiotics 旗下的 Pregnacare 系列。这个系列做得很全很细,从备孕到孕中再到哺乳期恢复期,都有针对的产品。甚至还有给男性备孕的产品(不单卖,跟女性备孕产品组合出售)。除了孕中会加一颗 DHA 鱼肝油,所有产品都是一天一颗,省心省事。
懒得写到孕中和产后再列一次,干脆列出来全系列。附带 TB 海外旗舰店的口令,这样跳过去就知道是哪一款。
比较遗憾这个系列还是出现了个别玄学成分,像男士备孕的产品里出现了秘鲁玛卡、西伯利亚人参提取物这种缺乏医学证据支持的成分,介意慎选。我们权衡了风险和便利之后还是选用了。还好这只是其中一个比较方便的选择,大家也可以了解其它同类产品。海外销售的产品,尽量选正规海淘渠道,或者托国外亲友带。现在物流信息和各种单据都可以伪造,小商家和私人代购有信用风险。
有了好身体,就该考虑上场比赛了。
开头先问大家一个问题:小蝌蚪和卵细胞,哪个存活时间长?
或者换个问法,谁等谁?
先别下拉考虑一下。
小范围调查的结果,要么回答不知道,要么认为卵细胞的存活时间比较长。毕竟卵细胞比较大,而且自带营养;公主待字闺中,等天下英雄擂台竞逐,比较符合想象。
但事实上,精子存活时间比卵子长。
自排卵算起,卵细胞会在输卵管存活 1 到 2 天时间,其中前 14~18 小时受精能力最强。如果没能受精,卵细胞的受精能力会逐渐减弱直至死亡。
而进入子宫的小蝌蚪,最多能存活大约 3 天!
小蝌蚪看着很脆弱,也确实很脆弱,在体外只能存活几分钟。但是,生殖道分泌液会为小蝌蚪提供能量,使小蝌蚪成熟,这个过程称为『获能』。小蝌蚪在阴道中能存活约 8 个小时,通过子宫颈之后则最多可以存活 3 天。
小蝌蚪还有一个优势,量大。一次出击以亿为单位。小蝌蚪看不见瞎游,一路上各种艰难险阻,还要在其中一根输卵管上浪费一半兵力,但是量足够大,就总有一部分能活到最后。
结合两个时间,可以得到什么结论?怎样才能使相遇的可能性最大化?
假设排卵的时间点为 T ,排卵后一天为 T + 1,之前一天为 T - 1,以此类推。
很容易推导出,最佳出击时间点很可能就是 T。此时双方细胞活力最强,一边沿输卵管往外移动,另一边一群往里游,汇合时间最充足。但人又不是机器,这个时间点很难掌控。如果时间偏差一点会怎样?
T + 1 应该也有机会,T + 2 就渺茫了。反过来说,T - 1 和 T - 2 完全没有问题。T - 3 也不是没可能(小蝌蚪苦等三天,见到刚刚出门的公主)。实际上,最佳时间点还要往前移差不多一天。从查到的数据看,从 T - 5 到 T 都有机会受孕,其中概率最高是 T - 2 到 T 的三天;T - 1 概率最高有 35%,T - 2 也有 30%,而排卵日当天才只有 25%,T + 1 则可能性非常低了。
结论就是,尽量预测 T 的时间,并在那之前完成鼓掌比赛。如果误差比较大,宁早勿晚,让小蝌蚪们提前等着。
怎么预测?
月经周期规律的女生相对好办。现在有很多记录周期的软件,坚持记录,就可以根据周期推算排卵期。记录的时间越长,周期越规律,预测越准确。前面说了,需要一段时间的赛前准备,正好先记录一段时间。
有些女生的周期不太规律,周期规律的也希望得到比推算更准确的结果。这时可以用排卵试纸。
排卵试纸本质上是检测尿液中的促黄体生成素(缩写 LH),检测时将试纸一端浸入尿液,等候一会看检测线即可,很方便。(现在新出的测试棒,比尿杯更方便使用。具体产品用法,以说明书为准。)因为是比较成熟的技术,结果比较准确,也不贵。我们当时直接用的网上销售量最大的套装,包括两个月用量的排卵试纸和 5 条早孕试纸,平均到每次检测才几毛钱。(¥FQJccNRNO7p¥)
试纸的灵敏度是固定的,而每个人的 LH 水平有差异(还有尿液浓度的问题),不要看少数几次检测的值,而是要看 LH 水平的变化趋势。一般推荐从月经结束后三天左右开始测,测得疏一些(每天或者隔天),发现增强就增加检测频率,从每天到每半天再到每 4 小时。如果发现到了最强突然开始转弱,意味着会在最强时间点的 24 ~ 48 小时内排卵,未来的 24 小时内是比较好的时机,赶早不赶晚。
需要注意的是,LH 试纸对部分人群不适用。多囊卵巢综合征、卵巢早衰等患者,LH 水平不具参考意义。
即使挑中最佳的日期,成功率也不过比 1/3 略强。心急要孩子的人很容易想到一个办法:在可能受孕的 5~6 天里,天天运动,甚至每天多次运动。
这未必不是一个办法。但是过高的频率,会影响精子的质和量,过度疲劳也会影响双方的状态。另外,女生过于频繁接触外来细胞,有可能会激发抗体,引起免疫性不孕。我找到的说法是,最频繁至少也要隔一天一次。没有找到依据,仅供参考。
另一方面,也有人会考虑到精子质量,每个月只在最佳时间上场比赛,其它时间都通过禁欲来养精蓄锐。这同样是不可取的,因为过长时间的禁欲,会导致精子老化,质量反而下降。
都努力到这里了,还有一个小技巧提高成功率。
不知道大家有没有观察过,刚发射出来的弹药,是半固体的凝胶状;然后过 10~30 分钟,会重新液化。这里面是一系列的凝固因子和液化因子在调节,前者包括纤维蛋白和凝固蛋白等,后者则包括一系列的酶。
身体不会平白无故制造纤维蛋白再造个酶分解掉,费这工夫当然有用。
比赛进行当中,双方剧烈运动,小蝌蚪们很难在目标上停留。这时纤维蛋白组成的带黏性的凝胶,有助于把小蝌蚪作为整体送达,并留在目标赛道上。等比赛结束,运动员休息,凝胶才液化,小蝌蚪们的比赛才正式开始。可以简单理解为 先打包运送,到达后再拆包裹。
知道这一点有什么用?
首先重申身体素质的重要性,如果身体有毛病,或者缺少某些营养素,导致这个凝固-液化的过程出了问题,小蝌蚪的活性会大打折扣(这个过程涉及多种锌蛋白,锌很重要)。
然后就是女生要注意赛后休息,不要乱动。参考液化需要的时间,比赛结束后应该仰卧平躺休息一段时间,可以垫个枕头让入口抬高,尽量给小蝌蚪液化和游动的时间,减少小蝌蚪的流失。这个姿势据说还能让小蝌蚪聚集在宫颈口,提高通过的数量。
上亿的数量看着很多,对看不见瞎游的蝌蚪来说,路上多的是陷阱。损兵折将之后,终于来到宫殿之内,生存环境变好,输卵管左还是右的抉择又少了一半。即使到了终点,透明带还是需要大量的小蝌蚪用自己的顶体去溶解。
珍惜小蝌蚪,提高成功率。
前面给了这么多建议,都做到了是不是就一定能成?
当然不是。即使一切就绪,挑到最好的时间点,受孕的概率也只有 35%。
成功受孕,实在是太多因素共同作用的结果,我们只能干预其中的一(小)部分。各种看似玄学的因素里,就包括了压力水平,或者说情绪。
2015 年荷兰一个综述性研究回顾了 1992 年到 2014 年之间的多个研究的数据,试图找出预测排卵期后定时同房与妊娠率之间的关系。在排除掉无效数据和过小的样本之后发现:
有兴趣的可以自行阅读论文:https://www.cochranelibrary.com/cdsr/doi/10.1002/14651858.CD011345.pub2
总的来说,预测排卵期然后定时同房在逻辑上无懈可击,但在研究中的效果却显得优势微弱,完全没有看上去那么美好。毕竟影响的因素太多,而预测并不能确保准确。为了养精蓄锐,仅在特定日期比赛,竞技水平容易受影响,还会导致精子老化。上场时过分关注受孕,双方带着任务和压力,也会影响比赛质量。
所以虽然上面给了这么多技术建议,并不需要像规范那样小心遵守。如果因此造成压力,反而得不偿失。享受过程,夫妻之间琴瑟和鸣,孩子是水到渠成的事。如果长时间的尝试都没有成功,或者发现了其它问题,则要尽快求助于正规三甲医院的生殖科。千万不要病急乱投医,中了野鸡医院的套路,特别要小心正规医院里的外包科室。
这篇写的是备孕,本来到这里就该结束了。剩下的话题,如果读者感兴趣,再接着写。
不过,可能会有读者在看完本文后马上行动,然后足够幸运,一个月内中奖。以我写字的速度,搞不好推送下一篇时,孩子都打酱油了。那再说两句。
如果买了上面提到试纸套装,里面一般会包含早孕试纸,没有就另外买一些备着。亲戚该来的时候不见来,验一下,两道杠就是中奖了(一条对照线,一条检测线,也有产品两条线组成十字形)。
然而早孕试纸阳性,并不能确切地说就是怀孕了。
试纸(或者验孕棒)只是检测人绒毛膜促性腺激素(缩写 hCG),正常情况下由胎盘分泌,怀孕是最大的可能,但一些疾病也会引起 hCG 升高。另外,即使成功受精,hCG 来自胎盘,也可能存在葡萄胎和宫外孕的情况。不正常发育的胚胎不仅不会带来一个新生命,还会危害准妈妈的健康,最严重时甚至致命。
此时,首要任务是去正规医院的产科,做一个确认,一般包括验血和超声检查。
如果胎儿正常发育,先恭喜你。接下来才是忙碌的开始。
在国内,大城市有大量适育青年,大型公立医院的产科床位常常紧缺。
这点我们在广州是亲身经历。条件好实力强的公立医院,产科护士站往往放着一个时间表,上面写着哪些月份(对应预产期)还有床位,一般未来几个月都已经排满。产科里前面的人刚出院,马上就会有临盆的准妈妈住进来;如果没有并发症,不需要特别的照顾,或者仅仅是产后的护理,有些人甚至住走廊用帘子围起来的临时床位。
为了确保床位,确认怀孕之后的第一件事,就是选择将来生产的医院,然后在该医院建档(有些地方叫建卡),尽量一直在这家医院产检。
理论上,建档是完全自愿的,医院也不会拒绝没有建档的产妇。但实际操作上,医院肯定优先接收有建档的产妇,在床位不足时,会劝未建档的产妇转院。而即使紧急情况不便转院,医院缺乏之前的产检记录,也会增加判断和救治失误的风险。
所以不仅要建档,还要早建档,按时产检,尽量不要转院。
医院的选择,要综合考虑医院情况、经济实力、距离远近。举几个需要考虑的问题例子:
这只是其中的一部分问题。每个家庭还会有自己的考量。
我们当时迁就上班地点,建档医院离家里稍远(半个多小时车程)。结果在家里破水,第一时间打 120,被告知只能就近派车,不能指定医院。权衡风险之后,产妇平躺在后排,我自己开车送到建档医院。那是开过最煎熬的 30 分钟。后来才知道,可以请求 110 帮助,即使不能派车开道,一般也可以报备车牌,安全范围内允许交通违规,像走应急道,确认对向没车时闯红灯等等。
至于建档的具体流程,每个地方每个医院有不同,一段时间之后政策也会更新,需要去医院和社区确认。
建档之后,医生和护士就会告诉你接下去该做什么,可能下一篇都没有必要写了(如果没人问我的话)。
国内提倡优生优育,除了备孕时会给发叶酸,对于部分产检和产后恢复项目是有补贴的。我们当时遇到的形式,是社区会发放服务券,在做相应项目时把券交给医院,会有部分抵扣,抵扣部分记账由政府买单。
这点需要及早了解和办理。很多年轻夫妻不知道有这些福利,根本没办;或者后面知道再去办时,可能一些福利已经错过了使用时间。
同样地,这些福利因地而异,请自行到社区了解。
很多地方都有一个风俗,怀孕前三个月只告知最亲密的人,对其他人秘而不宣。理由是前三个月胎儿不稳,有一定机会流产。
这是有道理的。
前三个月对应孕早期,胎儿还是非常脆弱。太多人知道,引起关注,有可能会变成打扰和压力。而一旦真的失去这一胎,所有的关注和祝福,都会变成更大的压力。
考虑到篇幅已经太长,而且这部分已经超纲,请自行搜索孕早期的注意事项。该注意的都差不多,只是比其它时间更娇气一些。
最后强调一点:谨慎保胎。
常见的情况是,抽血做早孕检测,发现某个指标低了,直接就要保胎,打针或吃药。甚至医生还没说什么,孕妇和家人这边先主动要求保胎。毕竟诊断上写着『先兆流产』四个字,非常吓人。(我被吓到过)
我说的就是这里要谨慎。
首先,很多时候指标不好是胚胎发育有问题的『果』,而不是『因』。hCG 来自胎盘分泌,刺激黄体产生孕酮。有染色体缺陷的胚胎,发育不好,孕早期就会体现在指标上。这本身是生命进化出来的筛选机制,在伤害最小的时候放手。用老一辈的话说,是缘分未到。强行挽留,将孕妇置于生命危险之中,还可能生出缺陷宝宝。
其次,各种激素之间的关系比较复杂,数值跨度非常大,检测本身也存在一定的波动和误差,不能看单次检测的高低。像 hCG 主要看翻倍情况,而孕酮则要结合 hCG 一起判断。一两次的指标偏差,不一定是有问题。
由于这些原因,国外主流的医学意见,不鼓励在孕早期进行干预。
国内医生难道不知道这些吗?我想当然知道,至少比我懂。我也相信大多数医生是在为病人考虑。
奈何医生拗不过国人『用药求心安』的心理,以及越来越被激化的医患矛盾。用药了,看起来尽力了,将来坏结局不容易怨到医生头上;反过来,哪怕不用药才是为患者着想,将来出事,难免会被患者和家属怀疑没有尽力。人有一个名为『如果当初』的心魔,而普通患者并不能区分有效治疗和安慰剂。
为了防杠,必须说这只是个人的有限观察,以及基于观察的推测,不是所有医生都这样。虽然我们只经历了一次生育历程,外加身边朋友的少数例子,但在看其它的大小毛病时,开惯例药、开安慰性质的药物和治疗,不在少数。(当然医生可能并不觉得这是安慰剂。)有时不方便详细沟通,药没什么害处也不贵,也就算了。特别不合适的,礼貌地跟医生商量,一般都会取消掉。有一次开的特别离谱,我指出里面的副作用,医生居然不知道药里有某种成分。这个话题很大,有机会另文讨论。
但是,但是!只是要谨慎,不是说一定不能保胎。 有些情况确实需要保胎,大龄孕妇需要干预的情况多一些。 也不是鼓动大家去质疑医生。而是要有主动了解的意识,要和医生沟通具体情况,了解前因后果和各种风险,参与到决策中去。 而不是觉得听不懂就放弃交流,当个工具人,让做啥就做啥,哪个指标低就补哪个。
孕妇和家人有权利了解不同选择背后的风险,也只能自己去承担选择的后果。
实际上只要表现出愿意了解的姿态,让医生觉得你讲道理,可以交流,医生一般都会愿意投入更多的耐心。
如果医生的解答无法打消疑虑,可以换一个医生甚至换一家医院确认,然后综合考虑不同医生的意见。意见一致当然最好,出现分歧,要么继续找别的医生,要么就从中间选择一个医生的意见忠实执行。千万不要在治疗进行中,因为心有疑虑而将治疗方案打折扣,更不要擅自组合不同医生的方案。
写太长了。
我像个爱操心的老管家,皇帝不急那啥急,写着写着想起什么就往里面加。我在担心『他们』不知道这些的时候,他们有着一张张具体的脸。有时候是他们私聊问我。有时候他们没问,我耐不住去提醒。
得了,赶紧结了。
再次强调,外行人,仅供参考,不作为医疗建议!有啥事,拿不准的不妨勤跑医院,大不了白跑。
原本打算列一下资料来源的,太长太多太碎了,放弃。反正不是论文。如果有人感兴趣讨论里说吧。最近也有可能开不了讨论,那就后台留言,多人问的我另起一篇。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
印象里是以自然地理命名为主。在地名里出现东南西北还有阴阳(山南水北谓之阳)的,一般都是用来指示在某个地理标志的旁边,而这样的名字很常见。
真的是这样吗?我也不确定。300 多个地级行政区就算了,34 个省级行政区不多,那就查一下。
溯源时我会尽量追溯最初的源头。例如两广以及广州的『广』,很容易查到来源于广信,但查询不会就此终止,如果可能还是要查出广信的『广』哪里来。又例如甘肃的『甘』来自甘州,而甘州的『甘』,则据传来自城内的甘泉。这种追溯并非总是可行,实际上大多数古地名都无法进一步查证来源,如果追溯到山川河岳的命名,更没有什么道理可讲。
简称也会列出来源。由于简称往往不止一个,不再统计数量。感兴趣的读者可以自己统计分类。
本文并非原创研究,也不是严谨学术文章,仅仅是出于个人兴趣的整理。
内容来自对网络公开信息的整理,主要参考维基百科(https://zh.wikipedia.org/),个别兼有参考百度百科(https://baike.baidu.com/),交叉比对的词条数量太多,不一一列举。
对于多种说法的情况,会尽量将不同说法列出,只略去个别明显不合理的说法。同样,分类标准也并非毫无争议,存在一些模棱两可的情况,这时纯粹依据个人判断。
如有错漏,欢迎留言指正。
目录
[TOC]
这类命名,名字里的关键信息来源于自然的地理标志,如山脉、江河、湖泊等。两广这样『广』字来源于历史事件的不算。这类命名往往作为地区泛指先出现,在成为习惯叫法很多年以后再确认为正式的行政区。
这一类的数量确实最多,达到 18 个,刚好过半,但是仍然比我预期中的要少。我本以为会占压倒性的大多数。
自然地理的范围太广,还能进一步分类。其中山脉 2 个,江河 8 个,湖泊 3 个,海洋港口 3 个,能明显看出地理特征但不好分类的 2 个。二级分类里,以江河命名的最多,甚至有 3 个行政区,名字直接就是江河名本身(包括古称或简称)。这也很正常,毕竟人类文明就是在大河流域孕育的。
太行山以西。原为地区泛指,春秋时属晋国。秦及以后各代,山西地区都属于政权的腹地,并有多个朝代在山西地区建立陪都。
唐宋时境内大部分属于河东道(路),取黄河以东。时至今日,黄河仍然是山西和陕西的分界线。直到元朝设河东山西道,直属中书省,『山西』开始出现在行政区名上。明朝设山西等处承宣布政使司,正式成为行省,山西彻底取代河东成为行政区名。
简称晋,春秋时境内为晋国之地。
太行山以东。『山东』的说法最早出现于战国,金代以前是一个地区泛指,先秦指崤山和华山以东,唐宋指太行山以东。
境内在唐时分属河北道和河南道,宋时主要属京东东路。南宋时山东全境被金占领,金设山东东路、山东西路两路,『山东』开始作为行政区名。元时为山东东西道,属中书省。明朝设山东等处承宣布政使司,正式成为行省。
简称鲁,有时也称齐鲁或齐,因春秋战国时境内主要为齐国和鲁国。
古黄河以南。秦汉以前河专称黄河,河流称川或水,后来才逐渐变成通称。
楚汉之争时项羽封申阳于原韩国三川郡(黄河、洛水、伊水为三川),为河南国,后亡于汉,改河南郡,为西汉京畿地区直属七郡之一,大约相当于今洛阳及周边地区,是今河南境内最早以『河南』为地名。
唐改河南府,属都畿道(即东都);同时期有河南道,约为今山东全境、河南全境(除洛阳)加上安徽和江苏北部。宋初将两京归入河南道,但很快又重新划分十五路,其中并没有河南路,河南府属京西北路。元设河南江北等处行中书省,简称河南省或江北省,辖下的河南江北道接近今河南大部加湖北北部。明设河南等处承宣布政使司,基本与今河南一致。
河南简称豫,境内为禹贡九州的豫州之地。《周礼》解释说『禀中和之气,性理安舒,故云豫也。』豫为安乐、安逸之意。
古黄河以北。
秦置河东郡河北县,为『河北』最早见于地名,但此时的河北只是一个县,大约在今山西南部。
唐设河北道,开始包括今河北全境及邻近省的部分地区。宋改河北路,金分河北为东西两路。元时因紧邻大都,分属中书省的京畿山后道和燕南河北道。明清属(北)直隶。民国被分为察哈尔、热河、河北三省。新中国成立后恢复原河北行政区。
河北简称冀,境内为禹贡九州的冀州之地。又因春秋战国时境内有燕赵两国,又称燕赵之地。
长江以南的西部。秦汉以前江专称长江,汉称『大江』,六朝开始出现『长江』的记录。
唐初设江南道,取长江以南之意。玄宗时分江南道为江南东道、江南西道、黔中道。江南西道即江西前身,包括今江西及湖南全境、湖北及安徽南部部分地区。肃宗时设洪吉都团练守捉观察处置使,代宗时改江南西道观察使(通称江西)。后升为节度使,懿宗时改江西节度使,管辖今江西全境,为『江西』一名的开始。
宋初复设江南路,真宗时又分为东西两路。元设江西等处行中书省,包括今江西大部和广东西部以外全境,正式使用『江西』作为行政区名。明设江西等处承宣布政使司,基本与今江西一致。
简称赣,来源于境内最大的河流赣江(又写作灨)。又因为从北往南看在长江右边,被称为江右,与之相对江东又称为江左。
取自浙江,即钱塘江古称。一般认为,钱塘江古称『浙江』,见于《山海经》、《越绝书》、《史记》、《水经注》等古籍,又称『折江』、『之江』。
浙江境内在春秋战国时属越国,后亡于楚。秦灭楚后设会稽郡。
唐初设江南道,玄宗时分出江南东道,肃宗时又分江南东道为浙江东道、浙江西道以及福建道,是『浙江』二字第一次作为行政区名,浙江二道合称『两浙』,辖境大致为今天苏南、皖南、上海及浙江全境,与今天吴语区相仿。
宋设两浙路。元代在两浙及福建设立江浙等处行中书省,治杭州路。明设浙江等处承宣布政使司,开始和福建分开,辖区与今基本一致。
简称浙。因为境内为古百越中的于越的核心区域,也是春秋时越国所在,有时也称为越。
取自上海浦(即黄浦江),吴淞江(今苏州河)北支流;另有下海浦,在今虹口区,已被填平。
上海地区早在晋朝已经有渔民聚集发展出商贸集镇,到唐天宝年间设华亭县,属苏州。南宋时华亭县属嘉兴府,于上海浦西岸设市镇,集市名上海市,镇名上海镇,为『上海』首次见于地名。
元朝时华亭县升格为松江府,辖华亭县。后以『华亭地大人众,难理』析华亭县东北置上海县,仍属松江府,为上海建制之始。至明嘉靖年间为抵御倭寇,上海县筑起城墙。此时松江府已较为富庶。
1843 年《南京条约》上海作为通商五口开埠。1845年中英《上海租地章程》开始租界历史。此后法国与美国相继在上海设立租界。租界逐渐形成不受中国管辖,拥有独立司法、行政权力的地区。之后历经数次扩张,范围基本为今上海的核心区域的大部分地区。凭借独特的政治制度和地理位置,上海开埠后逐渐发展为远东最繁荣的经济和商贸中心。新中国成立后,上海市从江苏省划出,设为直辖市。
简称沪,源于古时当地人创造的一种名为“扈”的捕鱼工具,东晋时松江入海口称之为沪渎(今上海市青浦区东北旧青浦镇西)。有时也称申,相传上海西部战国时曾为楚国公子春申君的封地。
取辽河永远安宁之意。
东北地区在古代大部分时间属于渔猎民族活动范围,先秦时期有肃慎、秽貊、东胡等民族,与汉族并称东北四大族系。汉至唐期间,境内先后有高句丽、挹娄、室韦、靺鞨等民族活动。
战国时燕国在辽宁地区设置辽东、辽西两郡,为『辽』最早作为行政区出现。中原对两郡的统治一直延续到唐末。西汉征服卫满朝鲜之后,一度设置乐浪、玄菟、真番、临屯四郡,合称汉四郡。但到西晋时因不堪长期与高句丽和百济作战,侨置辽西而丢失。
东北境内先后建立过扶余国、高句丽、渤海国等国,渤海国后成为唐的属国。唐以后先后是辽朝与金朝的统治范围。
元朝开始将东北纳入中央王朝管治。明朝在东北境内设置都司、卫所等军事机构,对当地民族实行招抚、羁縻政策。清初将这里作为自己的起源禁地,禁止汉人入内,并作为流放犯人之地,设东北三将军镇守。
南部为镇守盛京等处将军,又称奉天将军。光绪三十三年(1907 年)裁三将军,改东北三省。南京国民政府时期考虑到『奉天』取自『奉天承运』,君主思想浓厚,改今名。
简称辽。辽河旧称巨流河,西汉前称句骊河。
因为东北地区历史上诞生过辽朝,很容易让人觉得辽宁的辽来自辽国。但实际上在战国时燕国就有辽东、辽西两郡,很可能就是以辽河作为分界线,比辽国的诞生要早得多。辽的国号从契丹改为大辽,也有可能是受辽河影响。
满语吉林乌拉的简称,意为沿着松花江,原指今吉林市。
(部分内容参考『辽宁』一节)
清顺治时设宁古塔昂邦章京,康熙时改宁古塔将军,后移驻吉林,改称镇守吉林等处地方将军,吉林从城池名变为行政区名,包括今吉林全境、黑龙江东部、辽宁北部,以及俄罗斯滨海边疆区和哈巴罗夫斯克边疆区。沿海地区在 1860 年的 《北京条约》被全部割让。1907 年改省,1954 年省政府从吉林市迁到长春市。
简称吉。
来自境内最大河流黑龙江,满语萨哈连乌拉,即黑水。
(部分内容参考『辽宁』一节)
康熙时设镇守黑龙江等处地方将军,辖今黑龙江西部及内蒙古自治区东北部,黑龙江开始作为行政区。1907 年改省。
简称黑。
取自洞庭湖以南。
湖南境内在唐玄宗时分属山南东道、江南西道和黔中道。
唐代宗从江南西道分置湖南都团练守捉观察处置使,又称湖南道,辖衡(今衡阳)潭(今长沙)在内五州,后增至七州,为『湖南』之名首次出现。
宋时分属荆湖南路和荆湖北路。元设湖广等处行中书省,包括今湖南、湖北、广西、海南及广东西部,先设宣慰司于衡州,后迁潭州;今湖南境内主要属于岭北湖南道。
明属湖广等处承宣布政使司,包括今湖南湖北(广东及广西布政使司已从湖广分出)。清初沿明制,至康熙时分湖广左、右布政使司,后改湖广右为湖南布政使司,正式成为独立行省。
简称湘,来自贯穿全境的河流湘江。
取自洞庭湖以北。
唐以前湖北境内大部分时间属于荆州,唐玄宗时将江南道分置为三道,今湖北境内分属山南东道、淮南道、江南西道和黔中道。
宋改道为路后,湖北分属荆湖北路和京西南路。元设湖广等处行中书省,湖北主要属江南湖北道,北部则属河南江北行省河南江北道。
明至清初湖广等处承宣布政使司已不再包括两广地区,但两湖仍同属一省。康熙时分湖广左、右布政使司,后改湖广左为湖北布政使司,正式成为独立行省。
简称鄂,因武汉周边在西周属于鄂国。鄂国原在河南南阳一带,受晋国压力南迁。后境内诸侯国都被楚国吞并,春秋战国时期属于楚国,所以又称为楚。又因湖北全境属禹贡九州的荆州,又称荆、荆州、荆楚。
因青海湖得名。
青海东部自汉朝开始纳入中央王朝控制,至隋朝控制青海全境。
安史之乱时被吐蕃夺取西宁,至元朝重新并入中央管治。元以前属鄯、廓二州,北宋改鄯州为西宁州,为西宁名字之始。
元时大部分属吐蕃等處宣慰司都元帥府。清改西宁府,属甘肃省,西宁以外的大部分地区,设钦差办理青海蒙古番子事务大臣,又称青海办事大臣,驻青海湖东,是『青海』作为行政区的开始。
乾隆时青海办事大臣移驻西宁,改称西宁办事大臣。民国初改西宁办事长官,仍驻甘肃西宁。1929 年设青海省,省会西宁县,西宁不再归属甘肃。
简称青。
即海南岛及周边南海诸岛。取南海(实际上是琼州海峡)以南之意。
海南岛从西汉开始纳入中央管治,属珠崖郡(今海南琼山)、儋耳郡。东汉平交趾(今越南北部),珠崖县归交趾刺史部(今广东、广西、福建漳州及越南北部)。之后海南岛及雷州半岛地区一直归属两广所在行政区。
明以后归属广东,至 1988 年独立建省,取岛名为行政区名。
唐代在岛上建琼州,简称琼和琼州海峡得名于此。
地理上指香港岛、九龙半岛及周边岛屿。香字来历有多种说法,一说由于东莞香料在此转运,一说源于叫香江的溪流。而港则因为此处是天然良港。
香港开埠之前归属广东,历属番禺县(今广州)、宝安县(今深圳)、循州(今惠州)。
1810 年代,英国东印度公司勘探珠江口香港一带地形,英国人于香港岛赤柱登陆后,获原居民陈群引路到香港岛北部,行经香港村(今黄竹坑旧围)时从陈群的蜑家话回答中得知『香港』发音,后成为整座岛屿的总称。英治时期的香港旗有『阿群带路』图纪念此事。而香港村的得名,则有上述两个说法。
香港因两次鸦片战争签署的《南京条约》和《北京条约》被英国强租为殖民地。1997 年回归后设特别行政区。
简称港。雅称香江。
本名蠔镜澳(后蠔改濠),指盛产蚝且水域如镜的港湾。澳指海边湾区可以停船的地方,即泊口。因澳外有名为『十字门』的水域,所以称澳门。
澳门被葡萄牙强租之前属广东,历属番禺县(今广州)、封乐县(今江门新会西北)、宝安县(今深圳)、东莞县(今东莞)、香山县(今中山与珠海)。
明代时葡萄牙人在屯门海战大败,开始转向澳门寻找地盘,先以贿赂获准在澳门暂居,后经公开将贿赂转为地租。清末葡萄牙停止向清朝交地租并占领关闸,后迫使清政府签订《中葡和好通商条约》,规定中国同意葡国永居管理澳门,再后来扩展边界,并划定澳门的界址,使澳门沦为殖民地。1999 年回归后设特别行政区。
简称澳。
以下两个能看出是以自然地理命名。陕指陕塬,『陕』和『塬』都是对地貌的描述。川为平川广野,指的是四川盆地。
陕塬以西之意。周朝初,周公召公以陕塬(又名陕陌,今河南三门峡市陕州区内)为界,分陕而治。『自陕而东者,周公主之;自陕而西者,召公主之。』关于陕,《直隶陕州志》有『山势四围曰陕,环陕皆山故曰陕』。塬则是黄土高原上的一种地貌,周围被流水冲刷形成沟壑,边缘陡峭,顶上保持比较平坦的状态。
唐朝陕西地区属京畿道,直属中央。安史之乱后在今陕西、河南之间设陕虢华节度使,后改陕西节度使,兼神策军(禁军的一支),为『陕西』之名的开始。
宋朝设陕塬以西为陕西路,包括今甘肃部分地区而不包括秦岭以南地区,『陕西』开始成为行政区。元设陕西等处行中书省。明将甘肃划入陕西等处承宣布政使司,改奉元路为西安府,为西安之名的开始。清朝将陕西布政使司左右分治,后发展为陕西省和甘肃省(含宁夏)。
简称陕。又简称秦,因境内在春秋战国主要为秦国领地。
川峡四路的总称。
先秦时期,蜀地已有部落政权。秦攻占蜀地,置巴、蜀二郡,蜀地开始进入中原视野。
唐置剑南道(剑门关以南),肃宗时分剑南节度使为剑南西川和剑南东川两节度使(川是平川广野之意)。宋灭后蜀后置西川路(治成都府),后多次分合。先分西川路置峡路(治夔州,今重庆奉节),称川峡二路。再分西川路为西川西路(简称西川,仍治成都)和西川东路(简称东川,治利州,今广元),为川峡三路。又并东川入西川为二路。后西川路兵变,平叛后宋廷深感『西蜀辽隔,事有缓急,难以应援』,分西川路为益州、利州二路,峡路为梓州、夔州二路,均以治所命名,合称川峡四路,简称四川,设四川制置使。这是『四川』名字的起源,但其时还不是正式行政区。
元设四川等处行中书省,正式成为行省。此时的四川省还包括重庆路。重庆从清末《马关条约》开始成为通商口岸,抗日战争时期升为陪都,从四川分出,新中国成立后设为直辖市。
简称川。因先秦时期境内分属巴国和蜀国,又简称蜀或巴蜀。
所谓组合命名,是指行政区在调整设置时,从辖区里最主要的州府,各取一字,组合为名。这种组合得到的名字一共有 4 个。
如果细究的话,来源的两个字本身又可以进一步分类。
甘州(今张掖) + 肃州(今酒泉)。
其中甘州在西魏时由西凉州更名,以城内甘泉遍地,泉水清洌甘甜而得名。肃州在隋朝建立,肃字来源未见记载,推测为肃清边患之意。
甘肃境内是周人和秦人的发祥地。春秋时东部属秦,西部属西戎。汉时归凉州。
唐时甘肃一带分属关内道、陇右道和山南道,其中最大的是甘州和肃州。
宋时归西夏,西夏取甘州和肃州首字,置甘肃军,『甘肃』正式成为行政区。
元一度设西夏中兴行省,后移治甘州路,改甘肃等处行中书省。明划入陕西等处承宣布政使司。
清分陕西西部为巩昌省,后改甘肃省,管辖整个西域。清末新疆单独划出,民国将青海、宁夏单独建省,甘肃形成现在的区域。
简称甘。又简称陇,来自境内的山脉陇山,即六盘山。
江宁(今南京) + 苏州。
其中江宁取江南安宁之意。苏州原名姑苏,原为姑胥,是禹舜时胥的封地,后因吴语中胥、苏音近改为苏。胥本义为小吏。
西晋改东吴都城建业(今南京)为秣陵县,又分出临江县,次年改名江宁县,为『江宁』名字之始;又从秣陵县分出建业县,又改称建邺,后避司马邺讳改建康。东晋及南朝宋、齐、梁、陈均建都建康,连东吴在内为六朝,故称『六朝古都』。
建康城在隋灭陈后被下令『平荡耕垦』,夷为平地,合建康、秣陵等县为江宁县。唐初以江宁县置江宁郡,后置升州。五代吴改金陵府,南唐改江宁府,是南京全境(大致)最早以江宁为名。此后历代多次改置,至明朝改应天府,前期为首都,成祖北迁后为陪都。清初降格为江宁府,为江南省首府。
传说舜封胥于江东,从此称江东一带为姑胥,姑为古吴语拟声词,无义。胥在江东灵岩山下建姑胥城(今苏州吴中区木渎镇)。周时泰伯奔吴,后代迁姑胥城,以胥有狱卒之意,改胥为苏。
吴王阖闾在灵岩山建姑苏台,灵岩山为姑苏山。春秋战国时姑苏城历属吴国、越国、楚国江东郡。秦改江东郡为会稽郡。隋灭陈后,废吴郡,以姑苏山为名改吴州为苏州,是『苏州』作为地名的开始。
唐时为江南东道治所。北宋末升平江府,元改平江路,明改苏州府,直隶南京。
江苏境内在春秋时属吴国。明以前一直分属不同行政区。
唐初分属江南道、淮南道、河南道。宋代分属江南东路、两浙西路、淮南东路、京东西路。元初属江淮等处行中书省,后以长江为界,分属河南江北等处行中书省和江浙等处行中书省。
明建都南京(后为陪都),今江苏省、安徽省和上海市境内州府直属中央,为直隶(迁都后为南直隶)。清初改南直隶为江南省,治江宁府,设江南左、右布政使司。康熙时,江南右布政使司改江南苏松常镇太等处承宣布政使司,简称江苏布政使司,为『江苏』一名的开始。乾隆时将安徽、江苏两省列入《大清会典》,史称『江南分省』。
简称苏。
安庆 + 徽州(今黄山市大部、绩溪、婺源)。
其中安庆于南宋由舒州改置安庆军,后因宋宁宗曾任安庆军节度使升为安庆府。徽州原名歙(shè)州,南宋平定方腊之乱后改名徽州,是徽文化的发源地。
隋改熙郡(今安庆)为熙州,再改为同安郡。唐改为舒州。北宋徽宗时置舒州德庆军。
南宋高宗升潜藩康州(今肇庆德庆县)为德庆府,后因德庆军与德庆府同名,取『同安郡』的安与『德庆军』的庆,改称安庆军,『安庆』之名始于此。宋宁宗时升安庆府。
隋灭陈后,改新安郡为歙州(今安徽歙县、绩溪、黄山市部分地区、江西婺源等地)。宋徽宗宣和年间,方腊于歙州起义,次年被平定。歙州因此改徽州,以徽岭、徽水(今绩溪县西北)为名。
安徽境内情况与江苏类似,明以前分属不同行政区。唐初分属江南道、淮南道。宋朝主要分属江南东路、淮南西路。元朝主要分属河南江北行省和江浙行省。
明朝与江苏同在南直隶,清初改江南省,设江南左、右布政使司。康熙时改江南左布政使司为江南安徽等处承宣布政使司,简称安徽布政使司,为『安徽』一名的开始。乾隆时『江南分省』,列入《大清会典》。
简称皖,因安庆府境内有皖山(天柱山)、皖水(皖河),先秦时曾有皖国。有时也简称徽。
江南省省会一开始在江宁。清初江南省分左右布政使时,右布政使移驻苏州,负责今江苏地区;左布政使留在江宁,负责今天安徽地区。江宁虽然在右布政使的管辖下,却是左布政使的驻地;直到近一百年后,安徽布政使才正式移驻安庆。南京是安徽省会的说法,并不是毫无根据。(手动狗头)
福州 + 建州(今建瓯市周边)。
其中福州唐开元年间由闽州改置,得名于州西北的福山。建州为唐改建安郡所置,得名于东汉献帝的年号。
福建境内在先秦时期属于百越地区,为闽越。战国时越国(今浙江境内,为于越)亡于楚,于越人一支外迁到闽中地区,与当地闽人一起建立闽越国,又叫东越。
秦时设闽中郡,治东治,废去闽越王王位,降为君长。汉初复封闽越国,至武帝时叛汉被灭,宫城焚毁,越人北迁江淮。东治设侯官驻守,闽地远属会稽郡管辖。汉昭帝时遗民渐多,设治县(今福州市区及闽侯县一部分),东汉初改东侯官都尉。
东汉献帝建安元年,会稽太守孙策攻侯官,废侯官都尉,并在侯官北面设建安(今建瓯)、南平、汉兴(今浦城)三县,取『建安年间,南方平定,汉室复兴』之意,连侯官在内,为福建境内最早四县。
三国时东吴以会稽郡南部诸县设建安郡,治建安县。自晋至唐初,今福州、南平周边地区几经改置,先后改闽州、泉州(今福州,与今泉州不同)、建安郡等。
唐高祖武德元年置建州,初治闽县,四年移治建安县,六年以闽县复置泉州(与建州并设)。睿宗时泉州改闽州,玄宗时以闽州西北福山为名改福州。泉州(福州)先后还析出漳、汀、武荣(后升泉州,即今泉州)诸州,福建各州基本成型。
唐时福建境内属江南东道,安史之乱后期,肃宗在境内设福建观察使,辖福州、建州在内的数州,为『福建』名字的开端。宋置福建路,接近今辖区。元属江浙等处行中书省。明设福建等处承宣布政使司,辖区基本维持至今。
简称闽,最早见于《山海经·海内南经》『闽在海中,其西北有山,一曰闽中山在海中。』汉代《说文解字》中说『闽,东南越,蛇种。』可见古闽人有蛇图腾崇拜,而这也体现在了『闽』字当中。
这类命名方式,名字里的关键信息具有特殊含义。除了北京的京字代表首都以外,其它名字大多是纪念重要事件。
这一类命名有 6 个。
北方的首都之意。京本义为人工筑的高丘,引申为高大。因为国都大多建在高地,后泛指汉字文化圈内的国都。
北京地区的建城历史已有三千多年,历来是北方边防重镇。先秦时期境内先属于蓟国;蓟被燕所灭后纳入燕国。秦设蓟县(约为今北京西城区,不是天津蓟县),为广阳郡治所,属幽州。西晋时幽州移治范阳(今北京到保定之间),十六国后赵时迁回蓟县。唐代安禄山、五代时刘守光分别在此建立过割据政权,国号均为燕。
后晋为了打败后唐,向契丹称臣,割让燕云十六州,其中包括幽(今北京)、顺(今北京顺义)、檀(今北京密云)、儒(今北京延庆)四州。北京地区开始纳入北方民族的控制范围,为辽、金打开了进入中原的大门。
北宋初年意图收复十六州,辽于是在北京地区建立陪都,称南京幽都府,后改南京析津府,再改燕京,为北京地区最早被称为『京』。(南京是相对辽上京临潢府而言。上京遗址在今内蒙古赤峰市巴林左旗附近。北京比南京更早被称作南京。)金朝灭辽和北宋后,海陵王完颜亮迁都北京,称中都大兴府。
蒙古大军攻下中都后,进行了屠城,城池被焚毁。忽必烈即位后,决定以汉地为统治基础,重建燕京为首都,后改中都路大兴府,再改大都路。
明朝在应天府(今南京)建都,改元大都为北平府。明成祖取得皇位后,升为北京顺天府,为『北京』一名之始;同时完善京杭大运河,保证北京的物资供给。18 年后,迁都北京,应天府改南京。清朝承明制。
辛亥革命后,民国定都南京。北京兵变后,袁世凯定都北京,直至北洋政府垮台。此时北京仍依清制称顺天府,至民国三年改称京兆地方,直辖北洋政府。
1928 年北伐成功后,国民政府重新定都南京,北京改称北平特别市,撤销京兆地方。1930 年改河北省北平市,同年改回行政院直辖市。
新中国成立后改回北京市,重新成为首都。
简称京。在一些特殊场合也有用到古称燕或燕京(像燕京啤酒)。
明成祖纪念靖难之役,改名天津,即天子津渡(天子经过的渡口)。
『天津』一词散见于古籍,有多个含义,所以天津最早的词源有多种说法,可能在明以前天津作为地名就已经存在(不一定是今天津地区)。
但天津境内在明以前多是盐场、码头、军事据点,加上海水侵蚀和战乱影响,未能一直保持较大的城市聚落;直到明成祖下诏赐名,筑城设天津卫,天津才逐渐发展起来。所以以明成祖赐名作为名字的来源。
天津地区位于渤海湾西部,历史上曾有多条河流从这里入海。古黄河就曾三次改道于天津入海,带来大量泥沙,形成冲积平原。直到金朝以后,黄河向南改道,海岸线才慢慢固定下来。
境内开发大致是自北向南、自东向西,秦汉时境内有泉州(约为今武清区)、雍奴(约为今宝坻区)、无终(今蓟州区)等县,并在泉州设置盐官。但西汉末因为海侵,海平面上升变为沼泽,汉初设置的四个县城均被废弃。
隋朝修建京杭运河,南北运河在天津境内交汇,称三岔河口,开始重新发展起来。唐在今芦台设盐场,今宝坻设置盐仓。唐中叶成为南方物资北运的水陆码头。
后晋割让燕云十六州,蓟州(今蓟州区)落入辽朝控制,直沽河(今海河)为辽与中原的界河,宋朝在南边设置许多军事据点,防备辽兵南下。金朝在三岔河口建直沽寨,天津地区从漕运枢纽变成了军事重镇。
天津地区在元以后重新统一,加之黄河已向南改道,海岸线稳定,海漕开通,直沽重新成为漕运枢纽。元朝改直沽寨为海津镇,属大都路(今北京)。
明朝靖难之役时,明成祖在此渡河,登基后下诏赐名天津,筑城设天津卫,为『天津』一名的开始。清朝改卫为州,再升州为府。
清末,西方列强多次攻打天津大沽口,先是与清廷签订《天津条约》;后大沽口沦陷,清廷与英国再签订《北京条约》,天津成为九国租界。此后,天津逐渐成为北方开放的前沿和洋务运动的基地,率先开启近代化建设,成为当时中国第二大工业基地和北方最大的金融商贸中心。
北伐时期,国民革命军占领天津后,将天津设为天津特别市,后改行政院直辖市。一度改河北省辖,后恢复直辖。
新中国成立后为中央直辖市,出于工业发展考虑一度并入河北成为河北省会,1967 年恢复直辖市。
简称津。
广南东路的简称。广字取自广信县(今梧州封开一带),汉武帝平定南越国,颁圣旨有『初开粤地,宜广布恩信』,取其意设广信。
先秦时期整个南方地区被中原称为南蛮,属于禹贡九州中的扬州百越之地。这时中原政权并没有对两广地区有实质性的控制。传说楚王曾在广州设置楚庭,今越秀山上有清代所建『古之楚庭』石牌坊记载了这个传说。但传说真实性,以及楚庭实际所指,至今存疑。
秦统一六国之后,继续征服百越,将岭南纳入版图,在今两广地区设置南海、桂林、象郡三郡。广东境内主要属南海郡,治番禺县(今广州)。广西境内主要属桂林郡,部分属南海郡和象郡。
秦末乱世,南海郡尉任嚣病重,传位龙川县令赵佗,赵佗以此建立南越国,还兼并了桂林和象郡。南越国疆域最大时包括两广大部分,福建小部分,以及越南北部和中部地区。汉朝建立后,南越国向汉朝称臣。吕后掌权期间两国一度交恶,赵佗称帝。吕后死后关系缓和。
到汉武帝时,南越国不愿归附,被汉朝所灭,属地分置南海、苍梧、郁林、合浦、交趾、九真、日南、琼崖、儋耳九郡,属交趾刺史部。汉朝因忌惮南越国的原有势力,不以番禺为治所,而是在苍梧郡设广信县,作为交趾刺史部驻地。交趾刺史部东汉时改交州。州治一度迁往龙编县(今越南河内东边),又迁回广信。
三国时属孙吴。孙权将州治迁回番禺县。后分交州合浦以北置广州(相当于今广东及广西北部),交州移治龙编,史称『交广分治』。这是『广州』这个名字第一次出现。
唐置岭南道,后分岭南东道和岭南西道,广东境内属岭南东道。宋初复置岭南道,后改广南道,再改路,又分置为广南东路、广南西路。
元朝时两广没有独立的行政区,广东大部分属江西行省,广西及雷州半岛、海南岛属湖广行省。
明朝设广东、广西两个承宣布政使司,『广东』、『广西』正式成为行政区名。雷州半岛和海南岛开始归属广东。之后两广辖境基本沿袭明制。
新中国成立后,钦州、廉州(今钦州、防城港、北海)划入广西,怀集划入广东。1988 年海南独立建省。
简称粤。粤,古同越。先秦时期中原称长江以南至今越南北部大部分地区为百越。秦末汉初广东境内曾建立南越国。后为了跟百越其它地区区分,逐渐固定使用粤字。
良渚文化双孔玉钺,1986年反山遗址发掘。良渚反山遗址位于浙江省杭州市西北。
图源见水印。
广南西路的简称。
先秦时期,广西地区远离中原,和广东以及其它南方地区一起被称为南蛮、百越。境内主要为西瓯和雒越族群。
因两广历史上长期作为一个整体,广西得名历史参见『广东』条目。
新中国成立后,改壮族自治区。
简称桂,因为广西境内在秦时主要属于桂林郡。《山海经·海内南经》有『桂林八树,在贲隅西』。贲隅即番禺,指番禺县(今广州)或番禺城(今广州越秀区)。
原汉朝西域,清朝收复后取故土新归之意。
新疆境内在汉朝时被称为西域,存在许多古国,如龟兹、楼兰、于阗、车师、焉耆、疏勒、康居、月氏等。这些古国同时处于汉朝和匈奴的影响之下。
公元前一世纪,匈奴冒顿单于即位后,统一漠北,歼灭月氏国,控制西域诸国。汉武帝决心联合西域各国夹击匈奴,派张骞两次出使西域,与西域之间连接起丝绸之路。到汉宣帝时,汉朝在与匈奴的争斗中胜出,在龟兹建西域都护府,西域诸国成为汉朝属国,西域首次纳入中国版图。
西晋因北方胡族入侵而亡,进入五胡十六国时期,西北先后多个民族的政权试图控制西域,最后北魏统一中国北方,控制西域东南部。南北朝时期,吐谷浑和柔然分别从南北入侵西域,北朝逐渐失去对西域的控制。直到隋炀帝打败吐谷浑,重新控制西域东南部。
唐朝太宗至高宗年间,或诸国来朝,或唐发兵征伐,唐在西域设安西、北庭两都护府。玄宗时,曾在都护府上又设碛西节度使。同时,在龟兹、于阗、疏勒、碎叶(一度是焉耆)设军事建制,史称安西四镇。安史之乱发生后,吐蕃趁机逐渐控制天山南部及河西走廊,甚至一度攻占并洗劫长安城。另一边,回纥正式改称回鹘,控制漠南漠北至中亚的广大地区,包括西域北部。
唐朝末年,吐蕃、回鹘衰落,中原政权无暇顾及西域,西域诸国重新陷入混战。回鹘汗国灭亡后,分三路西迁:迁河西走廊的称甘州回鹘,后与部分高昌回鹘和蒙古等其他民族融合,形成今裕固族;迁吐鲁番盆地的称高昌回鹘,除部分拒绝伊斯兰化东迁到河西外,大部分与察合台汗国人融合成为维吾尔族族源之一;迁帕米尔高原的称葱岭回鹘,建立喀喇汗王朝,和葛逻禄人融合,成为维吾尔族另一个族源。
喀喇汗国控制西域西部,虽为首个改宗伊斯兰教的突厥语国家,但制度保持东方王朝的特色,强调与中原传统的联系。中亚的历史记录也把喀喇汗王朝当作中国的一部分。同时期西域还有高昌、于阗等国。
辽朝被金所灭后,辽宗室耶律大石西迁称帝,建立西辽(又称喀喇契丹)。耶律大石受过汉文化教育,在辽朝担任过翰林院编修,西辽沿袭辽制,以中国自居。随后向西域、蒙古高原、中亚乃至西亚扩张,统治西域八十余年,后被蒙古所灭。蒙古帝国时期,西域分属不同汗国。
明初为防范漠北蒙古,于哈密设卫所。后不敌吐鲁番汗国,卫所多次被破内迁,至明末退守嘉峪关。明中期,东察合台汗国演变成叶尔羌汗国。清朝时,蒙古准噶尔部以伊犁为根据地建立准噶尔汗国,南部的叶尔羌汗国则由和卓家族掌握了实权。
1755 年清朝灭准噶尔,后又平定阿睦尔撒纳反叛和大小和卓之乱,西域全境平定,乾隆命名为新疆,在伊犁设伊犁将军,新疆重新回到中国的版图。名字出自给陕甘总督的谕令『新辟疆土如伊犁一带,距内地远,一切事宜难以遥制』,为『新疆』一名之始。但此时新疆还不是专称,雍正时贵州新辟疆土也叫新疆。
1864 年,塔城条约向俄国割让新疆西北部巴尔喀什湖南部大片土地,苏联解体后这些地方属于中亚多个国家。同年,新疆发生叛乱,并得到英俄两国支持,清朝最后只剩下塔城、哈密等少数据点。
1875 年左宗棠督办新疆事务开始,陆续收复天山南北,至 1881 年收复被俄国占领的伊犁地区,新疆又一次回归中国版图。1884 年依左宗棠建议,新疆正式建省,此时的『新疆』的含义出自左宗棠的奏折『他族逼处,故土新归』。
新中国建立后改为维吾尔族自治区。
简称新或疆。
南宋光宗先封恭王,后即帝位,升恭州为重庆府,取双重喜庆之意。宋朝有皇帝即位时,将潜藩升府的惯例。
双重喜庆最常见的说法是,赵惇于淳熙十六年(1189 年)正月被封恭王,紧接着同年二月受禅登基,故为重庆。这个说法是站不住脚的。赵惇早于 1162 年孝宗登基的时候就被封为恭王,1171 年被封为太子,然后又过了 18 年到了 42 岁才登基,受封恭王早就是 27 年前的事了。登基时已是太子,喜庆的事要么不算,要么封王和封太子一起算,为什么只是双重?而且宋朝的皇帝,基本都是先封王在登基的,为什么唯独这次是重庆?
另一个说法是,因为赵惇受禅登基,登基庆典时父母俱在,是为重庆。这个说法似乎更合理一些。总之,因登基升府改名是确定的,但具体是哪『双重喜庆』已不可考。
重庆在清末以前一直归属四川,可以参考『四川』部分。
秦占领蜀地后,在重庆境内设巴郡,历属荆州、益州、巴州、楚州。隋朝以渝水(嘉陵江)改渝州。
宋徽宗时改恭州,再因宋光宗升重庆府,为重庆名字的开始。
清末中英签订《烟台条约》,英国可向重庆派驻领事;《马关条约》开始,重庆正式成为内陆通商口岸。抗日战争时期,国民政府先定重庆为战时首都,后升格为直辖市,从四川省分出,后定为永久陪都。
新中国成立后重新并入四川省,1997 年设直辖市。
简称渝,因渝水及渝州。又简称巴,因先秦时期境内主要属于巴国,且巴国定都境内。
这类命名有 4 个,3 个直接来自民族名,1 个来自原住民族群。(台湾对原住民的族群认定尚存争议。)
来自清朝对该地区的称呼卫藏(明朝时译乌思藏)。卫为藏语中央之意,指拉萨周边的前藏地区;藏即日喀则地区,即后藏。卫藏在清朝先译为满文,因『卫』与满文中的西方(wargi)发音接近,且卫藏在中国西南部,被译为 wargi dzang,汉语再据此翻译成西藏。
『藏』在藏语是本是满盈、纯净、清澈的意思,后用来指雅鲁藏布江(藏曲),再后来又引申指雅鲁藏布江的发源地——以日喀则为中心的后藏地区。也可以算因江河得名。清朝开始以『藏』为地名称呼日喀则地区,然后引申为整个藏区,再根据地名称呼藏族人为藏人。藏族对藏区自称『博』(威利转写:bod),藏人自称『博巴』(威利转写:bod pa)。
广义的藏区除了卫藏,还包括在青藏高原东北部的『安多』地区(大部分在青海、甘肃、四川辖下),在东南部的『康』(现分属青海、西藏、四川、云南),和西北部的『阿里』。现在的西藏自治区主要由卫、藏、阿里和康区西部组成。
青藏高原在石器时代就有人类居住,后面发展为一些部落国家,其中较强盛的是西北(今阿里地区)的象雄(又作羊同)。此外还有羌人部落从北部和东部进入藏区,包括唐旄(葱茈羌,西羌的一支,原居住于天山南部葱岭一带)、发羌(原居住于今川藏青交界地区,晋朝左右进入青藏高原)等。
南朝末至隋初,发羌后裔吐蕃部崛起,自山南雅砻河谷向唐旄后期中心逻些(今拉萨)推进。至松赞干布在位时,吐蕃成功经略周边小国,成为青藏高原上的大国。吐蕃因攻打吐谷浑与唐朝发生冲突,后求娶文成公主与唐朝建盟。但盟约在松赞干布死后不久即宣告破裂,吐蕃与唐的关系时而紧张,时而修好:赤德祖赞时,娶唐中宗养女金城公主;到赤松德赞时,以唐停止纳绢为由,攻陷唐都长安(此时安史之乱尚未完全平息),并在签约后退兵。经过两百年的纷争,到赤祖德赞(同时期为唐穆宗在位)时,双方为集中精力应对国内危机,建立『唐蕃甥舅之盟』。
唐末,吐蕃末代赞普因打击佛教,被僧人刺杀,西藏进入割据时期,形成诸多互不相属的小国。与此同时,汉地也经历了五代十国的混乱。到宋朝时,为了安抚边境,以及抵御西夏的崛起,时有招抚加封藏人部落。在部落割据时期,宗教文化上百家争鸣,佛教逐渐复兴并藏化,藏传佛教各教派开始形成。
宋末蒙古崛起,西藏被蒙古人征服,后成为大元帝国的一部分。由于元世祖信奉藏传佛教,封萨迦派法王 八思巴 为国师,设总制院(后改宣政院),由萨迦派治理西藏地区。自此西藏进入各教派统治时期。明清基本沿袭前朝的做法,对当地实权首领予以承认,并颁布封号。明设乌思藏(后来的卫藏)、朵甘(后来的安多和康区)两个卫指挥使司和俄力思(后来的阿里)军民元帅府,后将两卫指挥使司升格为行都指挥使司。至清设驻藏大臣为止,中间历经萨迦、帕竹、仁蚌巴、藏巴等代理政权。
藏巴政权信奉噶举派,打压新兴的格鲁派。格鲁派先后请求蒙古喀尔喀部和和硕特部入藏,最后建立与和硕特部联合统治西藏的甘丹颇章政权。
清康熙年间,蒙古准噶尔部入侵西藏,攻入拉萨,杀死拉藏汗,和硕特汗国灭亡,西藏向清朝求援。清朝派兵平定准噶尔,驻军并协助七世==达= - =赖==入藏,为清朝经营青藏地区事务的开始。雍正派内阁学士驻拉萨,设驻藏大臣衙门。乾隆年间,西藏郡王珠尔默特那木札勒联络准噶尔部反叛。清廷平叛后颁布《西藏善后章程》:驻藏大臣成为定制,并扩大驻藏大臣职权;正式规定DL 和僧官的世俗权力;设立俗官的噶厦制度。此后清朝开始称呼称呼西藏地区为卫藏,后改称西藏。后来西藏又经历了廓尔喀(今尼泊尔)入侵,清廷在驱逐廓尔喀之后,订立《藏内善后章程》,进一步加强驻藏大臣的权力。
清末英国人觊觎西藏地区,多次挑衅,甚至直接入侵。此时清政府内忧外患,无力周旋,陆续与英国签订一系列有损西藏利益的条约。由此,驻藏大臣的权威不断受到影响,加上驻藏大臣日后的治理不力,噶厦与清政府逐渐疏远,立场由此时的反英逐渐转向日后的亲英。1903 年,英军担心西藏倒向俄国,以噶厦政府拒绝执行条约为由入侵西藏,攻占拉萨,要求驻藏大臣诱逼 DL 谈判,签订拉萨条约。十三世 DL 在拉萨城被占前见势逃往蒙古,清政府革去其名号。
清廷代表经过与英国反复谈判,虽保住对西藏的主权,但由于清廷日益衰落,后续的西藏新政、驻军、改土归流等计划都一一搁浅。而辛亥革命开始后,清政权开始有分离崩析的迹象,内地产生强烈的排满情绪,而未建省的蒙古和西藏则出现分离倾向。
民国沿袭清朝版图,设蒙藏事务局,任命直属国务总理的驻藏办事长官。在西藏地区设西康省和西藏地方。但由于一战、二战以及内地的军阀混战,西方各国与军阀都无暇顾及西藏问题,噶厦政府获得了一段时间事实上的自治。1913 年十三世DL发布圣地佛谕,涉及体制改革、实行新政等内容。文件透露出谋求自治,乃至寻求独立的倾向。
英国为确保在印度的利益,希望以西藏作为抵挡俄国的屏障,撺掇西藏独立。英国先是出兵占领藏南地区(今印度所谓阿鲁那恰尔邦),然后在 1914 年西姆拉会议上,英方与藏噶厦政府私下交换条件,以将藏南地区划入印度,换取英国承认西藏完全自治。中方拒绝在该条约上签字。条约中的中印分界线就是所谓的麦克马洪线,自始至终没有得到过中国任何一届政府或者政党的承认,成为日后中印争议的根源之一。
噶厦损失了藏南地区,却未能获得英国的实质支持。噶厦既无实力一直维持边界,民国政府也因忙于抗日战争和内战无力进一步管控西藏,西藏名义上仍然从属于民国政府,十四世 DL 和十世班禅的认定,仍经过民国政府批准。
1950 年,解放军解放西藏。1951 年,西藏代表团与中央政府签订和平解放西藏的《十七条协议》,表示不变更现行政治制度、不强迫各项改革、人民提出改革要求时与西藏领导人协商解决 等。1954 年的第一届全国人大一次会议上,DL 表示拥护民族区域自治原则,并当选全国人大常务委员会副委员长,班禅当选委员。
1950 年代初,全国开展土地改革,到 1953 年少数民族地区外的地区基本完成。1955 年在青海、四川的藏区展开民主改革,因此引发的阶级斗争触动了上层藏人的利益,他们联合向 DL 请愿要求D立。
1956 年西藏自治区筹备委员会成立,DL 任主任委员,班禅任第一副主任委员。
1957 年以康区藏人为主体的反抗组织『四水六岗』(指康区的四条河流和六个山脉)成立,1958 年下属武装部队成立,背后接受美国中央情报局的支持。
1959 年冲突扩散到拉萨,反对者包围夏宫罗布林卡,并在大街上张贴海报、呼口号,要求中共离开西藏,最后演变为武装叛乱。3 月 17 日,解放军开始镇压反抗武装,当夜 DL 逃离拉萨,飞往印度实际控制的藏南达旺地区。3 月 22 日,叛乱被平息。3 月 28 日,国务院总理周恩来签署国务院令,解散原西藏政府,由西藏自治区筹备委员会行使政府职权,班禅代理主任委员。4 月 29 日,噶厦政府人员在印度宣布成立流亡政府,要求独立;6 月 20 日,DL 宣布不承认《十七条协议》。7 月 17 日,西藏自治区筹委会二次会议决定提前进行民主改革,废除政教合一的农奴制。
1965 年,西藏自治区正式成立。
简称藏。
来自内札萨克蒙古,指归附清朝较早的漠南蒙古各旗。
蒙古高原自古是北方游牧先民的活动范围,活跃的民族先后有匈奴、东胡、鲜卑、柔然、突厥、回鹘、契丹等。(这些民族并非严格的并列关系,有些是同一民族在不同时期的称呼。像契丹是柔然的一支,而鲜卑和柔然又很可能源自东胡。)
汉武帝时汉朝成功控制漠南及河西走廊,将匈奴赶到漠北。漠南成为汉人政权与北方民族的缓冲区,主要为汉人聚居。
五代十国时期,契丹人建立契丹国(后改辽国),成为蒙古草原上的第一个帝国,定都上京(今内蒙古赤峰市巴林左旗附近)。后来辽被金所灭,在此期间蒙兀室韦趁机摆脱辽的控制,开始在蒙古草原上扩张。
蒙兀室韦是一个东胡族源的部落,最早见于《旧唐书》。铁木真(即成吉思汗)曾祖合不勒汗统一尼伦各部,建立蒙兀汗国。汗位传至铁木真父亲也速该,也速该被塔塔儿部毒死,蒙兀儿亡国。
铁木真经过征战,带领蒙古乞颜部崛起,统一了漠北各部,建立大蒙古国,『蒙古』成为各部统一的名称。蒙古帝国打破不同部落的各自为政,将各部重新编成九十五个千户,蒙古成为军政合一的国家,『蒙古』这个名字又从部落名逐渐变成统一的民族认同。
在第四任蒙古大汗蒙哥去世后,领有汉地、主张汉化的忽必烈,与受漠北蒙古贵族拥护的阿里不哥争夺汗位,最后忽必烈胜出,同时控制漠南(大约相当于今内蒙古)、漠北(大约相当于今蒙古国)。
但忽必烈的汗位并没有得到所有蒙古贵族的承认,其它汗国时而独立,时而承认宗主自治,使蒙古帝国事实上分裂成四大汗国。忽必烈在其领地内改国号为大元,建立元朝,承袭了蒙古帝国在汉地、蒙古高原及西伯利亚的领土。
元被明朝所灭后,残余势力退居漠北,史称北元。北元覆灭后分出鞑靼、瓦剌、兀良哈三部。漠南则在 15 世纪末由成吉思汗十五世孙达延汗统一。漠南蒙古其中一支,达延汗的孙子俺答汗率土默特部驻牧呼和浩特,后归顺明朝,这部分后来变成了内属蒙古。
到后金崛起时,察哈尔部的林丹汗为蒙古大汗。察哈尔部与后金交战兵败,林丹汗逃亡,其子额哲投降,漠南蒙古被并入后金(后来的清朝)版图,为内札萨克蒙古。后来归顺的漠北蒙古被称为外札萨克蒙古。两者合称外藩蒙古,由世袭的札萨克管理;与之相对的是土默特等内属蒙古,由朝廷官员直接治理。
清朝后期文书开始出现内蒙古和外蒙古的概念,分别指代内札萨克二十四部 和 喀尔喀四部。
在清朝灭亡、民国建立之际,受沙皇俄国影响,泛蒙古主义兴起。发生辛亥革命的 1911 年,外蒙古喀尔喀四部宣布脱离清朝统治。为实现蒙古统一,还向内蒙古出兵。1915 年中、俄、外蒙三方会谈,签订《中俄蒙协约》,袁世凯政府以承认外蒙自治和俄国在外蒙的一系列特权,换取俄国承认中国为外蒙的宗主国。1921 年外蒙古在苏联的帮助下建立共和国。1946 年,民国政府承认外蒙古独立。
与此同时,内蒙古在民国政府治下,仍分属绥远、热河、察哈尔、宁夏、兴安等省。
1947 年,内蒙古举行人民代表会议,成立内蒙古自治政府,是抗战后中共领导的第一个少数民族自治政府。新中国成立后转制为内蒙古自治区政府。
简称内蒙、蒙。
取夏地安宁之意。西夏是党项族建立的政权,国号源于发祥地夏州及家族封号夏国公。
宁夏境内长期作为中原与西北先民对峙的前线,处于中原和周边部落国家的交替控制下。部分参考『甘肃』条目。
东晋时匈奴铁弗部人赫连勃勃在关中及河套地区建立大夏,又称胡夏、赫连夏。有人认为匈奴是夏后氏之后,所以国号称夏。后被北魏所灭,置夏州,铁弗部逐渐融入汉族。
宋时西夏占据今甘肃、宁夏地区,以宁夏为中心建立大夏国,定都兴庆府(今银川)。后亡于蒙古。党项族人后融入各族(据传李自成为党项人)。
元朝于西夏故地设西夏中兴行省,又称宁夏行省,取『夏地安宁』之意,为『宁夏』地名之始。后废西夏行省,改甘肃行省,建宁夏府路辖宁夏平原地区,归属甘肃行省。之后一直归属甘肃。
民国时独立建省。新中国改回族自治区。
简称宁。
即台窝湾,又叫大员,为平埔原住民的社名,在今台南市安平区大员镇。
台湾对原住民的认定尚存在争议。过往认为大武垅为西拉雅族的分支,原因之一是荷兰人以西拉雅语传教时,大武垅族被认为是西拉雅语使用者。后来发现大武垅语和西拉雅语之间的差异,且族人自我认同跟西拉雅族不同,开始逐渐将大武垅族独立出来。
大武垅族自称 Taivoan 或 Taibowan,音近闽南语之『大满』、『台窝湾』、『台湾』,加之族人发源自台南,也的确曾建立台欧湾社(Taiouwang),有学者认为是台湾一名的来源。荷兰人来台时最早接触该名,并在荷兰统治时期逐渐代指岛内全境,称为大员。清朝时定名台湾。
台湾历史在两岸存在争议(即使在岛内,蓝绿阵营之间、汉族和原住民之间也有分歧)。主要分歧点在岛上原住民的来源,以及岛上与中原大陆之间早期关系上。学术上就有争议的事再加上政治因素,不讨论。
可以确定的是,随着 17 世纪闽粤沿海汉人开始大量移居,汉人逐渐成为岛上居民的主体,中华文化也成为了岛上的主流文化。即使经过半个世纪的日治时期也未曾改变。现岛上近 98% 人口为汉族,除了保留原有风俗的原住民,岛上居民大多使用繁体中文,讲国语(民国形成的北方官话)、闽南话、客家话等汉语方言。
政治上,目前的台湾地区当局,是内战失败退守台湾的中华民国政府。台湾当局从未宣布独立(绿营小动作虽多,但不敢正式宣布)。无论在共和国还是所谓民国的行政区划上,台湾都是中国的一个省。海峡两岸同属一个中国,台湾是中国不可分割的一部分。
简称台。
云南县得名较早,确切由来已不存。
贵州地处偏远,是汉族为主的行政区中建制较晚的一个。贵州中贵字的起源,有多种说法,并不明确。
汉武帝时在境内置云南县(今云南大理祥云县),为『云南』最早记录。因汉代无史籍记载,后人对『云南』的名称由来作出各种推测:有彩云之南、云山之南、少数民族地名改易等。
先秦时期,云南境内为多个方国,境内先民被中原称为西南夷。秦统一六国后,在云南东北部设立郡县。汉武帝时征服西南夷,设益州郡和 24 县,其中包括云南县。
由于远离中央,境内地方豪强势力较强,名义上为州县,时有豪强获得实际的自治。唐朝洱海地区的蒙舍诏部落首领皮罗阁兼并其他五诏,建立南诏国,被唐朝封为云南王。后历经多个政权,至白族段氏建立大理国时,辖今云南、贵州、四川西南、缅甸北部,及老挝和越南少数地区。
元朝征服大理国,设云南等路行中书省,云南正式成为行省。之后有过短暂的割据政权,都很快被中央政权收复。
简称滇,来自滇池和古滇国。滇字来源有多种解释,可能来源于族名、少数民族语言演变等。滇原指云南及贵州地区,直至贵州建省,滇变为专指云南省。
又简称云。因为清代境内有迤东道、迤西道、迤南道,又称三迤,简称迤,现已很少使用。
贵州境内在先秦时期主要为夜郎等方国,被中原称为西南夷,少数地区属于楚国。秦统一后,分属巴郡、蜀郡、黔中郡和象郡,汉代分属牂牁郡(初属犍为郡)和武陵郡。
犍为郡为汉武帝招抚夜郎侯所置,约定以其子为县令。因汉地遥远,夜郎侯暂时答应,后多次不服反叛,留下『汉孰与我大』的典故,后演变为成语『夜郎自大』。最后于汉成帝年间被灭。
三国时多数地区归蜀汉,南北朝时多数属南朝,隋设明阳、牂牁二郡;唐设黔州郡,属矩州。到五代十国时期,因地处偏远,多数时间隶属相邻的割据政权,与中原政权关系疏远,来往较少。
宋太祖时,土著首领普贵以控制的矩州归顺,被封矩州刺史,宋朝在敕书中有『惟尔贵州,远在要荒』,为『贵州』之名称此地区的最早记载,推测是指今贵阳地区。宋徽宗宣和元年,思州土著首领田佑恭加授贵州防御使衔,贵州正式成为行政区,相当于现在的贵阳。
元朝时,境内大部分属于湖广行省,其它地区实行土司制度,设八番顺元宣慰司都元帅府于贵州(今贵阳)。
明设贵州等处承宣布政使司,贵州正式成为行省;程番府移治贵州城,并改称贵阳府,为今贵阳名字的开始。
简称黔,来自唐时境内属黔中道(战国时楚可能有黔中郡,尚有争议)。又简称贵。
江南·读城丨安徽的“徽”取自哪里:
你不知道的贵州 | “贵州”曾经真的等于贵阳?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
接下来让我们继续改进程序。
我们在之前已经了解过命令行参数。在 gosort
程序中,就是通过命令行参数,输入需要排序的编号。
简单的命令行参数输入后变成了字符串切片,只有位置(下标)的差别,不方便传递复杂的参数。如果规定特定次序的参数表示特定的含义,不好记忆不说,还无法缺省(因为一旦缺省,次序就乱了)。而像 gosort
程序这样,普通参数(又叫位置参数)数量不确定,把特殊参数放到最后面也达不到缺省的效果。
我们需要 标志(flag)参数,通过在短横线(也就是减号 -
)后面加上参数名,构成一个标志,使得参数变得有名字,不再受限于顺序。使用效果类似这样:
|
|
自己实现标志参数并不难,只需要检查每一个命令行参数,找出短横线开头的,然后根据类型决定要不要读下一个参数作为值;处理的同时,要把标志参数和位置参数分开,供后续使用。虽然不难,实现起来比较琐碎,一不小心会漏掉一些边界条件,需要耐心地去测试完善。
你可以尝试实现看看。不过这里我们偷个懒,使用标准库自带的 flag
包。
|
|
除了直接生成标志参数后返回储存地址(指针),也可以声明好变量之后,在设置标志参数时指定储存的变量。
|
|
简单总结一下:
flag
包保存着关于标志参数的全局状态, flag.Parse()
必须在 所有标志参数设置好之后、访问任意一个参数值之前调用 。flag
只支持基本类型 + time.Duration
类型的参数,每个类型有两个设置函数:不带 Var
结尾的直接返回储存变量的指针,带 Var
结尾的则需要你指定指针。gosort -x 123 456 -name bob
无法得到 name
参数,反而会得到这样的位置参数: ["123","456","-name","bob"]
。h
和 help
,因为 flag
包默认实现了这两个标志,打印帮助信息。更复杂的标志参数,可以使用第三方包 github.com/urfave/cli
和 github.com/spf13/cobra
,它们在 flag
的基础上,封装了更高级的用法。但目前为止,flag
已经够用了。
记得标准库和第三方包的详细文档,可以在 pkg.go.dev 搜到。
在这一期里,部分函数只会做简略的介绍,详细的函数签名和用法需要大家自行看文档。
篇幅关系,只展示代码的关键部分,需要补足剩余的代码才能编译运行。
在开始改进之前,先将之前的程序做一点小调整:把排序和拼接的代码,单独抽取成一个函数:
|
|
将功能相对独立的、会被复用的代码抽取成函数是一个好的编程习惯,将函数内部控制在较少的容易理解的行数,可以让程序代码行数持续膨胀的同时,保持一个较好的可读性。
还记得我们的需求吗?待排序的是一组提交编号,它们是单调递增的序列号。在实际使用中,因为代码库特别庞大,到后期提交编号达到好几位数,位数要过很长时间才增长一位,给人一种编号位数一直就是这么多的错觉。实际上并不是这样,编号用完了还是要进位的,9999 之后,就是 10000 了。
之前的实现按文本(字符串)排序,就出问题了。例如 9,80,564,1253
这几个数,如果按照数值排序,现在的顺序就是升序;可如果按字符串排序,则刚好反过来,顺序是 1253,564,80,9
。因为字符串是头部对齐后从左到右比较的,前缀分出先后就直接结束比较。
我们需要先将提交编号转换为数字,再按数字的规则排序。另一方面,按照字符串排序可以保留,让 gosort
程序有更多的用途,这时就需要一个标志参数区分开。现在规定默认情况按数值排序,而当设置 -l
(lexically)时按字符串排序。
|
|
首先是设置标志参数并储存在变量 lex
,这部分内容参考前面的准备知识。然后程序根据 lex
的值执行不同的分支。如果是字符串排序,就是之前的函数。另外一个分支则多了几个新函数。
strsToNums()
是我们自己实现的函数,用来把字符串切片转换为整型数切片。因为转换有可能失败,所以返回值列表里还带着一个 error
类型的返回值。
|
|
这里使用了标准库函数 strconv.Atoi()
。strconv
是 String Convert 的缩写,这个包里是跟字符串转换相关的工具函数,其中 Atoi()
就是把按十进制显示的字符串,转换为 int
型。因为字符串储存的不一定是十进制数,就有可能转换失败。
sortNums()
是另一个我们自己实现的函数,跟之前的 sortStrings()
类似。
|
|
由于整型切片无法直接 strings.Join()
,为了让 sortNums()
内部跟 sortStrings()
保持类似,我们又自行实现了 numsJoin()
。把整型数拼接成逗号隔开的字符串有很多种具体的做法,这里是其中一种:
|
|
不过我嫌这里做了两次转换(先从整型到一个个字符串,再把字符串拼成长字符串),有点浪费。能不能一步到位呢,于是我又写了第二种实现:
|
|
我还能基于 bytes.Buffer
和 strings.Builder
写出别的实现版本。
但第一种实现就挺好的。这里只是展示,有时同一个功能可以有多种实现方式。开发的首要任务是实现功能,并且尽可能让代码易读,不容易出错。性能有时也重要,但必须是经过分析,确认有性能差异,并且这个差异对于程序的表现有影响。第二种实现一定比第一种性能好吗?差异是否大到值得特意去优化?使用看起来性能好但是不熟悉的实现,是否会带入潜在的 bug?答案都是不确定的。
这里为了行文方便,每个函数分开讨论,实际上它们都放在 main.go
里。在 Go 里,包级成员(包括函数)的引用顺序和声明顺序无关,只要不存在循环引用即可。一般的惯例是,init()
和 main
(如果有)最前面,然后是导出(exported)成员(就是首字母大写那些),然后是未导出(unexported)成员。未导出函数之间,先被引用到的就放前面。
现在重新编译之后执行一下程序看看效果:
|
|
现在程序默认按数值排序,即使遇到长度不同的提交编号也不怕;同时按文本排序的功能也没丢掉,偶尔还能用来排一下人名之类的文本信息。程序够用了吗?
还是回到最初的需求。为什么不人工检查排序呢?因为编号多,最多达几十上百,这种数量,人工排序又慢又累又容易错。甚至不要说排序,就是把编号全部输入一遍,也是慢、累、易错。实际工作中都是打开记事本,把大家回复的编号整理起来,然后直接复制到命令行作为参数。有时还得追加提交编号,就把新的编号放到记事本最后面,然后 Ctrl + A(全选),Ctrl + C(复制),来到命令行,Ctrl + V(粘贴),熟练到麻木。
为什么要重复做这四个动作,能不能告诉程序,直接从指定的文件读编号?当然可以。不过从命令行参数输入编号还是得保留,方便数量少时使用。
这时可以设置标志参数 -f
(file,不过为了跟后面的输出区分,还是理解为 from 吧),传入一个文件,让程序改为从文件读入。这里假定文本文件里只有编号,以空白字符隔开。
|
|
增加了 -f
参数之后,如果 from
有值,而且这个值确实是一个有效的文件,就会从里面读取内容。位置参数的值同时也追加到切片里。
这里面用到的新函数,只有 isFile()
是自行实现,其它像 ioutil.ReadFile()
和 strings.Fields()
可以直接查询文档。
|
|
相应地,从命令行复制大段的输出也不够方便。极端的情况下,太多的输出甚至会超出命令行的缓冲区。可以从文件输入,自然也可以从文件输出。跟 -f
参数类似,我使用 -o
(output)参数指定输出文件。参数设置就不再示范了,这里看一下怎么写入文件。
|
|
同样地,为了偷懒,直接用 ioutil.WriteFile()
,三个参数分别是文件名(路径),写入的数据(string
需要转换为 字节切片)和 文件权限。这个函数在遇到目标文件存在且有写权限时,会直接覆盖原来的内容,但不改动权限;如果文件不存在,则以指定权限创建文件。0666
为八进制数,对应 Linux 的权限值(在 Windows 系统 Go 会自动转换为相应的操作)。
接下来试一下修改后的程序。我们首先创建一个 input.txt
(注意前后和中间间隔有多余空格,实际操作中不小心多输入空格是非常常见的):
|
|
然后执行程序:
|
|
除此之外,还能想到一些有用的功能。
无论是有人不小心回复了重复的编号,还是管理员整理时多写了,编号偶尔会出现重复。重复的内容,在不同场景下,造成的影响可大可小。对于一些严格的场景,我们更希望编号里没有重复。这就要求对结果进行去重。
这时我们可以增加一个 -u
(unique)的 bool
参数,表示开启去重。(当然也可以默认去重,设置一个反向的开关表示保留重复。)去重可以在排序之后做,因为这时重复元素相邻,更容易处理。这个功能实现起来并不难,对切片做一次遍历即可,大家可以尝试自己实现。如果一时没有概念,可以先用 Go 语言完成这道题 https://leetcode-cn.com/problems/remove-element/ ,做出这道题就知道如何高效地在切片里去除元素了。
需要注意的是,因为有 []string
和 []int
两种切片,去重的逻辑可能要实现两个版本(视乎你的代码实现)。这是 Go 目前不便的其中一个地方:没有泛型,会一定程度导致代码重复。
现在的程序,根据常用的场景,默认了输入的分隔符是空白字符,输出的分隔符是半角逗号 ,
。但这个设定不会总是好使。有可能输入文件是其他人整理的,可能是别的系统导出的,用了别的分隔符;输出内容也可能用于别的场景,需要别的格式。
这时我们需要针对每个场景修改代码,重新编译吗?没有必要。只要设置对应的标志参数,允许分别指定分隔符就好。而如果没有指定,还是用原本的默认符号。两个符号分别用于分割输入的内容,和拼接输出的内容,需要调整相关的代码,可能用到的函数基本都在 strings
包里。
这两个功能添加上之后,实现效果大概是这样的:
|
|
numsJoin()
的第二种实现有几个 bug,你发现了吗?strings.Builder
的用法,你可以写出 numsJoin()
的第三种实现吗?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
地址:https://medium.com/introskeptic/three-things-to-do-when-you-hit-a-crossroads-504be4bda616
作者:Ethan Greavu
翻译:Jayce Chant(博客:jaycechant.info,公众号ID: jayceio)
一直撰写和翻译技术文章,有点枯燥,今天看到朋友圈有人感慨人生的选择,所以想到翻译一篇相关的文(ji)章(tang),希望能从中得到启发。
以下是译文:
当走到人生的十字路口,在你面前有两种选择:一种是选择放弃,那是一条糟糕的路;还有一种是选择反击,一条真正艰难的路。
——马修 · 佩里
遇到十字路口最糟糕的一点是,你不可能总是知道自己什么时候会遇到岔路。十字路口让我们感到害怕,因为我们对接下来要做什么感到不确定。
如果我遇到一个十字路口,我应该知道该走哪条路,而且可能会试着走那条路。但这无法阻止我对曾经有过的其它选择的好奇。
我们之所以会把十字路口当作一个艰难的抉择,是因为我们知道,尽管很多道路前途光明,里面却可能隐藏着一两条糟糕的路。问题是,糟糕的路究竟有多糟糕;如果我们无法看清路的另一头有什么,我们又有多大可能冒险去走那些路?
十字路口是可怕的,却又是诱人的,因为背后充满了机遇。只有很少的路是负面的。但即使危险可能并不存在,做选择仍然让人感觉到痛苦,这种阴影在做出决定之前、期间甚至之后,都会笼罩在我们的头上。
做出重大改变是很难的,因为你可能无法准确地找到那些会引起重大改变的决定。
我们所知道的是,重大的变化是一般是指类似的事:接受一个新的工作机会、离开目前的工作、结束一段关系或者从家里搬出来等等。然而,有些重大变化是看不见的,而且是在没有预期的情况下发生的。
与一个刚认识的人建立新的关系,这个人可能从此在你的生活中扮演着重要的角色;但如果你们是偶然相遇,这个重大的改变未必是你发起的。
但重大的改变主要还是掌握在我们自己手中,只要我们想,即使感觉时机不对,我们还是可以随时采取行动。做出一个改变生活方式的重大决定,会让我们保持掌控感。
我们当然可以选择轻松写意地走进十字路口;但拼命地直奔向前,却有可能让最好的那条道路变得更加美好。用尽全力,逼着十字路口,将最好的道路交出来。
如果你不是用力地撞向十字路口,强行开路,那可能是你被道路选择而不是你选择了道路,你不一定能得到对你来说最有利的结果。
大家都知道的做事方法,是先思考再行动,做事要谨慎。
对于某些情况来说,这是对的。但在机会出现时——如果是决策时机稍纵即逝那种——没能采取行动,我们很可能就只能固守在当下的线性路径上。
仔细考虑一个机会,不能保证你还能有不受限制的时间抓住这个机会。任何事情都有可能发生,任何事情都有可能被撤回。
先行动再思考,让你有时间在行动过后去思考这个行动。行动之前的思考会让你产生是否要行动的焦虑。也许你本来已经决定好要走那一条路,但随着时间推移,你开始找理由和借口,说那不是当时最好的路。
即使这个行动利大于弊,如果当时没能抓住机会,以后就更难强迫自己行动了。
无论如何,先行动再思考能让你在某个地方得到立竿见影的效果;而先思考再行动则会让你原地踏步。你被留在原地想象你本来可以去的地方:如果我当时做了决定,而不是想得太多,结果会怎样。
我们的大脑并不总是围绕着我们的利益全面地考虑事情。我们并不总是考虑什么对我们最好。我们想保持舒适,而舒适让我们哪都去不成。
如果仔细考虑一个决定可能会导致过度思考,就不要给大脑思考的时间,直接做决定。
回溯有一种坏名声,它被视为已经失败,所以才不得不回到以前的地方。但事实并非如此。
有益的回溯是另一条机遇之路。也许你现在所处的地方,并不是你认为必须要做的抉择,你在来时的路看到了更好的机会。那就回溯到那个地方。
回溯可能并不总是可行的。可如果回溯是可行的,如果回溯是必要的,那就应该把它作为一个选项。
抓住的机会,即使没有充分利用好,也还是从中获得了经验。
回溯里唯一的损失是时间。尽管如此,如果你没有走过第一条路,也许回头再去走的另一条路也不会如此清晰。回溯并非只是单纯地走回头路。
你总是还在进步中,即使可能感觉不到,那也是过程的一部分。
遇到十字路口是令人恐惧的,我们可能看到好几条路,或者可能完全看不见路的另一头。
无论怎样,我们对自己想去的地方有一种直觉,困难的是迈出第一步,并下定决心走下去。
]]>这往往是因为我们对相应的领域了解不够,只看到复杂的结果,对如何通向目的地毫无概念。如果了解如何分解任务,到最简单的步骤为止,还有从最简单能看到反馈的雏形开始,逐步改善,普通人也能做出复杂的作品,最多时间比有天赋的人多花一些。
这一期开始,我们会花几期的时间,逐步地尝试改善一个命令行程序。
如果是从这篇文章才开始看的新手,建议先简单浏览前几期的内容。当然,你也可以完成第一期的环境搭建之后直接跳到这期,在实际遇到问题时再去查看具体的内容。
我们从一个命令行程序开始。
命令行界面(CLI,Command Line Interface),又叫字符用户界面 (CUI,Character User Interface),区别于图形用户界面(GUI,Graphic User Interface)。GUI 就像在国外不用学当地语言,有一份我们能看懂的、甚至有图片的菜单供选择,指一下就有结果,无需语言交流。而在 CLI 里,人和机器通过标准输入输出(可以简单理解为打字)进行交互:你必须通过命令准确地告诉系统你想干嘛,然后系统执行并把结果打在屏幕上。你必须得先知道系统接受什么命令。如果输入命令以外的东西,系统只能告诉你『我听不懂』。
GUI 当然要比傻傻等着你打字的黑窗友好,也是日常使用的主流。但在方便之余,你无法提出菜单以外的细致要求,执行菜单上没有显示的操作。同一个动作(如点一下菜单第二项),结果高度依赖当前的菜单显示,你必须等菜单显示完成才能接着『交互』,而不能一口气直接下达想要的一系列动作指令。这就好像你明明想好了要干什么,却不能说话,非要等下属慢慢翻到那页菜单。相比之下,CLI 可以一口气接受一系列精确的指令。所以即使在图形界面的系统中,命令行也没有被遗弃,甚至还在不断地加强。
从开发的角度说,图形界面开发的门槛反而比较高,命令行程序因为没有图形界面,减少了很多工作量,可以把精力集中在核心的功能上,适合练手。
别误会,我没有打算详细介绍函数。
在实际的开发中,自然会接触函数的用法。在写出优雅强大的函数之前,我们可以先调用标准库或第三方包里别人写好的函数,并从中学习。
要正确使用函数,我们需要查看文档,看懂函数签名和注释,有些还会有例子,就像看说明书。如果想学习实现,则要进一步看源码。
以经常用的 fmt.Println
为例。
可以在 https://pkg.go.dev 上搜索 fmt
包,找到 Println
这个函数,内容是这样的:
|
|
Println formats using the default formats for its operands and writes to standard output. Spaces are always added between operands and a newline is appended. It returns the number of bytes written and any write error encountered.
Example Code:
|
|
|
|
注:pkg.go.dev 从 19 年起取代了 godoc.org 成为了 Go 语言的文档网站,上面不仅可以搜索到标准库,所有被缓存了的第三方 module 也都能搜到。(go module 默认会先向 proxy 请求第三方包,proxy 发现尚未缓存就会先获取缓存再返回。换言之,几乎所有公开的有人请求的 module 都可以搜到。)
函数签名、注释、例子还有例子的输出,是标准的文档构成。
注:文档里的实际上是函数原型(prototype),但要确认的主要是签名信息。
Println
不是讨论重点,注释和例子就不展开了。主要介绍一下函数签名。
函数签名(function signature)定义了函数的输入(参数列表)和输出(返回值列表)。它本质上是函数开发者和调用者之间的契约,包含函数的关键信息:参数的类型、个数和顺序,返回值的类型、个数和顺序。调用者通过它了解调用时要提供什么,以及在调用完成后会得到什么。(当然,按签名调用还是有可能出现逻辑上的错误,开发者需要在注释中进一步说明注意事项。)函数名、参数名、返回值名可以出现在签名里也可以省略,命名信息对签名来说并不重要 。
最简单的函数签名是这样的:(参数列表) (返回值列表)
。签名信息前面加上 func
关键字就成了函数类型(type)字面量,再加上函数名就成了函数原型(prototype),再加上函数体 {/*代码实现*/}
就变成完整的函数。实际使用中,虽然函数签名是关键,但命名能帮助我们区分函数、参数和返回值,还能从命名中推测用途,所以很多函数签名其实是带着命名的类型字面量或函数原型的形式。
|
|
Go 里面函数也是一种类型,签名相同的函数就被认为是同一个类型。下面的代码是合法的:
|
|
实际上,真正的签名信息是 (int, int) int
,func
关键字和各种命名 a
, b
, c
, x
, y
, z
都可以省略,有没有命名、命名是否相同,不影响它们是同一个类型。(函数的参数名 x
和 y
在函数体没有引用时也可以省略,例如 func(int, int) int {return 0}
。)
无论是哪一种形式,关注的要点都是参数列表和返回值列表。知道以下几点规则,你就可以读懂函数签名:
跟其它 C 家族语言返回值类型在前、没有关键字不同(C 语言:int myFunc(int a)
),Go 以关键字开头,函数名和参数列表在返回值列表前面。
(顺序:关键字 - 函数名 - 参数列表 - 返回值列表。)
因为允许多返回值,参数和返回值都是列表。其中参数列表外面的括号不能省略,即使参数列表为空;而返回值列表如果为空或者只有一个匿名返回值,可以省略括号。
(区分参数还是返回值:第一个括号里的是参数,右边剩下的是返回值。Go 没有类似 void
的关键字,没有返回值时,返回值部分直接为空。)
连续多个相同类型的命名参数或返回值,可以一起声明,(a, b, c int, s string)
等价于 (a int, b int, c int, s string)
。(要看懂这种写法,但不推荐这样写。这样写在增减参数和调整参数顺序时,容易出错,会把类型张冠李戴。)
Go 支持可变参数(variadic arguments)。具体声明形式是,在类型前面加上三个句点 ...
,表示可以接受 0 到多个该类型的参数。例如 Println
的 (a ...interface{})
表示可以接受任意个空接口类型的值作为参数。
注:空接口方法列表为空,意味着任意类型都满足空接口,任意类型都可以作为实参传递给函数。相当于 Java 里用 Object 作为参数类型。
调用时:
|
|
函数最多只能声明一个 可变参数 ,而且只能是最后一个参数(可变参数放中间,后面的参数就很难对得上号了)。
可变参数实际上是一个语法糖,传给可变参数的一系列值被打包成了一个对应类型的切片,供函数内部引用。Println
的参数在函数内部相当于 (a []interface{})
。不过今天不讨论函数的实现,只讨论调用。
既然可变参数实际上变成了一个切片,如果调用方刚好有一个同类型切片 s
,可以直接拿来当实参吗?
不能。可变参数调用时要求传入的是一个一个对应类型的值,传相应的切片类型不符。难道只能 (s[0], s[1], s[2])
这样一个个地传参吗?如果切片有一百个元素呢……
这时有另外一个语法糖,在实参后面同样加上 ...
,就会产生类似 Python 解包(unpack)的效果。当然,只是像,实际上是告诉函数这是一个切片,可以直接复制给可变参数,并没有解包再打包的操作。
...
的位置很容易搞混:可变参数(形参)的声明放在前面,给实参『解包』放在后面。
铺垫了一些背景知识,下面开始动手。
准备这期内容时,我在读者中间征集过日常找不到软件工具的小需求,作为实战项目的选题。最后也没找到合适选题,这期先用我曾经遇到的需求做例子。后续大家想到什么需求,还是可以留言,也许就用在下一个项目上。
这个需求很简单:排序。源自我第一份工作时,开发之余偶尔帮项目做版本管理。VCS 用的 P4,所有手机型号的项目,在同一个代码库的同一棵源码树上,通过分支和特性开关区分型号。优点是,跨型号共性问题,只要在源头上修改一次,随着代码定期集成到各分支,都会修复,避免重复劳动和遗漏型号。缺点是,针对某些型号的修改,如果隔离没做好,会影响无关的型号。
送测和正式发布的编译,为避免引入不确定的提交,采用基线(base)+ 追加提交的方式。会选择一个经过验证的提交作为 base,到 base 为止的所有修改都参与编译;base 之后的提交,往往都不太确定,遇到必须包含的提交,就要添加到追加提交里,编译时会将这些提交当作补丁按顺序应用到代码上(相当于临时 cherrypick)。但这个顺序,不是提交顺序,而是填写顺序。假如提交 A 修复问题 1 同时引起问题 2,之后提交 B 对同一个地方做修改修复问题 2。那么填写时必须按照 A 到 B 的顺序,否则 B 的修改会被 A 覆盖,问题 2 将仍然存在。
每次编译之前,在内网公布 base,模块负责人根据 base 回复需要追加的提交,然后管理员就得到了一堆提交号。P4 的提交号是自增序列号,所以只要将它们升序排列,就能保证先后顺序。
交流大概是这样的:
|
|
管理员经过整理,得到了 123467,133297,145683,167834
作为编译的参数。提交少的时候,人工处理一下就完了。但如果因为某些原因无法提高 base,后续的补丁却源源不断,提交可能会积累到过百,这时人工确认就又累又容易出错了。于是我当时就写了一个命令行工具来处理这么一个简单的需求。
为什么不直接用 Excel 呢?首先是 Office 启动慢,特别在已经打开一系列开发工具的前提下;其次需要将提交录入,排序之后还得想办法导出,又增加了工作量。Linux 底下倒是有一个 sort
命令,但是当时我在用 Windows。对于这种简单的需求,自己开发不仅工作量不大,遇到需求有变化时还很容易按需调整。
当时还没接触 Go,用的 C 开发。现在当然要用 Go 来练习。
注:考虑到篇幅有限,下面只展示代码的关键部分,需要补足剩余的代码成分才能编译运行。
关于如何初始化一个项目,以及项目的基本结构,请参考第一期的内容。如果还有问题,欢迎在留言区或者加入交流群提问。
一开始不要设太高的期待,先让程序可以跑起来,这样才能基于运行的反馈,一步步改善程序。为此先把需求简化到最简:从标准输入获取提交号,排好序之后,输出到标准输出,用英文逗号隔开(格式要方便后续使用,P4 要求的格式就是用逗号隔开的提交号,你也可以根据自己的需要调整)。
假定把这个程序叫 gosort
,那么用起来大概是这样的:
|
|
这个程度很简单,调用标准库就可以做到。
gosort 133297 167834 123467 145683
这一串,对命令行环境来说,是(带参数的)命令,会根据开头的命令,传递给名为 gosort
的程序;而对 gosort
程序来说,这一串则是命令行参数。注意,命令(程序名)也是参数的一部分。有些程序实现了多种功能,对外链接到不同文件名,会根据传进来的程序名称不同,执行不同的动作。最典型的例子是 busybox
,它以单一可执行文件,提供了一个包含超过两百个命令的 unix 工具集合,被称为嵌入式 Linux 的瑞士军刀。
不像其它 C 家族语言,Go 的命令行参数不是作为 main
函数的参数传递的,而是通过 os
包的 Args
变量获取。os
包初始化时会获取参数并储存在 Args
中,它是一个字符串切片 []string
。前面介绍过查询文档的方法,想了解更多可以自行到 pkg.go.dev 查询;标准库源码则在 Go 的安装目录的 src
目录下,按包名储存,另外大多数 IDE 都支持源码的跟踪跳转(一般的操作,是对着 os.Args
按 Ctrl
+ 鼠标左键)。
先读取命令行参数然后直接输出看看效果:
|
|
|
|
这里我们需要改善几个问题:
os.Args
是第三方包的包级变量,尽量不要直接在上面排序。虽然命令行参数在这个程序里暂时没有别的用处,但直接修改公共变量仍是一个坏习惯。main
函数里的代码改进如下(这里就不再执行,请自己执行,查看改动后的输出):
|
|
多快好省地实现排序算法,本身也是学问。但这次我们不研究这个,直接使用 sort
包。
自定义类型想要排序,需要实现 sort.Interface
接口的一系列方法;基本类型则预先实现了对应的函数。对于 string
类型的升序排序,sort
包给我们提供了 sort.Strings()
。
另外,前面最后的输出代码,实现起来还是比较麻烦,而且存在一个 bug。借助字符串工具包里的 strings.Join()
函数,可以先拼接成目标字符串,再一口气输出,既简单又绕开了 bug:
|
|
这时编译之后再执行程序,效果如下:
|
|
通过调用标准库,5 行代码实现了我们阶段性的小目标。
下一期我们还是讨论这个程序,面对需求的变化,如何改善程序去支持更复杂的功能。
sort.Strings(nums)
为什么没有返回值?字符串切片 nums
只是作为实参传给了排序函数,按理说切片本身发生了拷贝,为什么排序最后对 nums
生效了?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
程序有三种基本的控制结构,分别是 顺序、分支(选择) 和 循环 。其中顺序结构不需要任何特殊的语句,程序语句按顺序执行即可。需要控制语句的是剩下两种结构。
注意,Go 语言的代码块不能省略大括号({}
,又叫花括号),哪怕只有一行。这是为了保持语言的可伸缩性,使得语言在不同规模和不同的上下文都不会出现歧义。与之相反,条件表达式处于关键字和左大括号 {
中间,而且总是只有一行,所以不需要加括号 ()
。
无论是分支结构还是循环结构,都需要引入条件表达式,来控制执行路径。由于 Go 取消掉了一些操作的值,使得它们从表达式变成了语句,导致相应的操作无法直接加入条件表达式。估计是因为这样,Go 允许在条件表达式前增加一个简单语句,中间用分号 ;
隔开,也就是这样 简单语句;条件表达式
。注意,只能是一句,如果是更复杂的效果,还是应该在进入控制结构之前,通过多个语句先完成操作。
其中简单语句可以是:
chan
)的发送语句。变量短声明很可能是用得最多的语句,因为在这里声明变量可以限制变量的作用域,仅限于控制结构内部,但需要注意变量遮盖(shadow)的问题:
|
|
分支结构会指定一个到多个条件,通过测试哪些条件得到满足来决定执行路径。Go 里面可以实现分支结构的语句有:
Go 的 if 语句跟其它语言差别不大,直接上例子。
细节差别有:表达式不用括号,代码块不能省略大括号,else if
不像一些语言可以写成 elif
(Go 很省关键字)。
|
|
只有一两个判断条件时, if 语句是很好的选择;但当分支多了起来,大量的 else if
会导致代码冗长又难以维护。这时 switch 语句是更好的选择。
可能是为了省关键字,switch 实际上有两个用法,分别是表达式 switch,和类型 switch。无论哪一种,switch 语句都会从上到下逐个测试 case,执行第一个满足的分支。
|
|
类型 switch 涉及类型断言。
Go 的接口变量允许储存任何满足接口的类型的值,举例说类型 T
满足接口 I
,那么 T
的值 t
就可以赋值给 I
的变量 i
:var i I = t
。
而将接口变量 i
转换为具体的类型 T
时,除了显式的类型转换 t = T(i)
,还可以用 类型断言 t = i.(T)
。两者之间一个明显的差别是,类型转换需要开发者自行确保可以转换,否则就会产生一个运行时 panic;而类型断言有安全形式 t, ok = i.(T)
,如果转换失败,ok
的值为 false
,并不会产生 panic。不过这里不是讨论类型断言,不详细展开。
类型 switch 借用了类型断言的形式,但是括号里的不是某个具体的类型,而是关键字 type
,也就是 i.(type)
;然后 case 后面接的,就不再是值,而是具体的类型。
|
|
所以总结下来,switch 语句实际上包含了
case
关键字)。不熟悉的话,还挺容易混淆的。
除此之外,虽然关键字和结构整体都是沿袭 C 风格的 switch 语句,细节上还是有几个比较重要的差异:
默认 break:
C 风格的 switch,case 之间的默认行为是 fallthrough。换言之,命中的 case 只是一个入口,如果没有遇到 break 语句,接下去的每个 case 都会执行,直到结束。
但实际上,需要 fallthrough 的情况非常少,大多数情况下,我们都只是希望只执行命中的 case,这就导致 case 和 break 总是成对出现,非常啰嗦。
Go 将 switch 的默认行为改为 break,需要 fallthrough 时,再在 case 的结尾显示写一个 fallthrough
。注意类型 switch 不允许 fallthrough。
case 不限定为常量值:
C 风格的 case 后面,只能接一个 常量或字面量的值。Go 无此限制。在经典用法中,值可以是常量,也可以是变量或者表达式。
case 后面允许接多个条件:
因为 C 风格 switch 默认 fallthrough,当多个条件共享相同的代码时,只要将多个 case 顺序写在一起,代码放在最后一个 case,就可以共享代码逻辑。
下面看一下例子:
|
|
Go 的默认行为变成了 break,不能这样写了;但是多个条件共享代码反而更容易了,因为 case 后面可以接多个条件:
|
|
例子中的任意一种写法,都可以;但是三种写法不能混用。
select 语句可以说是通信版的 switch,也可以说是借鉴了 C 语言 select 函数的含义(虽然读写的对象不同),并且升格成了关键字:
|
|
select 语句里的每个 case 后面都接一个通信操作,要么接收要么发送。
执行时,会在可以执行的 case 中 随机 执行一个(注意是随机,switch 则是从上到下测试第一个满足的),其它 case 忽略。如果没有可以执行的 case,则会执行 default 子句;没有可执行的 case 且没有 default 子句,则会阻塞直到有可以执行的 case 为止。所以如果不想阻塞,就一定要增加 default 子句。
select 语句常常嵌套在循环结构里,实现对通道轮询的效果。详细用法在介绍并发和通道时再展开。
在 Go 里面实现循环结构,只有一个 for 语句。看起来这又是省关键字的结果。Go 的不同写法,可以实现 C 风格里的 for、while 和 do-while ,甚至还有 Java 的 for-each 或 Python 的 for-range 效果:
|
|
range 表达式的右边,可以是 数组、数组的指针、切片(slice)、字符串(string
)、映射(map
)或者可接收(可读)的通道(chan
)。换句话说,它们的成员元素可以枚举。
range 表达式本质上是一个语法糖,实际上底层实现还是展开成普通的 for 循环,帮你枚举对象里的成员元素。对于通道以外的类型而言,展开后的三个部分可以近似看作:取第一个元素(初始化);没遍历到最后一个元素(循环条件);取下一个元素(步进)。而对于通道而言,循环条件变成了通道还没关闭。
详细用法,具体在每个类型里介绍。这里只提醒两点:
range 表达式获得的变量都是拷贝,对变量的修改不会影响集合中原来的值。(这个的原理会专门找一期介绍值和引用)如果想修改集合里的值,还是要老老实实地遍历下标(或者 map 的 key),然后直接修改索引值 s[i] = newVal
。
多数的 range 表达式可以返回不止一个值。你既可两个值都赋值给变量使用,也可以只要其中一个;丢弃第二个值可以直接忽略 for i := range s {...}
,而丢弃第一个值则必须显式赋值给空白标识符 for _, v := range s {...}
。
| 类型 | 类型定义 | 第一个值 | 第二个值 |
| ————————– | ——————- | ——————– | ———————————- |
| 数组、数组指针 或 切片 a
| [n]E, *[n]E, []E
| 下标 i
,类型int
| 下标对应的元素 a[i]
,类型 E
|
| 字符串 s
| string
| 下标 i
,类型int
| 下标对应的字节 s[i]
,类型 byte
|
| 映射 m
| map[K]V
| 键值k
,类型K
| 键值对应的元素 m[k]
,类型V
|
| 通道 c
| chan E, <- chan E
| 元素 e
,类型 E
| - |
无论是哪一种写法,都可以看作是经典写法的变体(省略了某些部分),所以执行流程都是相同的:
控制语句主要针对循环结构,除了按照 for 语句的规则执行,还可以加入一些控制语句,改变执行的方向。
分别有 break
、continue
和 goto
三种语句。这三种语句的用法跟 C 家族语言基本一致:
break
跳出当前的循环,并继续执行循环之后的语句。break
也可以用于跳出 switch 代码块,但由于 Go 的 switch 默认会在 case 结尾退出,所以 switch 里 break
用得比较少。
注意有多层循环嵌套时,break
只会跳出当前所在的内层循环。
如果想跳出在外层循环,需要在跳出的循环前面加标签(label),break
后面加上标签作为目标,就会跳出对应的循环。
continue
中止当前循环的执行,改为执行步进语句,(在同一个循环结构内)开始执行下一次循环。
跟 break
类似,有多层循环嵌套时,continue
只会影响当前所在的内层循环。
如果想开始外层的下一次循环,同样可以用循环前的标签作为 continue
的目标。
goto
必须配合标签 (label) 使用。goto
语句会无条件跳转到(同一个函数内部) 标签 声明的位置继续执行。goto
的使用不限于某种控制结构。实际上,靠 if-else + goto
可以实现任意的控制结构。
goto
用好了可以用很少的代码实现复杂的逻辑控制;但是用不好的话,会导致执行流程混乱,造成理解和调试困难。结构化程序设计一般不鼓励使用 goto
,多数后来的高级语言也都不提供 goto
语句。
标签(label)本质上就是一个 标识符,只是它指向的不是一个常量或变量,而是一个程序的位置(指向声明位置下一行代码);有效作用域总是为同一个函数体,不受嵌套的代码块限制(标签是 Go 里面唯一一种永远都是函数级作用域的标识符)。标签不占用常量和变量的命名,允许标签跟其它标识符重名,但强烈建议不要重名。
标签声明时单独一行,后面接一个冒号 LABEL:
;使用时作为 break
、continue
和 goto
的目标。为了区别于其它标识符,标签一般全大写。
goto
的目标标签可以声明在函数内的任意地方,跳转的限制只有两条:
相对应地,作为 break
和 continue
目标的标签多一个限制:标签必须声明在外层某个循环前面(相当于指向该循环),用来表明要跳出或者继续的是哪个循环。
|
|
写教程可以深入细节,一直到内部的原理。也可以不多解释直接模仿着跑起来,多见几个例子再回头解释。
前者似乎所有人都可以看,新手打基础,老手查漏补缺。但新手可能还没建立起直观的认识,一上来就太多细节,也许就懵了。这个系列既然起了『实战』之名,按理说应该接近后者。直接来几个实例,先跑起来,再介绍里面分别是什么。有 C 家族语言经验的朋友,甚至都不需要解释,从例子里大概就能体会差异,可以用来干些简单的活。
写到第四期,似乎越来越理论和细节了。这并不是我的本意。
这样固然是满足了一部分我自己深入了解 Go 的需要;另一方面,也是希望可以迁就一部分缺少 C 家族语言经验的读者。(是否有没有编程经验的读者呢?因为缺乏来自你们的反馈,读者的面目在我这其实是模糊的。)这就需要在第一个『实战项目』之前先铺垫一些『基础知识』。虽说是基础,凡是涉及的主题,我都希望写透一些,不太愿意把一个主题拆得零碎,先笼统过一遍,下次展开一点,后面再来深入。
但这样下去,与其看我啰嗦,还不如直接看 Go 官方的语言规范来得简洁清晰。虽然我不断强调大家可以当参考资料跳着看;但是从详尽的资料中筛选关键信息不正是我该做的事吗。幸好写到这一期,开发一个最基本的程序所需要的知识勉强是够了,所以下一期赶紧开始进入一个实战程序吧。
开发中肯定还会遇到没介绍过的内容,等到毫无遗漏介绍完再开始是不现实的,这时提问答疑和群内互助就足以解决问题了。开发中遇到的问题,后面正好着重介绍。
这里给出 Go 语言官方的规范文档,大家也可以自行查阅。
官网:https://golang.org/ref/spec
官方提供给国内访问的镜像:https://golang.google.cn/ref/spec
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
运算符将操作数(operands)连接成表达式(expression)。然后表达式根据(运算符的)规则求值。
注意,Go 是区分 表达式(expression,整体可求值)和 语句(statement,不可求值)的语言。
既然运算符连接操作数会得到表达式,然后表达式一定可以求值(注意操作数可能是一个值,也可能是另一个表达式,或者返回值,总之可以求值);那么反过来说,不能求值的就不是表达式,对应的符号也不是(Go 定义的)运算符。
在这里,作为对比会提及一些容易被当成表达式的语句(官方文档称之为 operators form statements,运算符形式的语句),但不展开讲语句。
Go 是强类型语言,意味着双目运算符的两个操作数类型要一致(移位运算除外)。对于不一致的情况,要么是 untyped
自动转换,要么是显式转换,相应的规则请参考前面两期的内容。
算术运算符对数字类型(numeric)的值进行运算,并得到与第一个操作数相同类型的值。
在 Go 里,四则运算 和 位运算 都被认为是算术运算。
算术运算符都是 双目运算符(意味着需要两个操作数);三个特殊情况是用作正负号的 +
、-
和用作按位取反的 ^
,这时它们是单目运算符。
四则运算包括我们熟知的 +
, -
, *
,/
(加减乘除),以及除法衍生的运算 %
(求余)。
+
, -
, *
,/
的操作数可以是 任意数字类型(包括整型数、浮点数 和 复数);%
则只能是整型参与运算。
加减乘除的含义和规则想必不用介绍,只提一下几个特殊情况:
我们都知道,除数不能为 0。如果整数除法里除数为 常量 0,直接编译错误;如果是 变量 0,则会引起一个运行时 panic。如果是浮点数除法,IEEE-754 没有规定,结果视具体实现而定,目前 Go 的实现是得到一个特殊浮点数『无穷』(视乎被除数的符号,得到 +Inf
或 -Inf
)。
因为结果的类型与操作数一致,整数除法的结果也是整数,那就可能出现『除不尽』,这时尾数向零的方向截断(truncate)。例如 7 / 4
结果为 1
(尾数 0.75
被截断,尽管 1.75
更接近 2
),-7 / 3
结果为 -2
(商 -2
余 -1
比 商 -3
余 2
,商更靠近 0)。
对于求余,假设 x / y
,商为 q
余数为 r
, 它们之间的关系满足 x = q*y + r
且 |r| < |y|
(余数的绝对值一定小于除数的绝对值,注意是除数不是商)。我们根据上一条规则求得整数除法的商之后,就可以根据这条规则得到余数了:
| x | y | x / y | x % y |
| —- | —- | —– | —– |
| 5 | 3 | 1 | 2 |
| -5 | 3 | -1 | -2 |
| 5 | -3 | -1 | 2 |
| -5 | -3 | 1 | -2 |
+
和 -
作为单目运算符时,放在值的前面表示指定数值的符号,也就是作为正负号使用,相当于省略掉作为双目运算符时前面的 0
:-5
等价于 0 - 5
。 (实践中 +5
跟 5
没有任何区别,所以很少会用到单目的 +
)
+
加号和 +=
也可以应用在字符串上,严格来说这不是加法,而是连接(concat);当然你也可以理解为特殊的『字符串加法』。
除此以外,还要留意类型的范围和精度,看运算结果是否有超出类型的表示范围。溢出的处理可以参考数字类型的类型转换部分。
位运算只能应用于 整型数 。进一步细分,位运算又可以分为 按位逻辑运算 和 移位运算。
由于位运算是直接对二进制位进行运算,所以我们要了解整型数的二进制表示:
正数 还有 零 比较好办,只要知道如何表示二进制数,做一个进制转换即可,例如 21
二进制位为 00010101
(为了方便举例,用最短的 int8
,下同)。
负数要麻烦一些,并不是直接在正数的基础上把符号位置为 1,因为这样正负数的加法很难实现。计算机使用二进制补码(简称补码)来表示负数。
这里不展开补码的原理,只要记住一个口诀『取反加一』,就可以从正数得到对应的负数的补码。例如 21
的二进制取反是 11101010
,加一得到 11101011
,就是 -21
的补码;-2
的补码是 11111110
(注意加一时产生了进位)。这两个数的补码,直接跟对应的正数相加,结果均为 0 (丢弃最高位的溢出);跟别的数相加也能直接得到正确的结果。
按位逻辑运算的基本规则跟逻辑运算一致。只是逻辑运算对布尔值进行运算,而按位运算对二进制位逐位进行运算。
op (运算符) | 11110000 op 01010101 | 解释 | |
---|---|---|---|
& | 01010000 | AND 按位与,两边均为 1 时结果为 1,否则为 0。 | |
\ | 11110101 | OR 按位或,两边至少一个为 1 时结果为 1,均为 0 时才为 0。 | |
^(双目) | 10100101 | XOR 异或,不同的位结果为 1 ,相同的位为 0。 | |
^(单目) | - | NOT 非,或者叫按位取反,^01010101 结果为 10101010 ,相当于省略双目运算的左操作数 11111111 (对应位宽全 1,相当于无符号数的最大值,和有符号数的 -1 )。 |
|
&^ | 10100000 | bit clear(AND NOT) 位清除,注意不是与非(与非是 NAND)。只有左操作数为 1 且右操作数为 0 时结果为 1,否则均为 0。可以看作将右操作数中的 1 从左操作数中清除掉。 |
其它更复杂的逻辑运算,可以通过组合基本的逻辑运算构成。例如,同或 (XNOR)可以在 异或 的基础上 取反 ^(a ^ b)
;与非 (NAND)可以在 与 的基础上 取反 ^(a & b)
。
相信你会发现,位清除(bit clear,AND NOT)其实也是 与 和 取反 的组合,a &^ b
等价于 a & (^b)
,那为什么需要组合成一个独立的运算符呢?看下面的代码:
|
|
可以看到,独立的 &^
运算符,跟两个运算符组合使用相比,规避掉了溢出错误,省略了将字面量先指定类型,还是有差别的。需要注意的是,并没有 |^
运算符。
分为左移位 <<
和右移位 >>
,就是把左操作数的二进制位(连符号位一起),向对应方向,移动右操作数指定的位数。
移位时,超出范围的位丢弃,空缺的位左移补零、右移补符号位(无符号数还是补零):
|
|
移位运算是少有的允许左右操作数不同类型的运算。两个操作数都可以是任意整型,但右操作数的值不能为负数(只能为 0 或 正数):
|
|
不过移位运算也可能涉及类型转换:如果移位表达式不是一个常量表达式(换言之,不可以编译期求值),而且左操作数是无类型常量(untyped const),左操作数会隐式转换为 移位表达式替换为左操作数时它要转换的类型 。
这句话非常拗口,需要举个例子。 var y int8 = 1 << x
这个例子里面,如果 x
是常量,那么 1 << x
就会在编译时求值,1
是 untyped const
,结果的类型跟它一样,然后结果会试图转换为 int8
类型;如果 x
不是常量,1
需要先转换为 int8
再参与移位运算。之所以是 int8
,是因为把表达式替换为左操作数的话( var y int8 = 1
),左操作数需要转换为 int8
。
换言之,运行时的移位操作,如果不知道左操作数应该转换为什么类型参与运算(这关系到溢出判断),可以先把移位拿掉,判断完类型再加回来。
赋值语句
Go 里面,赋值语句是没有值的。所以 a + b
是一个加法表达式,可以求值,可以放在赋值符号 =
或者 :=
的右边;但 c = a + b
整体却是一个赋值语句,不能求值。
与其它 C 家族语言类似,Go 提供了算术运算后赋值的语法糖 a op= b
,它等价于 a = a op b
,其中 op
可以为任意一个双目算术运算符(+=
, -=
, *=
, /=
, %=
, &=
, |=
, ^=
, &^=
, <<=
, >>=
)。有些教程把这种组合称作 『赋值运算符』,这种说法不严谨,因为这些语句整体不能求值,并不是表达式。不能把它们加入别的运算(如 c + (a += b)
),也不能作为右值赋值(如 c = (a -= b)
)。带 =
的这一系列符号不算(狭义的)运算符。
自增自减语句
在 C 语言里面,++
和 --
确实是运算符,自增自减是表达式且可以求值。甚至还弄出了运算符在前和在后两种用法,分别用来表达先运算后求值和先求值后运算。
Go 大概是觉得这些用法非常不直观容易出错,取消了自增自减的求值。所以在 Go 里面,它们是语句,只有写在变量后面一种用法,得单独一行先执行完,再访问变量取值。b = a++
这样的写法是错的,a++
把 a
的值加了 1,但 a++
语句本身并没有值。
比较运算又叫关系运算,应用范围比算术运算广,不在局限于数字类型。比较运算要求两个操作数的类型可以 互相赋值,以及满足两个条件:可比较(comparable) 和 有序(ordered)。其中可比较不一定有序,但有序一定是可比较的。
比较运算的结果是布尔值。
相等运算符 ==
(相等)和 !=
(不相等)要求操作数是可比较的。不同类型的规定如下:
布尔(bool
)值可比较。布尔类型只有 true
和 false
两个值,要么相同,要么不同。
数字类型都是可比较的。
math/big
包。字符串(string
)可比较。就是逐个字节比较。
指针可比较。两个指针指向同一个变量,或者都是 nil
被认为相等。
通道(chan
)可比较。两个通道变量指向同一个通道(来自同一个 make
调用创建),或者都是 nil
被认为相等。
接口(interface
)可比较。两个接口变量拥有相同的动态类型、而且值也是相等(具体怎么比较根据动态类型决定),又或者都是 nil
,被认为相等。
如果动态类型不同,不相等,不会报错。如果动态类型相同,但是该类型不可比较,会产生一个运行时 panic。(对不可比较的静态类型进行比较,编译时就不会通过;但是接口的实际类型需要运行时确定,所以变成了 panic 报错。)
非接口类型 X
的变量 x
与 接口类型 T
的变量 t
,满足以下条件时可比较:X
类型可比较,且,X
类型满足 T
接口。
而 x
与 t
相等需要满足:t
的动态类型是 X
,且,t
的值与 x
相等(按 X
类型的比较规则)。
数组类型可比较需要满足:两个数组是相同类型(同样的长度和元素类型, [10]int
和 [10]bool
是不同类型,[10]int
和 [9]int
也是不同类型),且,元素类型可比较。
两个数组相等则需要满足对应的元素都相等。
结构体与数组类似,可比较需要满足:相同类型,且,所有成员字段的类型可比较。
两个结构体相等需要所有成员字段都相等。
注意以上规则不仅在直接比较时有效,还有这些类型作为数组或者结构体成员,被递归自动比较时也有效。
切片(slice)、映射(map
)和函数(func
)不可比较。但是可以跟 nil
进行比较判断是否非空。
顺便提一下,映射的键值类型必须是可比较类型,这在以后介绍的时候会提到,这里先做个一个知识关联。
顺序(ordering)运算符 <
小于,<=
小于等于,>
大于,>=
大于等于 则要求操作数的类型是有序的。
有序的类型就非常少了,只有 整型、浮点型 和 字符串 三类。
这个比较好理解。数字直接比大小,浮点数涉及精度仍然参考 IEEE-754。字符串仍然是逐个字节比较,前缀相等时,较短的字符串较小(也就是空串最小,较短的字符串相当于同一个前缀后面接了一个空串)。
由于 Go 不支持运算符重载,所以除了这几个类型以外的类型都不支持顺序运算符,也没办法使它们支持。特殊的比较,就需要引入一些工具包(如 reflect.Equal
函数),自定义类型则需要自己实现比较方法如 (a T) compareTo (b T)
。另外,如想利用 sort
包进行排序,类型就需要满足 sort.Interface
接口。
逻辑运算符对布尔(bool
)值进行运算,并得到一个布尔值。
逻辑运算符只有三个:
操作符 | 解释 | ||
---|---|---|---|
&& | 短路逻辑与,操作数均为 true 时结果为 true ,否则为 false 。 |
||
\ | \ | 短路逻辑或,操作数只要有一个为 true 结果即为 true ,只有均为 false 时为 false 。 |
|
! | 逻辑非,单目运算,结果跟操作数相反。 |
Go 的逻辑运算跟 C 一样是『短路逻辑』,双目运算时右操作数是『按需求值』的——意思是,如果不需要用到右操作数的值就能得到表达式的值,右操作数就不会求值。这可以用于避免一些运行时错误。
例如 if a.val > 10
,如果 a
是 nil
,就会产生一个 “nil pointer dereference” 的空指针 panic。这时如果改为 if (a != nil) && (a.val > 10)
(括号非必要,只是方便看清左右操作数的范围),当 a
为 nil
时,a != nil
为 false
,对于逻辑与来说,无论右操作数的值是什么,整个表达式都一定是 false
,所以右操作数的表达式会被直接跳过不求值,也就不会触发空指针引用了。
不好归类的运算符在这里统一介绍。
有两个,分别是 取址运算符 &
和 解引用(dereference,又译 提取 或 取值)运算符 *
。均为单目运算符,写在操作数前面。
取址运算应用在 T
类型的操作数 x
上,会得到一个 *T
类型的指针,指向 x
的地址;x
必须是可寻址的(addressable)。
相反,解引用运算应用在 *T
类型的操作数 p
上,会得到一个 T
类型的值;p
必须是一个有效的指针,不能为 nil
,否则会引起空指针解引用的运行时 panic。
Go 中可寻址的范围比其它一些语言要广,除了变量(意味着有对应的可变内存)以外,还可以是:
p
是一个非 nil
指针,则 *p
可寻址,&*p
其实就是对取值的结果再取址,等于 p
自身。s
是一个 切片,则 s[1]
可寻址(即使切片本身不可寻址,当然需要 s
非空且索引在范围内)。a
是一个可寻址的结构体,则 a.X
可寻址(需要有 X
这个字段)。a
是一个可寻址的数组,则 a[1]
可寻址(同样需要索引操作先成功)。[]int{1,2,3}
,结构体字面量 struct{X int}{1}
,(使用上看起来)都是可寻址的。注意这条是一条例外,实际上是一个方便使用的语法糖,本质上 &T{...}
等价于 tmp := T{...}; &tmp
,表面上是对字面量取址,实际上是对自动生成的变量取址。作为对比,以下的情况均不可寻址:
map
)中的元素。*p
)以外的所有运算。如果觉得上述列举过于繁琐,可以总结为一点:取址的对象必须是安全的可修改的内存。相对应地,不可寻址的情况可以总结为三点:
不可变的。
字面量、常量、字符串的字节 都是这种情况。如果取址之后可以修改,则破坏了不可变性;如果不能修改,那么对不可变的值取址没有意义。
中间结果。
函数返回值、类型转换、类型断言、表达式结果 等都属于这种情况。中间结果的问题是,还没赋值给一个变量,没分配可访问的内存,没有地址可言。
不安全的。
映射的元素、包级的函数 等属于这种情况。这种属于底层操作上可以取址,但是取址会引起问题,语法上规定了不可取址。
映射的元素为什么不可寻址?映射(map)底层用哈希表实现,有自己的内存管理机制,当条目数量改变时可能会调整内存并重新哈希条目,将元素在内部移动,此时如果允许寻址,之前取的地址就会失效。
为什么哪怕是不可寻址的切片(例如函数返回的切片),它的索引也可以寻址?因为后面介绍到切片就会发现,切片是一个引用类型,切片本身只是切片的元数据,底层指向一个总是可寻址的数组。对切片的索引操作实际上是对底层数组的索引,自然是可寻址的。
更多关于可寻址的总结,可以参考 https://gfw.go101.org/article/unofficial-faq.html#unaddressable-values。
只有一个 <-
,单目运算符,用在通道(chan
)变量前面,表达式的值是从通道中接收到的值,类型则是通道的元素类型。
通道必须是可读的通道(读写 chan
或者只读 <-chan
,不能是只写 chan<-
)。接收操作会阻塞直到有值可接收,试图从 nil
通道接收会一直阻塞,而试图从关闭(closed)的通道接收则会马上返回一个对应类型的零值。
这里其实我们只需要知道 <-
是一个运算符,从通道接收是一个表达式即可。详细内容可以到介绍并发和通道时再深入了解。
上表格,数字越大优先级越高:
优先级 | 运算符 | ||
---|---|---|---|
6 | 所有单目运算符:正负号+ /- ,按位取反^ , 逻辑非 ! ,取址& ,解引用 * ,接收<- |
||
5 | * / % << >> & &^ |
||
4 | + - |
||
3 | == != < <= > >= |
||
2 | && |
||
1 | ` | ` | |
0 | 语句 |
所有单目运算符优先级都是最高,换言之其它优先级里的都是双目运算符。像优先级为 5 的 *
只能是乘号,而不是解引用。
原本没有 0,最后一行是我加的,语句并非运算符的一部分。语句的优先级最低,在所有运算符之后,像赋值 =
和 自增 ++
自减 --
。像 *p++
等价于 (*p)++
,解引用是优先级最高的单目运算符,自增则是优先级最低的语句。
对于优先级,我个人的看法是,留个印象即可,不必细究。遇到优先级容易产生歧义的地方,直接加括号,清晰明了,适当增加括号没有任何副作用。
你愿意花时间熟悉优先级当然好。但是在实际开发中,你熟悉的不能保证其他人也熟悉,也不能保证自己未来会不会一时看错。保持程序的可读性非常重要。
非常巧合,我在准备这一期的内容的前几天,刚好看见 Go 语言中文站的站长徐老师分享了两道练习题,正好作为运算符的练习题,那我就直接拿来主义(文中有解析,先自己尝试做,不要直接看答案):
问题1:
一般情况下,我们用自定义类型(defined type)的常量模拟枚举。三种状态最直接的做法是:
|
|
如果只是个别地方使用,完全没有必要纠结底层类型(underlying type)。考虑到有内存对齐,底层使用 int8
也不见得可以节约多少内存,反而可能增加类型转换的麻烦。
不过如果要用到一个很大的 []State
切片,这时底层类型就值得考虑一下了。三个(或者加上 Unknown
四个)状态, bool
无法表示,就只能选择 int8
或者 uint8
了。
问题2:
true
。
这里有一个刻意的误导。str1
和 str2
指向不同的字符串;这点都不需要 str2
是拼接得到的,即使 var str2 = "hello world"
,甚至 var str2 = str1
,它们都不会指向同一个字符串。这意味着,&str1 == &str2
(&
是取址操作,得到的是指针)总是 false
。
但是,比较操作符 ==
在字符串上比较的是内容。两个字符串长度一致,对应的每个字节都相等,就会被认为相等,所以是 true。这个问题其实超出了上期的内容,对错没有关系,能引发思考和留下印象就好。
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
地址:https://hackernoon.com/billion-dollar-mistake-in-go-ll1s3tkc
作者:Harri Lainio(@lainio)
翻译:Jayce Chant(博客:jaycechant.info,公众号ID: jayceio)
十亿美元(billion dollar)的错误 / bug 貌似是美国的一个梗,大概的意思是,对于那些市值上几千亿的大企业,如果一个错误能够导致市值下跌个百分之零点几,就已经是十亿左右了。
在计算机领域,最著名的 BDM 大概是 图灵奖得主Tony Hoare 说他在 1965 年发明的 null 引用。
但我不确定这是不是最早的出处,毕竟在商业领域这样的说法也很常见。
以下为译文:
以下示例代码来自 Go 的 标准库文档:
|
|
代码看起来没什么问题。出自标准库官方文档的代码,肯定不会错,对吧。
在阅读介绍 Read
函数的 io.Reader
文档 之前,我们先花几秒钟来弄清楚这里面有什么问题。
例子里的 if
语句(至少)应该这样写:
|
|
你也许在想,我是不是在自欺欺人:我们不是应该查看 File.Read
函数的 文档 吗?那个才是正确的文档吧?是的,但那不应该是唯一正确的文档。
译者注:读到这里的朋友可能会云里雾里,又未必愿意 / 方便(特别是公众号不能外链)看完文档再回来。我简单介绍一下。
在
io.Reader
接口的文档里,当Read
遇到文件结束时,io.EOF
可能跟着非 0 的 n (读取的有效字节数)一起返回,也可能在下次调用跟 n = 0 一起返回。(这部分文档很长,有 1300 多个单词,还介绍了Read
方法其它可能的行为,但多数是建议而不是强制的口吻。)
File.Read
的文档则只有一句话,非常明确地指出遇到文件结尾时,会返回0, io.EOF
。(换言之,io.EOF
不会跟有效字节一起返回。)
如果我们不能真的用接口隐藏实现细节,那接口有什么用处?一个接口应该规定(set)它的语义,而不是像 File.Read
那样规定它的实现者。当接口的实现者是 File
以外的其他东西,但仍是一个 io.Reader
时,上面的代码会发生什么?当它把数据和 io.EOF
一起返回时,它退出得太早了,但这对所有的 io.Reader
实现者都是允许的。
在 Go 里面,你不需要显式标记接口的实现者。这是一个强大的特性。但这是否意味着我们总是应该根据静态类型来使用接口语义呢?例如,下面的 Copy
函数是否应该使用 io.Reader
的语义?
|
|
那这个版本是不是应该只使用 os.File
的语义呢?(注意,这些只是虚构的例子)
|
|
实践中认为,总是应该使用接口语义,而不是绑定到具体的实现——这就是有名的 松耦合。
这个接口有以下问题:
io.Reader
的文档,你就不能安全地使用任何 Read
函数的实现。io.Reader
的文档,你就无法实现 Read
函数。正因为 io.Reader
是一个接口,前面提到的问题才多了起来。这给 io.Reader
的每个实现者 和 Read
函数的每个调用者之间带来了跨包依赖。
标准库本身就有很多其它 io.Reader
的调用者误用(misuse)该接口的例子。
根据这个 问题单(issue),标准库——尤其是里面的测试——都坚持使用 if err != nil
这个写法,这就阻止了 Read
实现中的优化。
例如,当检测到 io.EOF
时,如果(连同剩余的数据)立即返回 io.EOF
,就会让一部分调用者无法正确运行。原因是显而易见的。reader 接口文档允许两种不同类型的实现:
Read
在成功读取 n > 0 个字节后,如果遇到错误或文件结束的情形,它会返回读取的字节数。它可能会在同一个调用中返回(非 nil)错误,也可能会在后续调用中返回错误(同时 n = 0)。
接口应该是直观的、并且是通过编程语言本身正式地定义的,使得你无法实现或者误用它们(cannot implement or misuse them)。开发者不应该需要先阅读文档才能进行必要的错误传递。
译者注:这里的 ‘cannot implement’ 感觉意思不对,不知道原作者是不是想表达错误实现的意思,却只在 use 上加了 mis,忘了 implement。个人猜测本意是 ‘cannot implement or use them in a wrong way’ ,不能错误地实现或者使用它们。但这只是我个人的猜测,写在这里,译文还是忠实于原文。
允许接口函数有多个(本例中是两个)不同的显式行为是有问题的。接口的思想,是隐藏实现细节,实现松散耦合。
最明显的问题是,io.Reader
接口既不直观,也不符合 Go 典型的错误处理惯例。它还打乱了程序推导中正常和错误分离的控制路径。这个接口使用错误传递机制来处理一些实际上不是错误的东西:
EOF
是Read
没有更多输入时返回的错误。函数应该只返回EOF
来表示输入的正常(grateful)结束。如果EOF
在结构化数据流中意外发生,相应的错误应该是ErrUnexpectedEOF
或其他能给出更多细节的错误。
io.Reader
接口和 io.EOF
指出了 Go 目前的错误处理中所缺少的东西,那就是 错误的分类(the error distinction)。例如,Swift 和 Rust 不允许部分失败。函数调用要么成功,要么失败。这就是 Go 的错误返回值的问题之一。编译器无法提供任何支持。众所周知,这同样也是 C 语言的非标准错误返回的问题——当你有一个重叠的错误返回通道时就会这样。
Herb Shutter(译者注:C++ 程序设计专家,曾担任 ISO C++ 的秘书和会议召集人,原文有笔误,应为 Sutter)特意在他的 C++ 提案《零开销的确定性异常:抛出值(Zero-overhead deterministic exceptions: Throwing values)》中提到:
『正常』与 『错误』(控制流)是一个非常基础的语义区分,而且可能在任何编程语言中都是最重要的区分,尽管这一点总是被低估。
Go 当前 io.Reader
接口存在问题,是因为违反了语义的区分。
首先,我们通过声明一个新的接口函数,停止使用返回错误来处理不是错误的东西。
|
|
其次,为了 避免混淆 以及 阻止明确的错误,我们引导使用下面的助手包装器(helper wrapper)来处理这两种允许的 EOF
行为。包装器只提供了一个显式行为来处理数据的结束。因为文档中说,必须允许在没有任何错误(包括 EOF
)的情况下返回零字节(不鼓励在无错误的情况下返回零字节),所以我们不能将读取的零字节作为 EOF
的标志。当然,包装器也保持了错误的区分。
|
|
我们做了一个错误区分规则,错误和成功的结果是排他的。我们也对返回值 left
进行了区分。当我们已经读取了所有的数据,我们会将其设置为 false
,使得函数变得更加易用,这在下面的 for
循环中可以看到:只有在 left
设为 true
,即数据可用时,才需要处理传入的数据。
|
|
正如示例代码所示,它允许将正常路径(happy path)和错误控制流分开,这使得程序推导变得更加容易。我们在这里展示的解决方案并不完美,因为 Go 的多个返回值之间并无区别。
在我们这里,它们都应该是这样的。无论如何,我们已经了解到,每个新人(包括刚接触 Go 的人)都可以在没有文档或示例代码的情况下使用我们新的 Read
函数。这就是一个很好的例子,说明 正常路径和错误路径的语义区分是多么重要。
我们可以说 io.EOF
是一个错误吗?我想说是的。这里有一个错误应该与预期的返回(expected returns)区分开的完美的理由。我们应该始终构建 鼓励正确路径(praise happy path)和 防止错误 的算法。
Go 的错误处理实践还缺少语言特性来帮助语义的区分。幸运的是,我们大多数人已经在清楚区分的控制流中处理错误。
]]>Go 的数据类型分为基本类型和派生类型。篇幅关系,这期主要讲 基本类型,派生类型简单带过。
往期内容:
下面的内容,会反复提到一个词:零值。如果声明一个变量,却不指定它的值,又或者直接 new(T)
(T 是某个类型)申请一块内存,Go 会把这块内存置零。但同样是 0,在不同的类型下,会有不同的语义。了解零值,就是要知道不同类型的默认值的含义和行为。
基本类型又分为 布尔类型、数字类型 和 字符串类型。
类型标识符 bool
,零值为 false。bool 没有直接的字面量,true
和 false
在 Go 是预定义的 bool 常量,不过使用上跟字面量没有太大区别。而很多时候,用到的不是这两个常量,而是 关系运算的结果 (关系运算符 ==
, !=
,>
, >=
, <
, <=
)和 函数返回值。
要注意的是,不像有些语言 bool 其实是数值类型的一种特例,可以或显式或隐式转换成数值。Go 的 bool 不是数值,也无法转换为数值,无法参与任何 数值运算(加减乘除) 和 位运算(按位与、或、取反等);反之,数值也不能转换为 bool。
假设现在有 int 数组 nums
,要统计其中大于 0 的数的个数:
C 里面可以这样
|
|
但在 Go 里会报错
|
|
只能老老实实用 if
|
|
包括整型数、浮点数、复数等,区分有符号、无符号,还有不同字节长度选择(对应不同的内存占用和数值范围),有多种组合,具体看表格。
1 字节 | 2 字节 | 4 字节 | 8 字节 | 16字节 | 架构相关 | |
---|---|---|---|---|---|---|
无符号整型 | uint8 | uint16 | uint32 | uint64 | uint | |
有符号整型 | int8 | int16 | int32 | int64 | int | |
浮点数 | float32 | float64 | ||||
复数 | complex64 | complex128 | ||||
特殊类型 | byte | rune | uintptr |
0
,浮点数零 0.0
, 复数零 0+0i
)。byte
是 uint8
的别名,rune
是 int32
的别名。类型别名以后再详细展开,你只要知道它们是同一个类型就可以了。位宽是类型后面的数字,它表示该类型占用了多少个二进制位。因为计算机以 字节(byte,等于 8 bit)为组织单位,位宽总是 8 的 2 整数次方倍。
uint
、int
和 uintptr
三个类型的位宽与系统架构相关。其中 uint
和 int
不小于 32 位,在 64 位系统则为 64 位;但需要注意它们是独立的类型,以 int
在 64 位系统为例,尽管范围完全一样,int
变量跟 int64
变量之间仍需转换。
uintptr
的范围则保证可以存下当前系统架构下的地址,无需特别考虑。
既然有不同的字节长度,就涉及到表示范围。范围太小,会导致数字溢出;范围太大,则引起内存的浪费。当然,如果只是个别变量,几个字节的差异可以忽略,可以直接用范围较大的类型;但涉及大数组或者大量生成的结构体的字段,则最好确定数据范围然后挑选合适的类型。(事实上,如果真的有需要节省空间和访问时间,还得考虑 内存对齐(memory alignment)或者 数据结构对齐(data structure alignment),那又是另一个话题了。)
uint8
表示的最大值为 $ 2^8 - 1 = 255 $ 。这个值除了自己算,也可以通过 math
包的常量 math.MaxUint8
获得。16、32、64 位以此类推。int8
最小值为 -128,最大值为 127。这两个值也可以通过 math
包的常量 math.MinInt8
和 math.MaxInt8
获得。其它有符号整型以此类推。float32
的绝对值范围可以通过 math.SmallestNonzeroFloat32
和 math.MaxFloat32
获得。64 位以此类推。既然数字类型都有表示范围,而浮点数还可能有精度损失(有可能是超出尾数范围,也可能是进制转换造成),那么就有可能表示范围或者精度不足。如果需要表示超出范围的值,或者涉及金钱等业务需要非常高的精度,则需要用到 math/big
包的几个大数类型,包括整型 big.Int
、浮点型 big.Float
和 分数 big.Rat
。留个印象,后续再介绍。
类型标识符 string
,就是一串有固定长度的字符序列。
str[i]
得到的就是一个 byte 类型的值。 str[0] = 'a'
),会引起报错。而 str1 = str1 + str2
中则是合法,因为 str1
和 str2
的内存都没有被修改,而是开辟新的内存存放拼接后的字符串,然后 str1
指向新的内存。原来 str1
的内存如果没有被其它地方引用,会在后续的 GC 被回收。这点有点像 Java 的 String。大量频繁拼接字符串的场景,需要考虑优化。[]byte
或者 []rune
;修改完之后也可以重新转换为 string
。无论哪个方向的转换,内存都发生了拷贝(copy),返回的新切片 / 新字符串指向新分配的内存,所以互不影响。 频繁转换时需要考虑性能损耗。[]byte
和 []rune
差别是前者每个字符的范围是只有一个字节的 Unicode (只存得下 ASCII 码 + 拉丁符号扩展1),后者则是 四个字节的 Unicode。包含中文等等内容、字符编码可能大于一个字节的字符串只能转换为 []rune
否则会出现乱码;反之 ASCII 码的内容转换为 []rune
并不影响内容正确性,只是有一定的性能浪费而已。""
。 而不是特殊的空值 nil
、null
、None
等。未初始化的字符串和空白字符串不像 Java 那样需要区分,判断都是 if str == ""
。Esc
下方的键)包裹。区别是双引号内支持转义,不支持换行;反引号字符串恰恰相反,不支持转义,所有字符都会原样保留,包括换行(但为了跨平台兼容性,换行符统一替换成 newline
,去掉 carriage return
)。反引号字符串常用于一大段的内容(为了保留换行)和 正则表达式(为了保留特殊符号不被转义)。\'
改为支持 \"
;这是因为字符串以双引号界定,单引号不再是特殊字符。string 本身值得专门开一篇文章,先说这么多。
派生类型,又叫衍生类型。顾名思义,它是在其它类型的基础上衍生出来的。
一个派生类型,严格来说,是一个大类,底下可以包含多种具体类型。例如 *int
和 *bool
虽然同为指针类型,但由于指向的类型不同,它们也是不同的类型(称作 int
指针 和 bool
指针);int
数组 和 bool
数组也是不同的类型;甚至,长度为 10 的 int
数组 [10]int
和 长度为 11 的数组 [11]int
也是不同的类型。
派生类型是个比较大的话题,个别类型光一个类型就够写一篇文章,所以这里不详细展开,只作简单罗列:
指针:从 C / C++ 一脉相承,内存管理的高阶操作;不过 Go 的指针比 C / C++ 要简单和安全得多,类型安全,也不用关心内存的释放和悬挂指针。
var iptr *int
声明了一个指向 int
的指针 iptr
,零值为 nil
(相当于某些语言的 null
,None
)。
数组(array)和 切片(slice):类似其它语言的数组和动态数组。(注意这是两个不同类型,只是性质相近一起介绍)
var a [10]int
声明了一个长度为 10 的 int
数组 a
,零值为成员都是零值的数组,可以直接使用。
var s []int
则声明了一个 int
切片 s
,零值为 nil
。
映射(map):类似其它语言的 map。不过不像 C++ 和 Java 作为库引入,Go 的 map 是语言内置的。因为 Go 没有 集合(set),很多时候也需要用 map 模拟。
var m map[string]int
声明了一个 key 为 string
,value 为 int
的 map m
,零值为 nil
。
函数(function):Go 里面函数也是第一等公民。函数既然是一种类型,那么就可以作为变量和参数。Go 支持函数式编程。
var f func(int)bool
声明了一个 『接受一个 int
参数并返回一个 bool
值的函数』变量 f
,零值为 nil
。
当然你也可以用 func
关键字直接声明一个函数 f
:
|
|
两者都是通过 f()
调用(当然,实际调用要提供一个 int
参数)。后者只能用于全局(包级)函数,必须给出函数体,f
不是一个变量,f
的值是一个具体的函数而且不能修改。从效果上接近一个函数常量(但不是)。
结构体(struct):类似 C / C++ 的结构体,但是可以定义行为(方法)。Go 没有 类(class)和 继承(inheritance),而是通过 结构体 和 组合(composition)实现面向对象。
下面声明了一个『拥有一个 string
字段 和一个 int
字段 的结构体』的变量 alice
,零值是所有字段都为对应零值的结构体:
|
|
但这样写,每次声明都要把结构体重新写一遍,啰嗦还容易错——只要字段的名称、类型或者顺序,随便一样有差别,都会被认为是不同的类型。一般情况下,除非这个结构体只使用这么一次,否则都不应该这样写。
正确的做法,是给结构体一个类型名,然后用名字来声明:
|
|
在这里 person
成了一个新的自定义类型,与之相对应,前面没有定义名称的结构体称为匿名结构体。
接口(interface):接口是一系列行为(方法签名,方法是一种特殊的函数)的集合。跟其它语言不同,Go 的接口不需要显式声明实现(implementation),一个类型只要实现了接口的所有方法,它就隐式地满足接口。是否满足接口可以在编译期静态检查,所以是类型安全的。Go 实现了类型安全的鸭子类型(duck typing) 。这种设计是 Go 的组合式面向对象的重要组成部分。
跟结构体类似,接口的定义比较长,也应该定义成一个自定义类型:
|
|
这里声明了两个 『拥有两个方法的接口』的变量。接口变量的零值是 nil
,可以接受任何满足接口的类型的值。
通道(channel):channel 用于并发时在协程间通信,是 CSP 模型的重要部分。
channel 除了区分传递的消息的类型,还分读写和缓冲区大小。其中缓冲区在初始化时决定,剩下的在类型上体现:
|
|
channel 的零值为 nil
。
关于派生类型,最后补充两点:
所有零值不是 nil
的变量,都可以声明之后直接使用(只不过值都是零);而零值为 nil
的类型,意味着需要额外的初始化,其中 切片(slice)、映射(map)和 通道(channel)都是通过内置函数 make()
申请内存并初始化。
先定义为自定义类型,再用新类型声明变量,对 结构体 和 接口 来说,既不是必选项,也不是特权。这句话的意思是:
这部分内容只是为了让大家对派生类型有一个整体的印象。细节会在用到这些类型时详细展开。
Go 使用 type
关键字自定义类型,有两种用法:
语法 type TypeName TypeDefinition
例子:
|
|
类型定义 看起来很像 C / C++ ,只是把名字移到了前面:
|
|
或者 Java 的
|
|
虽然像,差别也很明显:
typedef
倒是也可以用于基本类型,但得到的实际上是一个别名,新旧类型仍然是同一种类型;而 Go 声明了一种新的类型。以 type NewInt int64
举例,虽然它们共享同样的内存实现(8 个字节的连续内存),在基本运算符上有同样的结果,但是 NewInt
被认为是跟 int
不同的一个类型,可以拥有自己的方法。两种类型不能直接一起运算,也不能用作另一种类型的参数,需要经过转换。
不直接使用原类型,而是定义命名的新类型,我认为有以下几个原因:
使用方便。
这是对冗长的派生类型——尤其是 结构体 和 接口 而言的。简洁的名字当然比冗长的结构方便且不易出错。
自注释。
名字可以体现用途和意图。
借用静态检查发现错误。
将底层实现一样但是业务逻辑不一致的类型分别定义为不同的类型,可以借由静态检查发现逻辑错误。这在上一期类型转换部分,我用 砧板 和 地板 的 底层类型都是 木板 做了一个类比。
添加方法。
Go 可以(且只可以)给当前包定义的类型添加方法。内置类型和导入类型定义成新类型之后,就可以给新类型添加方法,实现面向对象编程。
需要注意的是,定义成新类型之后,原来类型的方法就全部丢失,不能再访问了(毕竟已经不是同一个类型)。如果需要保留原来的方法,应该选择将旧类型匿名嵌入新类型的结构体。匿名嵌入效果上接近继承,实际上是组合,只是跟一般成员组合相比,被匿名嵌入类型的成员和方法可以直接访问。具体在 方法 和 结构体 部分展开。
语法 type TypeAlias = AnotherType
例子:
|
|
类型别名 type IntAlias = int
中,IntAlias
被认为是 int
的别名,看作是同一类型,可以直接一起运算或者作为参数,无需转换。类型别名自 Go 1.9 引入,用来解决迁移、升级等重构场景下,类型重命名的兼容性问题,以及方便引用外部导入的类型。
实际上,类型别名仅在代码中存在,编译时会全部替换成实际的类型。只有类型定义产生了新的类型。
说到自定义类型,就顺便提一下枚举。
在数学和计算机科学上,枚举是指列出一个有限集合的所有成员。而枚举类型是一种特殊的类型,只能取限定的某几个值。有些语言只是限定了枚举类型的取值(C / C++);而有些语言则(以常量的形式)直接预先初始化了枚举类型所有可能值的实例,变量不仅仅只能取有限的值,而是只能是这几个实例之一(Java,Python)。
Go 没有提供对枚举的支持。是的,Go 跟谁都不像,根本没有枚举。相对应地,在 Go 里一般通过 自定义类型 + 常量 模拟枚举。我们来看看官方库里面 time
包对 月份 Month
和 周几 Weekday
的定义:
|
|
在这个例子里,Month
和 Weekday
都是底层类型为 int
的自定义类型;然后这两个类型定义了一系列的常量作为取值范围,并且定义了一个 String()
方法,返回对应值的字符串形式。
在 1.4 之后,Go 工具链提供了 go generate
命令,配合 stringer
工具可以自动生成常量的 String()
方法。除此之外,也可以按需给新类型添加各种方法,模拟其他语言里的枚举,或者增加需要的功能。详情可以自己查阅,这里不再展开。
有独立的类型、通过常量给定取值、能返回字符串,肯定比直接用一个整型数来表示要强。自定义类型被认为是跟原来不一样的类型,在赋值或者传参过程中,如果使用了不同类型的变量,直接在编译时就报错了;另一方面,如果只通过常量引用这些 “枚举类型” 的值,取值范围也限制住了。基本实现了枚举的目的。
但也只能说部分实现。提供的常量只是给出了范围的建议值,而不是强制值。Go 没有提供『把类型的取值范围硬性限制在某几个值』的语义。新类型的取值范围仍然是和底层类型一样:
|
|
如果说 m1
是违反了(模拟的)枚举类型只使用常量引用的原则,注意一下就可以避免;那么 m2
这种忘记赋值的情况可能更难发现一些。
既然没有办法通过类型安全本身限制取值,就只能在使用时注意判断值的范围,特别要处理意外的情况。在判断枚举值时,一般使用 if-else
或者 switch-case
代码块,这时记得加上一个 else
或者 default
处理无效值。又或者干脆给类型添加一个 IsValid()bool
方法,判断值是否有效。
当然这种实现方式也并不全是坏处。Go 没有限定枚举必须是什么样子,就可以按自己的需要设计:
总的来说,我个人觉得在枚举这个问题上处理得不够好,好像不太符合 Go 一向强类型和自带最佳实践的风格。当然也有可能是我理解得不够深入。但无论怎么说,目前的实现方式在官方库非常普遍,出于兼容性的考虑,至少在 Go 1.x 阶段不太会改动的样子。那就接受这个设定,并且小心避开潜在的坑吧。
问题1 :
假设程序中需要储存一个状态,有 待办、进行中、完成 三种状态,应该怎么样定义类型?
如果这样的状态需要储存非常多个,定义成一个大数组储存,该如何节省空间?
问题 2:
问以下程序的输出,为什么。老规矩,不用运行就得出答案更佳。
|
|
第二期最后的练习,答案如下
如果还没做练习,不要直接看答案
|
|
你答对了吗?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。
注意,文中出现的大多数代码都只是关键片段,如果想尝试运行,需要补充程序的必要成分。关于一个完整 Go 程序的结构,请看第一期的内容。
接着上一期,这期还是先介绍一些概念性的话题。
如果用一句话来描述,Go 的定位就是 保留 C 语言的简洁性和执行效率的前提下,重新设计放下历史包袱,增加现代特性,表达力和易用性向 Python 等现代语言靠拢 服务于软件工程的语言设计。(划掉的是我啰嗦又辞不达意的总结,加粗的是 Rob Pike 2012 年一个演讲的主题。)
我在写这一段时,发现无论如何都很难概括好。然后我就放弃了自己概括,改为学习大佬讲话了。我还把这个演讲翻译了分享给大家:
我个人看这篇文章的感受是受益匪浅。Rob 在 8 年前 Go 1 时提到的内容,8 年后仍然有效。8 年里 Go 的语言设计只是在保持兼容的基础上做了微调,更多的投入,是在运行时和工具链的改进上。他们从一开始就知道要做什么,怎么到达。作为对比,很多语言流行后的主要用途跟当年创造的目的大相径庭:一开始的目标没有达成,却意外收获了一个新阵地,然后被新的状况推着前进。
而这几个从 贝尔实验室 干到 Google ,参与过创造 Unix、Plan 9、UTF-8 再到 Go 的老大爷(Ken 今年 77 岁,Rob 也 64 岁了),四五十年的开发生涯,清楚地知道在工程实践中,什么是重要的。
Go 的语法大致是类似 C 语言的,有 C 族语言经验的话,很容易习惯,没有也不难学。这里只是提几个比较特别的点。这些语言风格和惯例的内容,一开始就要接触到,又很难归入哪个话题,先留个印象,后续如果涉及到会再次说明。
这可能是最显眼的差别。Go 里面所有的声明,都是统一的 关键字 命名 定义内容
这样的顺序。Rob 称这样的语法为『类型语法(type syntax)』,而是 C 的语法是『表达式语法(expression syntax)』。
Go 的例子:
|
|
|
|
阅读代码时,经历一个『是什么——什么名字——具体内容』理解的过程,还是很自然的。关键是,不同的声明都很统一,不需要切换思维模式。用 Rob 的话说,无论对人对计算机来说,都好解析。
C 的表达式语法,大部分情况下不过不失,但是考虑上数组和指针,情况就变得脑筋急转弯起来:
(如果你没有接触过 C 族语言,以下对比可以先跳过)
|
|
C 是很多人接触计算机的第一门语言,不知道你初学时是否有过跟我类似的困惑:
array[10]
的类型是 int
,这我知道了,数组在哪?是 array[10]
还是 array
?
同样 *iPtr
的类型也是 int
,但 *iPtr
是什么东西?指针在哪里?
表达式语法试图通过告诉我们『变量引用时指向什么』,来让我们知道『这是个什么变量』;下标引用得到 int
,就是 int 数组,这有点跳跃;声明的数字是大小而不是下标,容易产生 array[10]
是 int
那 0 到 9 呢的疑惑;最主要的问题,还是 array[10]
视觉上连在一起,需要特意去识别,这在复杂的函数指针上就更严重了。这是个历史遗留问题,这么多年我们早已习惯,但不代表这就不糟糕。
相比之下,var array [10]int
和 var iPtr *int
就直接多了,起码变量名一眼可见,没有粘连;类型也很好理解,[10]int
10 个 int 的数组,*int
指向 int 的指针。
好比向别人介绍一款他没见过的食物,正常情况应该是『它的名字叫饺子(array),馅料是猪肉末([10]int)』;没有人会上来就『饺子馅(array[10])是猪肉末(int)』,对方会疑惑,这个食物叫 饺子馅?
Go 没有常见的 public
和 private
关键字,而是 靠名称首字母的大小写来控制可见性。因为代码组织的单位是包(package),包内都是可见的,区别在于是否能被包外面访问。
首字母大写的名称像 Name
称为导出(exported)标识符,包外可以访问,相当于 public;除此以外的情况,像 name
或者 _name
对包外都不可见,相当于 private。这条规则对所有标识符,包括常量、变量、类型、函数、方法、字段 …… 统统有效。你只要看一眼名字,就能知道是否可以被包外访问,不需要再查看声明。
唯一的例外是内置类型,像 int
、float64
、string
、 map
都是未导出标识符,但却可以全局访问,甚至连导入(import)都不用。
Go 支持下划线命名(又叫蛇形命名 snake case,像 snake_case
),但不推荐。按惯例除模块名和包名以外,推荐所有标识符都是用驼峰式命名(camel case,像 CamelCase
或者 camelCase
),包括常量。
大多数 IDE 会在保存时自动执行。如果你习惯使用记事本,手动执行一下也不难。gofmt
会把合法的 Go 代码格式化为统一的规范。它多管闲事到,缩进是 Tab 而不是空格,左花括号 {
不用换行,哪些地方有空格哪些没有,代码块之间要不要空行空多少行 …… 都管。
不要以为只是自带了一个工具这么简单。因为自带了,所以大家都有,不用额外安装第三方和产生不同的规范(即使有第三方,也是考虑兼容官方规范之后再增强);因为有官方规范,所以大家不用争论那种风格好;提交时不会产生因为格式不同产生的差异和冲突 …… 等等。
请务必把这个功能用上。要么打开 IDE 的自动格式化,要么记得手动执行一下。
Go 总是以清晰明确为第一目标,让人易读无歧义,让机器好解析编译快。所以 Go 不一味追求表达力强,甚至有点逆潮流地刻意区分一些语句和表达式,以避免某些单行长表达式的写法。
既然反正都是要换行的,分号就给省掉了。
而上面提到的声明格式,其实有省略的余地。
第一个是类型推断:
|
|
第二个是集中声明:
|
|
集中声明除了节省敲几个关键字的时间,更重要的是让同类声明放在一起,更有条理。
啰嗦一番之后,这里正式进入本期的主题。内容较多,如果通读有压力,可以跳着看留个印象,遇到问题回来翻阅。
在下一期的数据类型之前,先讲常量 和 变量。我们从 值(value)说起。
计算机科学中,值是指『无法进一步求值的表达式(expression)』。像 1 + 3
这个式子,可以进一步求值得到 4
,但是 4 已经无法进一步简化,那么 4 就是一个值,是 1 + 3
这个表达式(还有 2 + 2
、 5 - 1
…… 还有 4
本身)的值。简单一点理解,可以认为本质上就是一个 数。
当需要用到一个值,就需要表示、储存和引用它,涉及到三种量(quantity):字面量、常量、变量。
广义上的常量包括 字面量。
字面量又被称作 无名常量(unnamed constant)或 字面常量 (literal constant)。与之对应,一般所说的常量因为关联了标识符,又被称作 有名常量(named constant)。
字面量 和 常量 在很多语言里,底层实现都类似甚至一致,都是 编译期确定、储存在静态只读数据区、值不能修改,而且很多使用场景,两者都能互相替代。
但是,多数语言(包括 Go)只支持基本类型的(有名)常量,所以严格来说,字面量 和 常量 不能等同。派生类型想表示一个固定的值,只能使用字面量,或者用变量的同时对修改加以限制。
字面量(literal)是源码中对一个固定值的表示。换言之,它的值,如字面所示。
几乎所有类型都有对应的字面量表示方法。基本类型的字面量举例:
1
, 2
, 100
, 1000
, 0b101
(二进制 5), 0xff
(十六进制 255);1.0
,1.1
,1e4
(科学记数法 10000);'a'
, 'B'
;"字符串被双引号包围"
,还有一种反引号(Esc
键下方的键)包裹的字符串;需要注意的是,bool
没有字面量,在其它语言被定义为字面量的 true
和 false
, 在 Go 是内置的 bool
型的(有名)常量。
对于派生类型,字面量的表示是在类型后面加花括号 {}
,并在花括号内指定成员的值(如有),未指定的成员则为零值。例如 a := [4]int{7}
得到这样一个数组 {7, 0, 0, 0}
。具体到介绍具体类型时讨论。
在实际使用上,那些可以在编译期确定的值,像编译期求值的 表达式 和 内置函数返回值,也可以近似看作字面量(无名常量),因为编译器会求值并用得到的值替换它们。
常量(constant,关键字 const
)则是编译期就确定的,在程序运行中不能被修改的有名值。
因为需要在编译期就确定值,常量必须在声明时就指定它的值,而且只能是 引用 字面量 或其它 常量 的表达式 或 内置函数的返回值 ;编译器会对表达式或内置函数求值,原来的表达式或函数不再保留:
|
|
如上所述,常量可以看作给字面量绑定了一个名称,后续用名称引用。
实际上,在程序里使用 Pi
(声明为 const Pi = 3.14
)和直接使用字面量 3.14
的效果是完全一样的。两种做法 在 Go 里,甚至连生成的汇编代码都几乎一样,常量名实际上只在代码里起作用,编译后都是替换成直接访问存放在 SRODATA (即 static read-only data,静态只读数据)区的值。
既然效果一样,为什么需要常量呢?一般基于以下两个理由:
通过命名提高可读性:命名可以描述一个值的用途,提供值以外的信息,提高代码的可读性。
试想在一个程序里,既需要用到 π 的近似值 3.14,然后刚好另外有一个常数也是 3.14 (例如,计算材料时,某种标准石膏柱的体积是 3.14 立方米)。那么如果都直接使用字面量 3.14
,编码中就需要额外的精力去区分 3.14
究竟是指哪一个。而如果改用标识符 Pi
和 Volume
,就非常明确了。
这种光看字面量无法识别含义的值,称为魔数(Magic Number),是开发中需要避免的。
命名还能提高代码的可维护性:命名常量只需要修改声明处的值,就能改变所有引用的值。
还是 3.14 的例子。如果后面石膏柱的体积改变了,变为 10。那么我们就要把所有含义为石膏柱体积的 3.14
改为 10
;与此同时,π 的值当然没有变,含义为 π 的 3.14
必须保持不变。又或者 π 的值需要提高精度到 3.141593
,保持另一个常数不变。 当这两个值在代码中被大量引用时,即使有搜索功能的辅助,要正确地把值改过来,既不遗漏也不错改,也是一件吃力不讨好的差事。
如果使用了常量,就只需要修改常量声明处的值即可。
反过来说,如果一个字面量满足以下至少一点,就应该考虑定义为常量:
再次提醒注意的是,在 Go 里面常量(的底层实现)只能为 基本类型 (即 布尔型、数字类型、字符串类型 3 类,后面会讲到), 不可以是各种派生类型。
对于集中声明的常量,编译器允许省略标识符以外的内容,省略的部分自动补全为上一行的内容。注意,要自动补全, 常量 和 集中声明 (共用一个 const
关键字)这两个条件缺一不可。
|
|
等价于
|
|
错误示范:
|
|
除此之外,Go 还预定义了一个特殊的标识符 iota
(iota 是第九个希腊字母的发音),来方便定义常量。
iota
的值的变化规律是:遇到 const
就归零,每遇到一行常量声明(无论是否引用 iota
)就加一。或者换句话说,iota
是 const
声明块的声明行行号(从 0 开始)。看例子:
|
|
结合 自动补全,能够大大简化一些有规律的常量声明:
|
|
这样声明还有一个好处:有些常量对具体的值没有要求,但是要求一组常量之间总是保持一个先后关系;用 iota
声明,就不需要一个一个手动输入后续的值;而当需要加入新的常量时,直接插入中间,后续的值会自动后延。
因为常量要在编译期确定,而且后续无法修改,所以无法保存在运行时运算得到的值,也无法在运行过程中对值进行修改。这时就需要用到变量了。
变量(variable,关键字 var
),本质上是 一个关联了标识符(命名)的储存地址,用来保存 允许运行时确定或者改变的值。稍复杂一点的程序,都很难不使用中间结果直接运算出最终结果,变量允许我们 储存、引用、修改 中间结果,把复杂的运算层层分解成简单运算,再把中间结果拼接成最终结果。所以变量是实际编程最常打交道的。
|
|
其中 Go 的局部变量还有两个常量和全局变量没有的特点:
前面在语法与风格部分有提到,可以省略常量和变量声明中的类型,让编译器根据赋值的字面量推断类型。
而局部变量还能更进一步,把 var
关键字也省略掉,改用短声明赋值符号 :=
(就是冒号后面紧接等号),表示声明同时赋值的语义:
|
|
但是严格来说,短声明跟 var
+ 类型推断还是有区别:
|
|
var
关键字后面跟着的,都必须是新声明的变量;而短声明则意味着『至少声明了一个新变量』,不需要都是新变量。这种特性加上局部变量遮盖(shadow),容易产生一些非常难以察觉的错误,所以短声明要谨慎使用:
Go 不允许局部变量定义了却不使用。这是一个编译错误,不是警告。常量 和 全局(包级)变量 无此限制,只有局部变量有。
|
|
无论 常量 还是 变量,都不允许重复声明:
|
|
但是以下代码却是合法的:
|
|
这是因为常量 / 变量有作用域。第二个例子里面,后声明的 a, b, c
实际上是 不同作用域下的新常量 / 变量,所以不会产生『重复声明』的错误,它们可以同时存在。
而在引用的时候,会从引用位置的作用域开始往外查找,引用最近作用域的值。一旦更内层的作用域声明了新的 常量 / 变量,外部的值就无法引用到,这种情况称为 遮盖(shadow,又译作 遮挡、隐藏)。
多数语言都是这样的设计。但是在 Go 里,遮盖 跟 变量短声明 放在一起,很容易产生不起眼的错误。先看正常的代码:
|
|
然后很自然地,随着其他代码的加入, main
函数改成了这样(tryPerform
函数不变):
|
|
关键是,这个错误编译器无法检查出来,因为 :=
有歧义,在声明多个变量时,同时混合了 赋值 和 声明 的语义。在第一份代码中,result
已经存在,同时新变量 success
满足了短声明至少声明一个新变量的要求,所以短声明『很聪明』地理解了 result
只是要赋值。当因为某些修改,创建新的作用域时,在这个作用域内 result
还没被声明(尽管可以访问到外层的 result
),短声明又『很聪明』地声明了新的 result
。
这导致超出预期的行为。
解决办法也很简单:大跨度(特别是跨作用域)使用的变量,不要用短声明,老老实实用 var
关键字。var
很明确地告诉我们,是新声明的变量,没有 var
则只是赋值。视觉上,var
关键字比等号前的冒号好辨认;语义上不存在歧义,编译器很容易发现错误。
需要特别提醒一下的是,在代码块开头声明的变量,作用域也限于代码块内,哪怕声明位置在花括号 {}
以外:
|
|
如果变量在代码块之后还需要引用,就应该在代码块之外事先声明:
|
|
Go 是静态强类型(static strongly typed)语言。换句话说,Go 的类型是编译期确定的(静态),而且需要显式的类型转换(强类型)。在这个基础上,Go 又引入了类型推断(隐式类型 implicity typed,但类型仍然是在编译期可以推导得到,运行时不允许修改,仍然是静态强类型),加上 常量 和 变量 的处理不一样,显得好像有点复杂。下面梳理一下。
字面量、常量 和 变量 放在这里一起讲,做个对比。
Go 有两种意义上类型 :
一个是显式的类型 type
。
它可以在声明时指定,也可以在赋值时推断出来。在绝大多数语境下,当我们提到『类型 type』这个术语,说的就是这个类型。没有指定类型称为无类型 untyped
。
一个是编译器根据 字面量 或 表达式的值 推断得到的 常量专用类型 Ctype
(constant type 的缩略)。
编译器源码里的注释是:
Ctype describes the constant kind of an “ideal” (untyped) constant.
翻译过来就是:Ctype 描述了一个理想情况下的(无类型)常量的常量种类。
换言之,Ctype
是 untyped
常量(包括字面量)特有的,是作为没有显式 type
时的补充的隐式类型。一个值允许在逻辑上没有类型,也就是无类型 untyped
;但这个值又有储存、运算 的需要,所以编译器就给它推断一个 Ctype
(和对应的默认 type
)。
一个常量,如果显式指定了 type
,就没有 Ctype
的事;如果没有指定,则根据绑定的值确定,究竟是有类型 type
还是无类型 untyped Ctype
。
对于绝大多数类型,这两者差别不大,只是 untyped
逻辑上没有类型,允许自动转换(当然还需要满足转换规则,除数字类型以外的类型,都必须底层类型一致才能转换),一般的使用没有差别:
|
|
a 和 b(以及 aa、bb),c 和 d, 在语义上有差别,但不涉及类型转换的话,使用上完全没差别。
因为不同的数字类型之间允许转换,type
和 Ctype
的差异 主要体现在数字类型上。
因为还没讲到,先稍微列一下数字类型:
其中特殊类型里,byte
是 一个字节的 ASCII 字符(uint8
的别名), rune
是四个字节的 Unicode 字符(int32
的别名),可以归为字符类型;uintptr
实际上也是一个整型,只是这个数字表示一个内存地址。
大类 | type | Ctype(默认 type / 储存宽度) |
---|---|---|
整型 | uint8, int8, uint16, int16, uint32, int32, uint64, int64, uint, int, uintptr | int (int) |
浮点数 | float32, float64 | float(float64) |
复数 | complex32, complex64 | complex(complex64) |
字符 | byte, rune | rune(rune,即 int32) |
整型看起来很多类型,其实只是 有没有符号 和 位宽 的差别,下一期讲基本类型会讲。
字面量无法指定 type
,只有 Ctype
。
数字类型可以分为四个大类(kind),每个大类下面根据表示范围又可以分为很多个类型(type)。每个大类对应一个 Ctype
,同时对应一种默认的 type
。字面量会根据表示形式,自动推断为对应的 Ctype
,并以默认类型储存。
整型数字面量会被推断为 untyped int
,默认类型为 int
(int
的位宽与架构相关,64 位系统为 64 位,32 位系统为 32 位)。
以下字面量都被认为是整型数(二进制和八进制从 1.13 开始支持):
159
。0b
或 0B
开头(大写有效,但 gofmt
会自动格式化为小写,下同),如 0b10011111
(即十进制 159)。0
、 0o
或 0O
开头,如 0o237
(即十进制 159)。0x
或 0X
开头,如 0x9f
(即十进制 159)。浮点数字面量会被推断为 untyped float
,默认类型则为 float64
。
浮点数的字面量形式有:
普通十进制小数,如 15.9
;整数和小数部分都可以为零,1.0
和 0.0
虽然 和 1
和 0
值是一样的,但是推断类型不同。
整数或者小数部分如果为零,可以省略,但不能同时省略(毕竟不能只剩下一个小数点),如 .9
等同于 0.9
, 1.
等同于 1.0
。
科学记数法:十进制整数或符合前面两条的浮点数 + e
/ E
+ 十进制整数的指数,如 1.59e2
表示 $ 1.59 \times 10^2 $ 也就是 159,314E-2
表示 $ 314 \times 10^{-2} $ ,即 3.14 。
从 1.13 开始,支持十六进制的科学记数法:十六进制的整数或小数 + p
/ P
+ 十进制整数作为指数,如 15.9p7
表示 $ (1 \times 16^1 + 5 \times 16^0 + 9 \times 16^{-1} ) \times 2^7 $ (即十进制的 2760);p
后的指数是以 2 为底的,注意指数即使为 0 也不能省略。
这种表示法用于二进制(十六进制)小数比十进制清晰简单,像 0x.01p0
,对应十进制的 0.00390625;一般很少用到,了解一下即可,不展开。
复数字面量会被推断为 untyped complex
,默认类型为 complex128
。
复数由实部和虚部组成。实部和虚部分别都是一个整型数或者浮点数,只是虚部后面跟着一个 i
;实部和虚部允许用不同的进制分别表示,具体规则参考整型数和浮点数部分。只是为了兼容 1.13 以前的旧代码,虚部的八进制必须以 0o
或 0O
开头, 0
开头会被当做十进制的前导零。
例如 159 + 7i
,实部 159,虚部 7;0111 + 010i
实部为 73(八进制),虚部为 10;等等。实部如果为零,可以省略;但虚部不可以省略:0i
会被认为是复数,0
和 0.0
则分别被认为是 整型数 和 浮点数——尽管它们都是零值,值是相等的。
从数学上讲,浮点数(小数)是复数的特例(虚部为 0);整型数则是浮点数的特例(小数部分为 0)。但是从计算机更有效储存和运算的角度,需要把它们区分开来,一直为 0 的部分,就不必开辟储存空间。
从 1.13 开始,允许在数字中间加下划线 _
作为分段符来提升字面量的可读性。按英文惯例每三位加一个分段符,那么 十万八千 就写作 108_000
;对于十六进制数,一般每两位(一个字节)作为一个分段,如 0x_15_ef
。当然这只是惯例,也可以根据需要分段。分段符每次只能加一个,只能加在数字之间或者进制前导符和数字之间。这个实际试一下就知道了。
字符字面量会被推断为 untyped rune
,默认类型为 rune
。
两种字符类型只是两种整型数的别名。
字符及字符串相关部分会涉及到字符编码的知识,篇幅关系,不一一展开。初学者如果觉得难以理解,可以先跳过,先使用普通字符字面量。
byte
对应 uint8
,储存的是 1 字节长度的 Unicode 码(相当于 Unicode 开头 ASCII 码加上 Latin-1 辅助字符部分);
rune
对应 int32
,储存的是一个 4 字节长度的 Unicode 码。
字符型的字面量均以单引号 '
包裹,形式有:
普通字符,如 'a'
(十进制码值 97),'汉'
(十进制码值 27721,十六进制 0x6c49)。
码值转义,又分四种情况:
\
后接 3 位八进制数,前导零不能省略,如 '\141'
对应十进制码值为 97,即 'a'
;'\041'
对应十进制码值 33,即 '!'
,不能写作 '\41'
;3 位八进制数最大能表示十进制的 511,但由于这种表示法用来表示 1 字节的 Unicode,大于 377 (即十进制 255)的值均无效。\x
后接 2 位十六进制数,前导零不能省略,表示 1 字节长度的 Unicode,'a'
表示为 '\x61'
。\u
后接 4 位十六进制数,前导零不能省略,表示 2 字节长度的 Unicode (或者说高 2 位为 0 的 4 字节 Unicode),'a'
表示为 '\u0061'
;这个范围已经可以表示大部分的常用汉字了(严格说是『中日韩统一表意文字』的 初期统一汉字 + 扩展 A 区),如 '汉'
表示为 '\u6C49'
。\U
(大写 U)后接 8 位十六进制数,前导零不能省略,表示完整 4 字节的 Unicode ,这个范围已经可以表示绝大多数的 Unicode 字符了(Unicode 标准仍在扩展中), 'a'
表示为 '\U00000061'
,'汉'
表示为 '\U00006C49'
。如果 \
后面接的字符不是 数字、x
、u
或 U
,则被当做转义字符。转义字符实际上是常用的不可见字符的表示方式,避免记忆 Unicode 码值,常见的转义有:
'\b'
退格符(backspace),对应 '\x08'
,作用是光标往左一个字符,有些情况下意味着删除一个字符;'\n'
换行符(newline),对应 '\x0A'
,作用是光标往下一行;'\r'
回车符(carriage return),对应 '\x0D'
,作用是光标回到行首;……
比较特殊的是 '\\'
和 '\''
,就表示 \
和 '
,并非不可见字符;但由于单个符号有特殊含义,必须转义才能原义输出。
以上内容总结起来就是:数字类型的字面量,根据具体形式,会被推断为 4 种 Ctype
,并按范围最大的类型作为默认类型储存(整型除外,int
不能包含整型的最大范围)。
超出默认类型范围的值,会引起溢出错误,无法储存和使用。整型比较特殊,默认底层类型是 int
:在 64 位系统为 64 位有符号数,1 << 63
到 1 << 64 -1
之间的数可以以 uint64
储存;在 32 位系统为 32 位有符号数,uint32
、int64
、uint64
都有超出 int
的范围;当然如果连 int64
(负数最大范围)和 uint64
(正数最大范围)都超了,还是只能溢出。
网上有教程说,字面量(和常量)不限范围,这种说法是错的。他们的依据是以下的代码可以正常编译运行:
|
|
在这个例子里,如果看了生成的汇编代码就会发现,a
和 1 << 64 - 1
都不见了。因为编译器很聪明地发现,a
没有被引用到,在编译时就把它们优化删除掉了,所以没有报错。
还有这个例子也可以正常运行:
|
|
看了汇编结果就会知道,编译器实际上把上面的代码里的 a
优化掉了,b
绑定的值变成了 4611686018427387904
(1 << 62
)。
换言之,溢出的字面量只能存在于代码里,而且溢出值不能超出显式类型范围,不能被直接引用 。如果这个溢出的值,经过编译器求值之后发现,是一个可以优化的中间值,实际上没有被引用,编译器就不会报错;反之,如果有溢出值被引用到了,或者虽然没有直接引用溢出值,但超出了显式类型的范围,就会报 overflow:
|
|
关于转换,在下面会提到。
(有名)常量则有可能有 type
,也可能无类型只有 Ctype
。
常量在声明时:
type
,常量的类型跟绑定的值一致,前面用 bool
和 untyped bool
、string
和 untyped string
举过例子;type
,如果跟绑定的右值类型不一致,就涉及到转换;如果无法转换,就报错。常量被引用时,类型与所需类型不一致,就需要转换,这时要看常量的类型。
Go 是强类型语言,必须显式转换类型,但这仅限于类型确定 typed
的情况,untyped
会隐式转换。常量的转换跟变量不同,要求值要相等。只要一个 untyped
值可以以另一种类型表示,编译器会做自动的隐式转换;但转换过程中不允许有任何溢出(和因此导致的截取(truncated));而浮点数除了溢出,还有精度丢失,浮点数之间的转换中允许精度丢失。
溢出和精度丢失的差别,用一个简化的十进制字符串的例子来说明:
假定我们用一个长度为 8 的字符串储存一个小数,符号和小数点不能省略,那么可以表示的最大和最小的数分别是 “+999999.” 和 “-999999.” ,比前者大就是上溢出,比后者小就是下溢出。与此同时,最小精度(非零绝对值最小)是 “+.000001” ,比这更小的值无法表示。1000000(6 个 0)要转换为 “+000000.” 保存是溢出后截取低位,0.1234567 转换为 “+.123456” 保存则是精度丢失。
浮点数的二进制指数表示比这个复杂,还涉及到编码和进制转换,但溢出和精度丢失的原理是一致的。一句话总结就是,精度范围内超出是溢出,精度范围以外超出是精度丢失。
|
|
从十进制角度看,浮点数在某一位左边就是溢出,右边就是精度丢失,会非常费解。因为实际上这些数是以二进制保存的,从二进制的角度看就会顺理成章。这里不展开,有兴趣的朋友可以自己去看 IEEE-754 的标准。
一般程序不容易超出这些范围,但还是需要知道范围的存在。math
包有一系列常量给出了不同数字类型的最小值(MinXXX)、最大值(MaxXXX)和浮点数的最小小数(SmallestXXX)。如果涉及大数运算和对精度有特殊需求,则需要用到 math/big
包。
理解 整型数 和 浮点数 的转换之后,复数 和 字符 也很好理解。
复数的实部和虚部分别是一个浮点数,复数之间的转换可以直接参考浮点数。虚部为 0 的复数可以向浮点数自动转换,实部如果没有小数部分还能向整型转换;但是虚部不为 0 不能被截断(truncated):
|
|
字符则本质上就是整型,只是字面量形式不同:
|
|
自动转换限于 untyped
,如果一个常量已经指定了类型,那么哪怕值满足了转换条件,也必须显式转换;而且在显式转换中,值仍然要保持相等(显式转换是 T(src)
,T 是目标类型,src 是来源值):
|
|
变量一定有显式的类型 type
,不存在 untyped
的变量。
变量声明时,类型可以指定,也可以推断;如果声明时没有指定 type
,则类型跟赋值的右值一致;如果右值是 untyped
常量,则类型是对应的默认类型(而不是 Ctype
,数字类型的默认类型分别是 int
,float64
,complex128
,rune
)。
跟常量类似,变量被引用时也会出现与需要的类型不一致,需要转换类型:
由于变量一定有确定类型 (typed
),只能是显式转换。
非常量(包括变量)的转换允许溢出也允许精度丢失:
int8
转换时,保留低 7 位(去掉一位符号位,int8
数字位只有 7 位),得到 0 。int8
转换,得到 1。1<<64 - 1
(18446744073709551615,最大的 uint64
)转 float32
得到 1.8446744e+19
,73709551615 丢失。real()
和 imag()
提取实部和虚部;复数之间转换,实部和虚部分别是一个浮点数,跟浮点数之间的转换一致,也允许精度丢失。+Inf
和 -Inf
,复数则是具体实部或虚部是无穷)。注意到我在提到变量的转换时,提到了『非-常量』(注意断句),而不是直接说变量。
难道还存在常量和变量以外的量?是的。
首先是前面提到的,可以在编译期求值的 表达式 和 内置函数的返回值,实际使用上跟字面量一致,差别是 字面量一定是 untyped
的,而这种值视乎具体情况,有可能是有类型的。不过 转换规则仍然跟常量一致,差别仅仅是没有标识符,算广义的常量:
仅引用了字面量 / 常量的 内置函数返回值,如 len("1234")
,字符串 "1234"
是字面量,len()
是内置函数,字符串的长度在编译期就可以算出来是 4,这个值在编译的时候就会替换掉函数;但类型受函数返回值影响为 int
,跟 4
这个整型字面量的 untyped int
仍然有差别(有了确定类型就不能自动转换)。
相对地,如果 a
不是常量,那么 len(a)
就不能在编译期求值了;内置函数则是指不需要导入就可以调用的函数,math.Abs()
这样还要导入的函数不算(尽管是官方库)。
仅引用了字面量 / 常量的 表达式的值,如 1 + 2 + 3
就不必说了, len("1234") + 1.1
也是。
类型方面,如果表达式引用了多种类型,则会往一个统一类型转换,然后以该类型运算。范围窄的向范围广的类型转,untyped
往 typed
转;如果有多个不同的 type
,则需要显式转换。如果无法统一类型,就会报错。如 1 + 2
的类型是 untyped int
;1 + 2.0
是 untyped float
;len("1") + 1.0
是 int
;而 len("1") + 1.1
会报错,因为确定类型 int
无法自动转换,而 1.1
自动转换成 int
会造成截取,丢弃小数。
然后跟第一种情况相反,表达式或函数里出现了 非常量或者非内置函数,就无法编译期求值。它既不是常量,又没有像变量给一个内存空间,实际运行中可能会在编译器生成的临时变量或者寄存器上保存和运算。这就是前面提到的 『非-常量』。
不过这里只是提一下它们的存在,转换方式其实没有超出上述的情况的组合:
看类型,如果是 typed
就必须显式转换;untyped
则可以自动转换;
看是否常量(广义的,包括字面量),是常量就必须值相等不允许值的截取;常量以外则允许值按一定规则截取。
这样一组合,一共就四种情况而已,上面提到的那么多种情况都包括在内。
需要注意的是,当赋值时涉及转换,转换规则按 来源值 决定,如:
|
|
就属于 untyped float
常量 转换为 int
:untyped
允许自动转换,但是常量决定了不能截断小数,会报错。
到这里,想再提一下强类型的显式转换。
可以看到, untyped
的值允许自动转换。字面上的理解,就是『无类型』(尽管底层实现需要保存需要运算,带着一个默认类型)没有类型限制,值可表达为对应类型,就可以自动转换。
那么相对地,有类型 typed
的值需要显式转换(程序员主动表达意图),就是一种设计上的有意为之,让类型系统承担了一部分的逻辑表达功能。
因为还没讲到,解释一下自定义类型: type NewInt int
定义了一个自定义类型 NewInt
,它的底层类型是 int
,会具有 int
的内置行为,并且能增加自定义行为(方法)。但是 Go 会认为它们是完全不同的两个类型,直接运算会报错,必须显式转换;当然,两个底层类型同为 int
的自定义类型之间也是一样。 int
转 int8
虽然都是整型,毕竟位宽不同范围不同,需要显式转换还可以理解为担心值溢出;那么 int
和 NewInt
之间的转换,就是纯粹出于行为和逻辑上的考虑。
举个例子,地板、砧板 两种自定义类型,底层实现都是木板。为了简化讨论,我们姑且认为是一样的木板,并没有额外的特殊加工。即使是这样,在使用中,两者还是不能搞混。如果在弱类型环境中,不去检查木板的类型,只要能用就给你用,可能出现:『这砧板怎么有个鞋印』『这地板怎么有肉沫菜叶』这样的问题。
自动转换的假设是,程序员清楚知道自己要做什么,编译器不应该干预增加工作量;显式转换的假设是,程序员有可能出错,编译器要帮忙检查类型的不匹配,这里面可能隐藏着逻辑错误。
在需要快速写个脚本、刷个算法题的时候,强类型语言像自带啰嗦严谨的老管家,一旦做了不确定的事都要确认一下,写得很不爽。但如果是维护一个大型项目,在里面人工排查类型误用引起的错误,工作量可比增加一点确认大多了,最后往往还是得引入类型检查工具——等于最后还是去雇了一个管家,那为什么不一开始就让管家参与呢?老管家并不会真的干预你做事,只是需要你额外的确认;如果特殊情况,你明确表示要用地板切菜(显式转换),他也不会拦你。
大家不要被上面的篇幅吓到,感觉光 常量 和 变量 都这么复杂。这是把 原理、边界情况 和 容易犯的错误 都给罗列出来。有些内容一般使用很难涉及,留个印象日后碰到知道往哪个方向排查;有些内容看着复杂,实际操作一遍其实很直观——IDE 都会有提示,并不需要人肉 check,这里只是让你知道为什么会报错。另外,还有部分内容涉及到类型系统的知识,需要结合下一篇类型的介绍一起理解。
下面给出一系列的 常量 和 变量声明,大家可以试着判断一下,哪些会报错、为什么;合法的声明具体是什么类型,值是多少。有自信的朋友可以试着人工检查一下,暂时做不到可以把代码补全之后实际运行一下:
|
|
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。