Go 变量、类型和流程控制:把基础语法写得清清楚楚

本文用真实业务片段讲解 Go 的变量声明、零值、常量、基本类型、if、for、switch 和作用域,帮助初学者把基础语法写扎实。

基础语法不是背规则,而是建立手感

很多人学 Go 的第一反应是:语法不难,但总觉得写起来有些规矩。变量声明有好几种方式,类型写在变量名后面,if 没有括号,循环只有 for,switch 默认不贯穿。单独看每条规则都不复杂,真正需要练的是判断:什么时候该显式写类型,什么时候可以让编译器推断,什么时候应该用 switch 让代码更清楚。

Go 的基础语法很强调可读性。它不追求用最短的字符表达最多的东西,而是希望一段代码交给同事时,对方不用猜你的意图。变量名、类型、作用域和控制流都应该服务于这个目标。

这篇文章会从变量和类型讲起,再讲 if、for、switch。示例尽量贴近真实后端代码,比如订单金额、用户状态、配置开关和分页参数。你不需要一次记住所有细节,但要开始形成 Go 的写法习惯。

变量声明的几种方式

Go 最完整的变量声明是:

var name string = "小林"

这行代码同时写出了变量名、类型和值。它当然正确,但在函数内部并不总是必要。因为右侧已经是字符串,编译器能推断类型,所以可以写成:

var name = "小林"

在函数内部,最常见的是短变量声明:

name := "小林"
age := 28
active := true

:= 只能用于函数内部,不能用于包级变量。它适合局部变量,因为局部上下文短,类型通常一眼能看出来。

包级变量一般更谨慎:

var serviceName = "user-api"
var maxRetryCount int = 3

包级状态会影响整个包,使用时要克制。初学阶段不要把所有东西都放到全局变量里。能作为参数传递的,就尽量作为参数传递。

零值:Go 很重视默认状态

Go 变量声明后即使没有赋值,也会有零值:

var name string
var count int
var enabled bool

fmt.Println(name)    // ""
fmt.Println(count)   // 0
fmt.Println(enabled) // false

零值让 Go 代码少了很多未初始化错误。你可以放心声明一个变量,再根据条件赋值。

func discountRate(level string) float64 {
	var rate float64

	if level == "gold" {
		rate = 0.20
	} else if level == "silver" {
		rate = 0.10
	}

	return rate
}

如果不是金牌或银牌用户,rate 保持 0。这段代码是安全的。但安全不代表一定清楚。很多时候直接返回会更明确:

func discountRate(level string) float64 {
	if level == "gold" {
		return 0.20
	}
	if level == "silver" {
		return 0.10
	}
	return 0
}

Go 的零值很有用,但不要用它隐藏业务意图。如果 0 表示“没有折扣”,可以接受。如果 0 表示“未知状态”,最好显式建模。

基本类型要选得自然

Go 常用基本类型包括:

string
bool
int
int64
float64
byte
rune

业务代码里,整数默认使用 int 就可以。需要和数据库、自增 ID、时间戳或外部协议对齐时,才更常见地使用 int64。金额不要随便用 float64 表示真实货币,因为浮点数有精度问题。入门示例可以用 float64 表示折扣率、评分、比例,但订单金额更建议用“分”为单位的整数:

type Order struct {
	ID          int64
	TotalCents int64
	Paid        bool
}

字符串是 string,但要注意中文字符不是一个字节。下面代码输出的长度不是 2:

name := "小林"
fmt.Println(len(name))

len 返回字节数。中文字符在 UTF-8 中通常占多个字节。如果你要按字符遍历,应使用 range:

for _, r := range "小林" {
	fmt.Printf("%c\n", r)
}

这里的 r 类型是 rune,本质上是 Unicode 码点。初学者不必深入编码细节,但要记住:处理中文时,不要把字节长度当成字符数量。

常量适合表达稳定规则

常量用 const:

const defaultPageSize = 20
const maxPageSize = 100

它适合表达不会在运行时改变的规则。比如分页默认数量、订单状态值、配置键名。

