
1. 从哪里开始先弄懂Go语言是拿来干什么的最近接连有几个想转后端的同事问我go语言基础语法到底该怎么学、学到什么程度才算入门。这个问题本身不难难在知识点太散——今天看一篇教程讲变量明天刷一道题练循环结果花了两周连一个能跑起来的命令行工具都写不出来。如果你也有类似的困惑我建议先别急着敲代码先弄清楚Go语言解决的是什么问题。Go是一门编译型、静态类型、自带垃圾回收的编程语言2009年由Google的Robert Griesemer、Rob Pike和Ken Thompson设计。它的定位非常清晰在保留C语言执行效率和安全性的同时提供更简单的语法、更快的编译速度以及原生级别的并发支持。我们今天看到的Docker、Kubernetes以及大量云原生基础设施、微服务框架、数据库中间件底层核心都是用Go写的。掌握基础语法之后你能做的事情非常多写一个处理日志的命令行工具、搭一个HTTP API服务、写一个并发爬虫甚至去阅读Kubernetes的源码都不会再像看天书一样无从下手。这篇文章适合两类人。第一类是完全没有编程经验的小白你不需要懂任何语言就能跟下来第二类是写过Java、Python、C但想快速迁移到Go的开发者。我会从设计理念、环境搭建、变量类型、控制流、函数、错误处理、结构体、接口、并发这几个维度把基础语法的骨架搭起来并且把我在实际开发和带新人过程中踩过、见过的坑都标出来。你可以把它当成一份“带避坑指南的语法地图”配合官方文档一起看效果比单纯刷教程好得多。1.1 Go语言为什么能“简单但高效”Go最反直觉的一点是它刻意删减了很多其他语言习以为常的语法糖。没有类继承没有泛型1.18版本之前没有异常机制没有三目运算符也没有复杂的隐式类型转换。刚开始接触时你会觉得“这也不能那也不能”但写上一段时间就会发现这种限制恰好是优势代码风格统一读别人代码的成本极低。用生活化类比来解释C更像一套没有固定规矩的格斗术招式自由但需要极高的自律Python像用自然语言写作文表达很舒服但运行效率一般Go则像一本有明确规章的驾驶手册规范清晰、行为可预期。你不需要操心“这段代码在别的编译器上是不是能跑”因为go build一旦通过编译出来的就是单个可执行二进制文件扔到目标机器上直接运行没有JVM、没有运行时依赖。这种“编译一次到处运行”的爽快感是很多公司选择Go做后端服务的重要原因。1.2 学习基础语法的正确姿势我见过太多人一上来就去看那些几百行的开源项目结果被结构体、接口、channel搞到怀疑人生。基础阶段最忌讳的就是“跳着学、急着写大项目”。我自己总结的路线是先掌握“顺序、循环、分支”这三大件再搞懂“变量和类型的底层含义”接着学会“用函数组织逻辑、用错误处理表达失败”最后才是“包、结构体、接口、并发”这些组织代码的能力。每一步都用一个小工具去练比如先用grep模拟器练字符串处理再用并发下载练goroutine这样语法不仅记得住还能真正形成肌肉记忆。2. 环境搭建与第一个可运行程序2.1 开发环境准备少走弯路的三件事安装Go本身很简单从Go官网下载对应操作系统的安装包一路下一步就行。装完在终端输入go version能看到版本号就算成功。但有三件事新手容易忽略我逐个说。第一GOPATH和Go Module的区别。在Go 1.11以前所有Go项目必须放在$GOPATH/src目录下非常反人类。现在官方推荐的方式是Go Modules也就是每个项目自己管理依赖版本。你不需要额外配置任何东西只要在任意目录下执行mkdir hello-demo cd hello-demo go mod init hello-demogo mod init会生成一个go.mod文件里面记录模块名和依赖。这个文件你可以理解成项目的主清单以后go get安装的第三方包都会记录在这里。我给新人的话永远是不要再去手动设置GOPATH更不要因为某个网上老教程讲GOPATH就纠结半天直接用modules就是当前最标准的做法。第二编辑器选择。VS Code加上官方Go插件gopls基本够用能实现代码补全、跳转定义、格式化保存等功能。GoLand也很好但它是付费软件对新手来说VS Code完全够用。配置好后记得把“保存时自动格式化”打开让编辑器自动帮你跑gofmt这个问题非常重要因为Go社区对代码风格几乎是零容忍的“不整齐就难受”。第三Go语言的中文学习环境。我一般建议新手把Go官网的交互式教程先过一遍里面几十个练习很精华。搜索“Go语言基础语法”会出来大量博客水平参差不齐最好以官方文档为准。2.2 Hello World逐行拆解在hello-demo目录下创建一个main.go文件内容如下package main import fmt func main() { fmt.Println(Hello, Go!) }在终端运行go run main.go你会看到输出Hello, Go!。就这三五行代码里藏着Go最重要的三个知识点。package main声明了这个文件属于main包。Go程序由包组成每个目录可以是一个包。如果一个包里包含func main()并且main包被编译时会生成可执行文件。你可以把main理解为“程序的入口标记”只有main包能被直接go run执行。import fmt引入了标准库中的fmt包它的主要功能是格式化输入输出。注意Go的import路径是从标准库或模块根路径开始的不需要加任何前缀。如果你用了某个第三方库比如github.com/gin-gonic/gin那就要写import github.com/gin-gonic/gin。func main()是函数声明。func是声明函数的关键字随后是函数名、参数列表、返回值列表这里为空最后是函数体。入口函数main没有参数、没有返回值这是一个约定。最后是最容易被忽略的fmt.Println中的Println首字母是大写的。这不是偶然而是Go的可见性规则大写字母开头的标识符是导出的可以被其他包访问小写开头的只能在包内使用。后面讲到包管理时我会再强调这里先记下这个现象。2.3 编译与运行的区别go run适合开发时快速测试它会先编译成一个临时可执行文件再运行。而go build会直接生成一个正式二进制文件。在Linux上进行交叉编译时你可以这样设置环境变量GOOSlinux GOARCHamd64 go build main.go这个特性在部署时非常爽。你不需要在服务器上安装Go语言环境把编译好的文件传上去就能跑。基础语法阶段理解这一点就够了以后部署服务时你会发现对比Java直接把一个1GB的JAR包拖过去、还要配JDK版本Go这个优势会让运维同事非常开心。3. 变量、常量和数据类型写代码前必须先搞懂的内存逻辑3.1 变量声明var、:和零值规则Go声明变量的方式有两种。第一种是var可以在函数内、函数外使用第二种是短变量声明:只能在函数内使用。下面这个例子展示了不同风格package main import fmt var globalName Go // 包级变量 func main() { var a int 10 // 完整声明var 名称 类型 值 var b 20 // 类型推断编译器从20推断出b是int c : 30 // 短变量声明等价于 var c int 30 var d int // 只声明不初始化d的值为零值0 fmt.Println(globalName, a, b, c, d) }这里有一个非常基础但极其重要的概念零值。Go对变量有默认值规则整型和浮点型默认是0字符串默认是布尔默认是false指针、接口、切片、map等默认是nil。这和其他语言“未初始化就是未定义”不一样。很多新手在写JSON解析的时候会遇到“为什么我没有给这个字段赋值它变成0了”——这就是零值在起作用。零值的存在让很多代码天然安全比如你声明一个结构体数组里面每个元素的字段都已经有合理默认值不需要额外写构造逻辑。说到:必须强调它的限制。第一它不能在函数外使用包级变量必须用var。第二:左侧至少要有一个新变量。这个规则尤其容易在重试赋值时出错比如value : 1 value, err : someFunc() // 这里是可以的因为err是新变量但如果写成value : 2编译器会报错“no new variables on left side of :。理解了这一点你就能明白一个网上的典型报错No new variables on left side of :。3.2 基本数据类型不要把字符串当字符数组Go的基本数据类型包括布尔型bool整数类型int、int8、int16、int32、int64以及对应的无符号uint浮点型float32、float64复数类型complex64、complex128字符串string还有两个非常值得单独讲解的类型byte和rune。先看整数。在绝大多数64位平台上int是64位长度但如果你写一个跨平台程序千万不要假设int固定是64位。为了明确长度网络协议、二进制文件解析这些场景应该使用int32、int64这样的显式类型。另外Go不会做隐式类型转换两个不同类型不能直接加减比如int32和int64相加会编译失败必须强制转换var x int32 10 var y int64 20 var z int64 int64(x) y很多从Python转过来的人会在这上面卡壳因为Python的int长度无限且自动转换而Go非常顽固地要求你明确表达。再讲字符串。string底层是一段只读的字节切片它不能被修改。len(s)返回的是字节数量而不是字符数量。这可能是新手最常踩的坑之一输出一个中文字符串的长度结果是字节数而不是字数。比如len(你好)的结果是6因为一个中文字符在UTF-8编码下占3个字节。要想按字符遍历得用ranges : 你好Go for i, r : range s { fmt.Printf(index%d, rune%U, char%c\n, i, r, r) }这里的r类型是rune代表一个Unicode码点本质上是int32的别名。而byte是uint8的别名二者经常让初学者困惑。简单理解处理ASCII文本用byte没问题需要处理中文、Emoji这些Unicode字符时用rune遍历才不会乱码。3.3 数组、切片和map三种最常用的容器三者的关系可以用一句话概括数组是定长的切片是定长数组的“动态视图”map是哈希表。数组声明var arr [5]int [5]int{1, 2, 3, 4, 5}数组的元素个数属于类型的一部分[5]int和[10]int是不同的类型所以在函数传参中很少直接用数组因为长度死板拷贝浪费。实际开发90%的场景用切片slicenums : []int{1, 2, 3} nums append(nums, 4)切片内部结构由三个部分组成指向底层数组的指针、切片长度len、切片容量cap。append时如果容量不够会分配一个新的底层数组并把旧数据复制过去。理解了这一点就能解释很多诡异行为。举个例子s : []int{1, 2, 3} modify : s modify[0] 100 fmt.Println(s[0]) // 100因为modify和s共享底层数组初学者如果不理解切片底层结构很容易以为modify : s是一次赋值拷贝然后修改modify不会影响s结果发现数据全变了。要复制一份独立的切片得用copy(s, src)或者append([]int(nil), s...)。基础语法阶段掌握这些就够了更多细节后面在源码阅读时再深入。map的声明和使用ages : make(map[string]int) ages[alice] 25 age, ok : ages[bob] if !ok { fmt.Println(bob不存在) }make是用于切片、map、channel三种内建类型的初始化函数。访问map中不存在的键时会返回该值类型的零值所以一定要用ok接收第二个返回值来判断键是否存在否则遇到年龄为0的人时你分不清是“这个人没记录”还是“这个人0岁”。map是引用类型内部已经包含指针所以不需要再用获取地址来传递。4. 控制流if、for、switch里那些反直觉的细节4.1 if语句条件不用加括号却多了初始化子句Go的if条件和大多数语言最大的区别是条件表达式外层不能加圆括号加了自己难受而且Go会自动在行的末尾插入分号因此else必须和}放在同一行。典型写法if score 60 { fmt.Println(及格) } else if score 40 { fmt.Println(补考) } else { fmt.Println(重修) }另一个不多见但很有用的特性是if可以带一个初始化语句if err : loadConfig(); err ! nil { fmt.Println(配置加载失败:, err) }这里的err属于整个if块包括else分支可见一旦判断结束err在这个作用域之外就不存在了。这能有效避免变量泄漏到函数中让临时错误处理非常干净。4.2 for循环Go里唯一的循环关键字Go没有while关键字没有do...while循环统一用for表达。它有三种形态。第一种标准三段式for i : 0; i 10; i { fmt.Println(i) }第二种当作while用n : 0 for n 10 { n }第三种无限循环for { // 配合break退出 }还有最关键的是for ... range它可以直接遍历数组、切片、map、字符串、channel。遍历切片时有两个返回值索引和元素副本names : []string{a, b, c} for i, name : range names { fmt.Println(i, name) }注意这里的name变量在每次迭代中会被重新赋值吗在Go 1.22版本之前range循环中的循环变量是复用的也就是说每次迭代里name是同一个变量只是值被覆盖。这会导致一个经典面试题在循环里启动goroutine输出的往往是同一个值。Go 1.22之后每次迭代都会创建新的变量这个坑被官方修复了。但我建议你在看旧代码时仍然要留意这一点尤其是项目里用了Go 1.21或更早版本。4.3 switch语句默认不穿透fallthrough才穿透传统C系语言的switch如果不写break会继续执行下一个case而Go恰恰相反匹配到case后自动退出不需要写break。如果你想故意穿透要用fallthroughscore : 85 switch { case score 90: fmt.Println(A) case score 80: fmt.Println(B) fallthrough // 继续执行下一个case case score 70: fmt.Println(C) }不过fallthrough会导致不符合常规直觉的行为我在工程代码里见过的次数屈指可数不推荐新手使用。Go的switch还有一个特性是case后的条件不要求是常量可以是任意表达式。另外case背后还可以接一赋值语句类似switch v : anyValue.(type)这种类型分支这通常用于处理接口的动态类型后面讲接口时再展开。5. 函数、错误处理与包管理组织代码的基本单元5.1 函数多返回值才是最实用的语法糖Go函数的基本声明格式是func add(a, b int) int { return a b }如果多个参数类型相同可以合并写成a, b int。多返回值是Go极具特色的设计func div(a, b int) (int, error) { if b 0 { return 0, fmt.Errorf(除数不能为0) } return a / b, nil }调用时可以用_忽略某个返回值quotient, _ : div(10, 2)也可以用命名返回值。命名返回值相当于提前声明了返回变量函数体内的return可以省略操作数func getMinMax(nums []int) (min int, max int) { min, max nums[0], nums[0] for _, v : range nums { if v min { min v } if v max { max v } } return // 自动返回min和max }命名返回值在逻辑复杂的函数中能让结构更清晰但不建议滥用因为如果不小心省略了return中的操作数很容易误以为函数没有返回值。Go函数还是一等公民可以赋值给变量、作为参数传递、作为返回值返回process : func(s string) string { return [ s ] } fmt.Println(process(hello))这种函数式能力为后续学习闭包、函数式选项模式functional options打下基础。defer也是非常日常的语法。defer把函数调用推迟到当前函数返回前执行而且是后进先出。最经典的使用场景是释放资源f, err : os.Open(data.txt) if err ! nil { return err } defer f.Close()无论函数后续逻辑是否出错defer f.Close()都会在函数结束时执行。很多人容易忽略的是defer的执行时机在返回值赋值之后、函数真正返回之前因此它无法修改已经确定的名字返回值除非返回变量本身是命名返回值。这个细节在排查资源释放顺序时经常用到。5.2 错误处理Go没有try-catch这不是缺陷Go处理错误的方式极其直白函数如果可能出错就返回一个error类型的值。调用方每次都要检查它。很多从Java转来的同学最初非常反感这种写法觉得太啰嗦。但写几年后你会认同它错误不再是异常情况下才抛出的“重磅炸弹”而是普通返回值必须被处理。创建错误最简单的两个方式err : errors.New(数据库连接失败) err2 : fmt.Errorf(用户ID %d 不存在, id)对于调用链中需要给错误增加上下文信息的情况推荐使用%w包装错误if err : queryUser(); err ! nil { return fmt.Errorf(查询用户失败: %w, err) }这样调用方可以用errors.Is(err, targetErr)判断是否匹配某个根错误也可以用errors.As把错误转为某个具体类型。包装错误的好处是既保留了原始错误的判断能力又增加了上下文信息。给新手一个忠告永远不要写f, _ : os.Open(...)然后把error丢掉。刚开始你觉得无所谓一旦线上出问题缺少错误信息会让你排查非常痛苦。我见过太多生产事故定位缓慢就是因为最初图省事吞掉了error。5.3 包与可见性大小写就是设计约定Go的包机制很简单每个目录下的.go文件都属于同一个包包名通常与目录名一致。main包是可执行程序入口。要导入其他包使用importimport ( fmt strings demo/util )Go编译器强制要求所有导入的包必须被使用否则编译失败所有声明的变量也必须被使用否则编译失败。这是许多新手刚开始最不习惯的一点但这个设计的初衷是保证代码整洁你永远不会在大型Go项目里看到成堆的“死代码”。标识符的可见性规则前面提过首字母大写是导出的可以被外部包访问首字母小写是私有的。所以结构体、函数、字段的命名风格直接决定了API的对外边界。比如你定义一个结构体type User struct { Name string // 外部可以访问 email string // 外部不能访问只能在包内使用 }老练的Go工程师在设计包时会非常谨慎地决定哪些标识符暴露出去因为一旦暴露后续修改就会影响所有调用方。基础语法阶段你可能感受不到这个重要性但随着你维护的包变大就会理解Go这种“简单规则下的强约束”多么有用。6. 结构体与接口Go的面向对象并不像你想的那样6.1 结构体用组合代替继承结构体定义type User struct { Name string Age int }初始化方式有多种u1 : User{Alice, 30} u2 : User{Name: Bob, Age: 25} u3 : User{Name: Charlie} var u4 User u4.Name David推荐带有字段名的初始化方式因为当结构体有十几个字段时位置初始化极易出错。方法定义在结构体外部通过接收者receiver声明func (u User) Introduce() string { return fmt.Sprintf(我是%s今年%d岁, u.Name, u.Age) }值接收者和指针接收者的区别是这一章最核心的内容。值接收者调用时会复制整个结构体方法内对字段的修改不会影响原对象指针接收者则直接操作原对象func (u *User) Birthday() { u.Age }调用时Go会自动转换如果定义的是*User接收者用var u User调用u.Birthday()也没问题因为Go会取地址反过来如果定义的是值接收者用指针调用也会自动解引用。理解这个区别的意义在于性能大结构体比如包含很多字段的对象用值接收者会导致每次方法调用都复制一遍应该用指针接收者。此外如果结构体里含sync.Mutex之类的锁字段一定不要复制这个结构体否则锁会失效这时必须用指针接收者。Go没有继承但支持结构体嵌入embedding:type Admin struct { User Permissions []string }这样Admin自动“提升”了User的所有字段和方法调用方式类似于继承但本质是组合。组合比继承更灵活修改User不会破坏Admin的行为契约这也是Go社区推崇的设计风格。6.2 接口隐式实现让代码更灵活Go的接口非常独特某个类型只要实现了接口里所有的方法它就自动满足这个接口不需要显式声明“implements”。比如定义type Greeter interface { Greet() string }任何拥有Greet() string方法的类型都已经是Greeter。这是典型的鸭子类型只管它会不会叫不管它是什么。好处是你可以给外部包里的类型添加方法只能通过定义在你自己包里的类型再嵌入然后在自己的接口里使用它松耦合程度极高。空接口interface{}等价于any可以表示任意类型类似其他语言的Object。在泛型普及之前空接口经常用来写通用容器但现在有了泛型空接口的使用场景已经缩小到处理未知的JSON结构这类情况了。从接口取回具体类型时用类型断言var v any hello s, ok : v.(string) if ok { fmt.Println(s) }类型断言不判ok时如果类型不符会直接panic所以我会要求新人在所有断言场景都使用v, ok : ...的写法。这个习惯能帮你减少很多线上崩溃。7. 并发基础go关键字和channel是Go最亮眼的名片7.1 goroutine一个go关键字启动一个任务并发是Go和Java、Python相比最有吸引力的一点。创建一个goroutine只需在函数调用前加gofunc printNum(i int) { fmt.Println(i) } func main() { go printNum(1) go printNum(2) time.Sleep(time.Second) }goroutine不是操作系统线程而是由Go运行时调度器管理的轻量级协程初始栈很小几KB可以创建几百万个而不至于耗尽内存。它让初学者不需要理解线程池、亲和性、上下文切换这些复杂概念就能写出并发程序。但是一定要记住主函数不会等待goroutine完成。上面代码里的time.Sleep只是为了让你能看到输出真实开发中应该用sync.WaitGroup或channel做同步。基础语法阶段了解sync.WaitGroup即可var wg sync.WaitGroup for i : 0; i 5; i { wg.Add(1) go func(n int) { defer wg.Done() fmt.Println(n) }(i) } wg.Wait()在循环内启动goroutine时务必要把i作为参数传递进去不要直接引用外部循环变量。虽然在Go 1.22之后这个问题已经被官方修复但为了兼容旧版本项目保持这个好习惯永远没错。7.2 channel协程之间的通信管道channel是Go在语言层面提供的并发通信原语声明和初始化方式ch : make(chan int)默认创建的channel是无缓冲的。发送操作会阻塞直到接收方准备好接收操作同理。无缓冲channel保证发送和接收同时发生这种同步机制天然实现了“两个goroutine汇合”的效果。如果想实现异步缓冲可以指定容量ch : make(chan int, 5)向有缓冲channel发送时只有缓冲满了才阻塞接收时只有缓冲区空了才阻塞。这个缓冲大小是一个需要仔细考量的参数设置太大会浪费内存太小会导致生产者频繁阻塞。实际项目中我一般先用无缓冲channel保证正确性再根据压测数据调整缓冲大小。向channel发送值和接收值ch - 10 v : -ch发送和接收是两种不同的语法操作非常直观。当不再需要向channel发数据时可以关闭它close(ch)关闭channel之后接收方依然可以继续读取已缓冲的数据缓冲读完后会不断读到该channel元素类型的零值。所以通常会用for v : range ch来循环接收当channel被关闭且数据读完后循环自动退出。不要试图在接收方关闭channel也不要重复关闭否则panic。多路同时监听多个channel时使用selectselect { case msg : -msgChan: fmt.Println(msg) case timeout : -time.After(time.Second): fmt.Println(超时, timeout) }select类似switch但每个case必须是发送或接收操作且每个channel可以有各自的处理逻辑。如果没有case准备好select会阻塞除非有default分支立即执行。channel的正确使用习惯是“谁创建谁关闭谁写入谁负责”。这个概念可以用一个生活类比理解channel就像两个人之间的一条管道有人往里面扔球有人在另一端接球。等球扔完了、不再需要这条管道了扔球的人说一声“结束”close接球的人知道不会再来了。别让对方接球的人来做这个关闭动作否则你永远不知道什么时候该关。这套规则可以避免大量由channel引发的panic。8. 新手最容易踩的坑常见问题与排查技巧实录8.1 经典编译期错误速查下面这些报错几乎每个初学者都会遇到。我把它们整理成一张速查表你看到对应信息时可以直接对照排查。报错信息原因解决办法undefined: xxx函数或变量未定义常见是包未导入或首字母小写导致不可见先确认import是否包含对应包再检查标识符首字母是否大写expected ;, found }换行导致自动插入分号最常见于else另起一行把} else {写成同一行unused variable: xGo禁止声明未使用的变量删除变量或把它赋给_imported and not used: fmt导入了没使用的包删除import或真正用一下它no new variables on left side of ::左边全是已有变量至少保证左侧有一个新变量或改用赋值invalid operation: (int32 int64)不同类型无法直接运算先显式转换类型我见过最频繁的就是else换行的问题。它背后是Go语言的分号自动插入规则编译器会在行末自动补分号}后如果跟着换行前面就会被补上分号于是else变成新语句和if脱钩了。这种问题调试起来其实非常简单只需要看报错的行号和附近的缩进就能定位但因为报错信息不直观很多人会卡很久。8.2 排查代码逻辑问题的办法编译通过不代表程序正确。遇到逻辑问题时我推荐按以下顺序排查。第一步是加日志。虽然fmt.Println看起来很初级但它是最直接的手段可以在关键位置打印变量的值、函数调用的顺序。第二步是跑go vet它会做静态检查比如打印格式字符串写错、锁复制这类常见隐患。第三步是跑数据竞争检测这才是Go真正强大的地方go run -race main.go在并发场景下-race会检测数据竞争一旦发现会输出详细的goroutine栈和冲突的内存地址。这是定位“偶发性内存异常”的利器我可以负责任地说几乎所有的并发bug都可以先用它扫一遍。8.3 三个在真实工程里埋过雷的细节第一切片的append可能导致意外的数据修改。当切片容量不足时append会创建新数组你修改新切片不会影响原切片但容量充足时它会原地写底层数组修改会反映到原切片。这种“有时改到、有时改不到”的行为最可怕。建议是当你要把切片传给函数并允许函数修改它时要么在函数内创建明确的新切片要么在文档里写清楚“共享底层数组请勿修改”。第二map是引用类型永远不要直接赋值给另一个map变量然后修改期望原map不变——它们指向同一份数据这是Go语言的明确行为。要复制map必须手动遍历创建新的map。第三在Go 1.21及以前循环变量复用引起的goroutine数据错乱非常隐蔽。你可能以为goroutine会读到每次迭代的i实际读到的是最后一次迭代的值。我在写批量请求的时候就亲眼见过同事因为没传参导致所有请求都用了同一个订单ID查了很久才发现是这个问题。9. 收个尾我建议你用这样的节奏去练最后分享一点我自己的经验。学Go基础语法不需要追求把每个细节都背下来而是应该边学边用。我当年给自己定的小目标是用Go重写平时常用的十个命令行小工具第一个是统计某个文件里关键字出现次数的计数器第二个是批量给图片重命名的脚本。这个过程坚持下来变量、类型、循环、函数、文件操作全都练熟了而且非常有成就感。你如果现在刚起步可以按这个节奏执行第一周只学变量、类型、控制流、函数每天写一个小程序第二周学结构体、接口、错误处理尝试写一个命令行版的待办清单第三周学并发用goroutine和channel写一个并发下载器。语法这东西看十遍不如自己写一遍。写顺了之后你会发现后面的http服务、数据库操作、web框架这些内容学起来会非常快。