Easton Notes
知识八股GoJS 转 Go

JS 转 Go:类型、接口与包

本页关键词:零值、切片、map、struct、隐式接口、any、类型断言、指针接收者、大小写可见性、go.mod、internal


一、静态类型在干什么

JS 的值可以换类型;Go 不行。好处是很多错误编译期就爆,代价是要学会切片、接口、指针这套词汇。

常用类型对照:

JSGo零值
numberint / int64 / float640 / 0.0
stringstring(不可变,按字节 len""
booleanboolfalse
null / undefinednil(只给引用类型)
Array[n]T 数组(定长)或 []T 切片数组全零;切片 nil
Object / Mapstruct / map[K]V结构体字段零值;map nil
任意值any(即 interface{}nil
  • 切片就是动态数组罢了

日常几乎只用 intint64float64。需要固定宽度再写 int32/uint8(例如协议、二进制)。

字符串是只读字节序列。中文按 rune 遍历:for _, r := range slen(s) 是字节数,不是字符数。


二、复合类型

数组 vs 切片

先读语法:

  • [3]int:长度为 3 的 int 数组。
  • [4]int:长度为 4 的 int 数组。
  • []intint 切片,长度不写死,可以增长。

Go 里的数组长度是类型的一部分,所以 [3]int[4]int 不是同一种类型。它们不能直接互相赋值:

var a [3]int
var b [4]int

a = b // 编译失败:类型不同

这点和 JS 数组很不一样。JS 的数组长度是运行时属性,[1, 2, 3][1, 2, 3, 4] 都是 Array。Go 的数组更像「固定长度的一块值」,长度写进类型里。

日常业务代码几乎总是用切片 []T,因为它更接近 JS 数组:可以 append,长度可以变。

s := []int{1, 2, 3}
s = append(s, 4)     // 必须接返回值
sub := s[1:3]        // 和底层数组共享,改 sub 可能改 s
a := [3]int{1, 2, 3}
s2 := a[:]           // 数组转切片

这里要分清:

  • s := []int{1, 2, 3} 创建的是切片。
  • a := [3]int{1, 2, 3} 创建的是数组。
  • s2 := a[:] 是从数组 a 上切出一个切片。

切片本身可以理解成一个很小的描述头,里面有三个信息:指针、len、cap

  • 指针:指向底层数组的某个位置。
  • len:当前能看到几个元素。
  • cap:从当前位置开始,底层数组最多还能容纳几个元素。

append 容量不够会新分配数组,所以必须接住返回值:

s = append(s, 4)

两个切片如果共享同一个底层数组,改其中一个可能影响另一个:

s := []int{1, 2, 3}
sub := s[1:3]
sub[0] = 99

fmt.Println(s) // [1 99 3]

初学阶段先记住两句就够:固定长度用数组 [n]T,可变列表用切片 []T;业务代码默认选切片。

make([]int, 0, 16) 预分配 cap,减少扩容。不要用 == 比较切片,只能和 nil 比,或用 slices.Equal

map

m := make(map[string]int)
m["a"] = 1
v, ok := m["a"] // 逗号 ok,区分「没这个键」和「值就是零值」
delete(m, "a")

m := make(map[string]int) 读作:创建一个 map,键是 string,值是 int。它接近 JS 里的对象或 Map,但键和值的类型必须提前写清楚。

v, ok := m["a"] 不是 JS 的解构赋值。它是 Go 的多返回值接收。读取 map 时可以拿到两个结果:

  • v:这个 key 对应的值。
  • ok:这个 key 是否真的存在。

为什么需要 ok?因为 map 读不到 key 时,会返回值类型的零值:

m := map[string]int{"a": 0}

v1, ok1 := m["a"] // v1 是 0,ok1 是 true:key 存在,值就是 0
v2, ok2 := m["b"] // v2 也是 0,ok2 是 false:key 不存在

如果只写 v := m["b"],你只能拿到 0,分不清「真的存了 0」还是「没有这个 key」。所以 Go 里查 map 时常见写法是:

if v, ok := m["a"]; ok {
    fmt.Println(v)
}

delete(m, "a") 是删除 key。key 不存在也不会报错。

make 的 map 是 nil map:

var m map[string]int

fmt.Println(m["a"]) // 0,可以读,读不到就返回零值
m["a"] = 1          // panic:assignment to entry in nil map

所以要写入 map,先用 make 或字面量初始化:

m1 := make(map[string]int)
m2 := map[string]int{"a": 1}

另外两个常见规则:

  • 遍历 map 的顺序每次都可能变,不要依赖它。
  • 键必须可比较,不能用切片当键;字符串、数字、布尔值可以当键。

结构体

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

u := User{Name: "Ada", Age: 30}

这段是在定义一个结构体类型。可以逐行读:

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
  • type User struct:定义一个新类型,名字叫 User,它是一个结构体。
  • Name string:字段名是 Name,类型是 string
  • Age int:字段名是 Age,类型是 int
  • `json:"name"`:结构体标签,告诉 JSON 编码/解码时这个字段对应 JSON 里的 name
  • `json:"age"`:同理,对应 JSON 里的 age

为什么 Go 字段是 Name,JSON 却是 name?因为 Go 用首字母大小写控制可见性:Name 大写,包外可见,JSON 包才能读到;但接口返回给前端时通常希望字段是小写的 name,所以用 tag 做映射。

创建结构体值时推荐写字段名:

u := User{Name: "Ada", Age: 30}

这比按位置写更清楚,也不怕以后字段顺序调整。Go 也支持按位置初始化:

u := User{"Ada", 30}

但项目里少用,因为字段一多就很难读。

Go 没有 class / 继承,复用靠嵌入

type Person struct{ Name string }
func (p Person) Greet() string { return "hi " + p.Name }

type Employee struct {
    Person
    Salary int
}
// emp.Greet()、emp.Name 可直接用,等于组合而不是 IS-A

面试要点:JSON 只导出大写字段name string 序列化会丢;要小写键名用 tag:`json:"name"`


三、接口

Go 接口是方法集合,满足是隐式的:类型不用声明 implements,有方法就算。

type Measurable interface {
    Area() float64
}

type Circle struct{ Radius float64 }

func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }

func printArea(m Measurable) { fmt.Println(m.Area()) }

printArea(Circle{Radius: 5}) // Circle 没写 implements

接口可嵌套:type ReadWriter interface { Reader; Writer }。标准库 io.Reader / http.Handler 都是小接口。

anyinterface{})能装任何值,等于放弃静态信息,取出要用断言:

var v any = "hello"
s := v.(string)        // 失败则 panic
s, ok := v.(string)    // 推荐
switch x := v.(type) { // 类型 switch
case string:
    _ = x
case int:
    _ = x
}

原笔记问过:map[string]interface{}interface 是干什么的?就是「值的类型到运行时再定」,接近 JS 对象。能用具体类型就不要用 any

面试要点:接口值内部是 (动态类型, 动态值)。把 nil 指针赋给接口后,接口不是 nil(类型还在)。这是 if err != nil 的经典坑,见第 6 篇。


四、指针

JS 对象默认引用传递;Go 默认拷贝值。要改调用方的结构体,用指针。

n := 42
p := &n  // *int,指向 n
*p = 100 // n 变成 100

func scale(r *Rect, k int) { r.W *= k }

func (r *Rect) Scale(k int) { r.W *= k } // 指针接收者,可改接收者
func (r Rect) Area() int    { return r.W * r.H } // 值接收者,改的是副本

规则:

  • 要改接收者,或结构体较大,用指针接收者。
  • 同一类型的方法接收者尽量统一,不要有的 T 有的 *T
  • 零值指针是 nil,解引用 *p 会 panic,先判空。
  • Go 有 GC,没有 free,也没有指针运算。

方法集:*T 的方法,值 T 也能调(编译器取地址);反过来,若只有值接收者,接口变量里若是指针也能调。面试常问「为什么接口装值/指针有时编不过」:看方法集是否包含接口要求的方法。


五、包与可见性

同一目录所有 .go 必须同一个 package 名。导入的是,不是文件。

package utils

var Public = 1  // 包外可见
var hidden = 2  // 仅包内

首字母大写 = 导出(类似 export),小写 = 包私有。结构体字段同样。没有 export default

特殊目录:

目录含义
internal/只有 internal 的父树能 import,编译器强制
cmd/xxx/可执行入口,一个子目录一个二进制
pkg/社区约定的对外库,不是关键字

导入:

import (
    "fmt"
    "github.com/gin-gonic/gin"
    myfmt "fmt"                 // 别名
    _ "github.com/lib/pq"       // 只跑 init,注册驱动
)

点导入 . "fmt" 会污染命名空间,生产代码别用。循环 import 直接编译失败,用接口解耦或抽第三包。


六、模块:go.mod 对照 npm

npmGo
清单package.jsongo.mod
package-lock.jsongo.sum(哈希)
包名expressgithub.com/gin-gonic/gin
安装位置node_modules/模块缓存 $GOPATH/pkg/mod
加依赖npm i expressgo get github.com/gin-gonic/gin
清理手工go mod tidy
go mod init github.com/you/app
go get github.com/gin-gonic/gin@v1.9.1
go mod tidy
go list -m all

模块路径就是 import 路径,clone 下来就能编译,不经过中央注册表(proxy 只是缓存)。版本用 Git tag,如 v1.2.3

go work 用来本地同时开发多个 module,相当于简单 monorepo,了解即可。vendor/ 是把依赖拷进仓库,CI 离线构建才需要。

面试要点:导出靠大小写不是风格问题,是语言规则;JSON、跨包调用、测试里的 Foo/foo 全跟它挂钩。

On this page