const (
	StatusPending = "pending"
	StatusPaid    = "paid"
	StatusClosed  = "closed"
)

Go 里常量没有必须大写的要求。首字母大写意味着导出,能被其他包访问;首字母小写表示包内使用。如果这些状态只在当前包里使用,写成小写也很好:

const (
	statusPending = "pending"
	statusPaid    = "paid"
	statusClosed  = "closed"
)

不要为了“像常量”就全部大写。Go 社区很少写 MAX_PAGE_SIZE 这种风格,除非你在对接已有协议或环境变量名。

if:让判断贴近业务语言

Go 的 if 没有括号:

if age >= 18 {
	fmt.Println("adult")
}

可以带初始化语句:

if discount := discountRate("gold"); discount > 0 {
	fmt.Printf("discount: %.0f%%\n", discount*100)
}

这里 discount 只在 if 和对应 else 范围内可见。这个写法适合临时变量,能减少作用域污染。

真实代码里,更常见的是提前返回:

func canCheckout(stock int, userActive bool) bool {
	if stock <= 0 {
		return false
	}
	if !userActive {
		return false
	}
	return true
}

这种写法比多层嵌套更容易读。Go 代码经常这样处理错误和边界条件:先把不能继续的情况排除掉,最后留下正常路径。

不太建议写成:

func canCheckout(stock int, userActive bool) bool {
	if stock > 0 {
		if userActive {
			return true
		}
	}
	return false
}

它能运行,但读者要在脑子里穿过两层条件。业务条件越多,嵌套越难维护。

for:Go 只有一种循环

Go 没有 while,循环统一使用 for。

普通计数循环:

for i := 0; i < 5; i++ {
	fmt.Println(i)
}

类似 while 的写法:

count := 0
for count < 5 {
	fmt.Println(count)
	count++
}

无限循环:

for {
	fmt.Println("working")
	break
}

遍历切片使用 range:

names := []string{"小林", "阿周", "老陈"}

for index, name := range names {
	fmt.Printf("%d: %s\n", index, name)
}

如果不需要索引,用 _ 忽略:

for _, name := range names {
	fmt.Println(name)
}

遍历 map:

scores := map[string]int{
	"小林": 95,
	"阿周": 88,
}

for name, score := range scores {
	fmt.Printf("%s: %d\n", name, score)
}

要注意 map 遍历顺序是不固定的。你不能依赖它每次输出一样顺序。如果需要排序,先取出 key,排序后再访问。

switch:比一串 else if 更像规则表

当判断同一个变量的多个取值时,switch 很清楚:

func statusText(status string) string {
	switch status {
	case "pending":
		return "待支付"
	case "paid":
		return "已支付"
	case "closed":
		return "已关闭"
	default:
		return "未知状态"
	}
}

Go 的 switch 默认不会贯穿到下一个 case,所以不需要写 break。这减少了很多传统 C 风格 switch 的错误。

多个值可以放在同一个 case:

func isFinalStatus(status string) bool {
	switch status {
	case "paid", "closed", "refunded":
		return true
	default:
		return false
	}
}

也可以不带表达式,把它当成更清楚的 if else:

func shippingFee(totalCents int64) int64 {
	switch {
	case totalCents >= 19900:
		return 0
	case totalCents >= 9900:
		return 500
	default:
		return 1200
	}
}

这段代码比嵌套判断更像一张规则表。业务规则一多,switch 往往比连续 else if 更容易维护。

作用域:变量活得越短越好

Go 的作用域规则直接影响代码质量。变量应该尽量靠近使用它的地方声明。

func printOrder(orderID int64) {
	orderNo := fmt.Sprintf("NO-%06d", orderID)
	fmt.Println(orderNo)
}

不要把局部变量提前声明到很远的地方:

func printOrder(orderID int64) {
	var orderNo string

	// 中间隔了很多代码
	orderNo = fmt.Sprintf("NO-%06d", orderID)
	fmt.Println(orderNo)
}

变量作用域越大,读者需要记住的状态越多。Go 的短变量声明、if 初始化语句、for 初始化语句,都是为了让变量留在尽量小的范围内。

也要避免无意中遮蔽变量:

count := 10

if true {
	count := 20
	fmt.Println(count) // 20
}

fmt.Println(count) // 10

里面的 count := 20 创建了新变量,不是修改外面的 count。这种情况有时是故意的,有时是 bug。初学阶段遇到值没变,先检查是不是用了 :=。

写一个分页参数清洗函数

把前面的知识组合起来,写一个常见函数:清洗分页参数。

package main

import "fmt"

const (
	defaultPage     = 1
	defaultPageSize = 20
	maxPageSize     = 100
)

func normalizePagination(page int, pageSize int) (int, int) {
	if page <= 0 {
		page = defaultPage
	}

	switch {
	case pageSize <= 0:
		pageSize = defaultPageSize
	case pageSize > maxPageSize:
		pageSize = maxPageSize
	}

	return page, pageSize
}

func main() {
	page, pageSize := normalizePagination(-1, 500)
	fmt.Printf("page=%d pageSize=%d\n", page, pageSize)
}

这段代码包含常量、变量、if、switch 和多返回值。它不复杂,却很像真实后端服务里的参数处理。

为什么不直接在调用处到处写判断?因为分页规则是一件独立事情。把它封装成函数,调用者只需要相信结果已经合法:

page, pageSize := normalizePagination(req.Page, req.PageSize)

学习基础语法时,最好总是问一句:这段语法在真实代码里解决什么问题?当你能把语法和场景连起来,记忆会牢得多。

小结

Go 的变量、类型和流程控制都不难,但它们共同塑造了一种风格:显式、短作用域、少嵌套、规则清楚。变量声明不要炫技,能让读者看懂最重要;类型选择要贴近业务,尤其是金额、ID、状态这类字段;if 适合处理边界,switch 适合表达规则,for 统一处理所有循环。

写 Go 基础代码时,可以先记住几个简单判断:局部变量优先用 :=,包级变量少用;能提前返回就少嵌套;中文字符串不要用字节长度当字符数量;map 遍历不要依赖顺序;变量作用域越小越好。

这些习惯看起来细,但会一直伴随你。后面写函数、结构体、接口和并发代码时,基础语法的清晰度会直接决定程序是否容易维护。

常见问题与解答

var 和 := 怎么选?

包级变量只能用 var。函数内优先用 :=,但如果需要显式指定类型或零值初始化,用 var。

整数溢出怎么办?

Go 的整数溢出不会 panic,而是静默回绕。需要检查范围时用 math 包或手动判断。math/big 支持任意精度整数。

switch 为什么不需要 break?

Go 的 switch 默认在每个 case 后 break,不会 fallthrough。如果需要贯穿,显式写 fallthrough。

类型转换

var i int = 42
var f float64 = float64(i)
var s string = strconv.Itoa(i)

Go 要求显式类型转换,编译器不会自动转换不同类型。

类型别名

type UserID int64
type OrderID int64

func GetUser(id UserID) { }
func GetOrder(id OrderID) { }

类型别名让代码自文档化,减少把用户 ID 当成订单 ID 的错误。

defer 基础

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // 函数返回时执行

    // ... use f
    return nil
}

defer 语句在函数返回前执行,常用于资源释放。多个 defer 按 LIFO 顺序执行。

panic 和 recover

func safeCall() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("recovered: %v", r)
        }
    }()
    mightPanic()
}

recover 只能在 defer 函数中使用。一般只在框架层或顶层 handler 中 recover,业务代码不要滥用。

实践练习

完成以下练习以巩固所学知识:

  1. 阅读 Go 官方文档相关章节
  2. 编写一个完整的示例程序
  3. 为示例程序编写单元测试
  4. 使用 go test 和 go benchmark 验证实现
  5. 尝试优化内存分配和运行时间

推荐阅读

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./... 和 golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroup 和 context 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练