返回首页

Golang入门:1.6 结构体与面向对象

在前面的章节里,我们已经学习了 Go 的变量、流程控制、函数、数组、切片、映射等基础内容。从这一节开始,我们正式进入 Go 中非常重要的一块内容:结构体(struct)

如果你有 Java、C++、Python 等语言的经验,可能会下意识把结构体理解为“类”的替代品。这个理解不能说完全错,但也不够准确。Go 的设计思路与传统面向对象语言不同:它没有经典意义上的 class、没有继承体系,却依然能够写出清晰、可维护、可扩展的程序。

这正是 Go 的一个重要理念:用组合代替继承(Composition over Inheritance)

这一节我们会围绕以下几个核心知识点展开:

  • 结构体定义与初始化
  • 结构体嵌套与组合
  • 构造函数模式
  • 标签(Tag)与序列化基础

学完这一节后,你不仅会写结构体,还会理解 Go 在“面向对象”这件事上的设计哲学。

一、为什么 Go 需要结构体

在真实开发中,很多数据并不是单个值,而是一组彼此相关的数据。

例如,一个学生通常有:

  • 姓名
  • 年龄
  • 班级
  • 成绩

如果只用普通变量来表示,会变成这样:

var studentName string = "小明"
var studentAge int = 18
var studentClass string = "高三(1)班"
var studentScore float64 = 92.5

这样写在数据很少时还可以接受,但一旦数据增多,就会很混乱:

  1. 同一对象的数据分散在多个变量中,不容易管理
  2. 很难批量传递给函数
  3. 代码可读性和可维护性较差

这时就需要把这些相关字段组织在一起,而 Go 提供的方式就是 结构体

二、结构体定义与初始化

1. 什么是结构体

结构体是一种用户自定义类型,它可以把多个不同类型的字段组合成一个整体。

语法如下:

type 结构体名 struct {
    字段名 字段类型
    字段名 字段类型
}

比如定义一个学生结构体:

type Student struct {
    Name  string
    Age   int
    Class string
    Score float64
}

这里的 Student 就是一个新的类型,它包含了 4 个字段。

2. 结构体的基本初始化方式

Go 中常见的结构体初始化方式有三种:

  • 先声明再赋值
  • 按字段名初始化
  • 按顺序初始化

下面通过一个完整示例来看。

package main

import "fmt"

type Student struct {
    Name  string
    Age   int
    Class string
    Score float64
}

func main() {
    // 方式1:先声明,再逐个赋值
    var s1 Student
    s1.Name = "张三"
    s1.Age = 20
    s1.Class = "Go入门班"
    s1.Score = 95.5

    // 方式2:按字段名初始化,推荐使用
    s2 := Student{
        Name:  "李四",
        Age:   21,
        Class: "Go进阶班",
        Score: 88.0,
    }

    // 方式3:按字段顺序初始化
    s3 := Student{"王五", 19, "Go项目班", 91.2}

    fmt.Println("s1:", s1)
    fmt.Println("s2:", s2)
    fmt.Println("s3:", s3)
}

运行结果类似如下:

s1: {张三 20 Go入门班 95.5}
s2: {李四 21 Go进阶班 88}
s3: {王五 19 Go项目班 91.2}

3. 推荐优先使用“按字段名初始化”

虽然按顺序初始化写起来更短,但不够直观,而且字段顺序一旦调整,代码就容易出错。

来看一个对比:

初始化方式 可读性 是否依赖字段顺序 是否推荐
先声明后赋值 较高 适合少量修改
按字段名初始化 很高 推荐
按顺序初始化 一般 不推荐大型项目使用

因此,在工程开发中,更推荐这样写:

user := Student{
    Name:  "赵六",
    Age:   22,
    Class: "Go实战班",
    Score: 93.8,
}

4. 访问和修改结构体字段

结构体字段通过 . 来访问。

package main

import "fmt"

type Book struct {
    Title  string
    Author string
    Price  float64
}

func main() {
    book := Book{
        Title:  "Go语言实战",
        Author: "某作者",
        Price:  59.9,
    }

    fmt.Println("书名:", book.Title)
    fmt.Println("作者:", book.Author)
    fmt.Println("价格:", book.Price)

    book.Price = 49.9
    fmt.Println("打折后价格:", book.Price)
}

5. 结构体是值类型

Go 中结构体默认是值类型。这意味着:

  • 把一个结构体赋值给另一个变量时,会复制一份数据
  • 修改副本不会影响原对象
package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func main() {
    u1 := User{Name: "小王", Age: 25}
    u2 := u1

    u2.Name = "小李"

    fmt.Println("u1:", u1)
    fmt.Println("u2:", u2)
}

输出结果:

u1: {小王 25}
u2: {小李 25}

这说明 u2 修改后,u1 并没有变化。

如果你希望多个地方操作同一份数据,就需要使用指针,这部分我们会在后续章节继续深入。

三、Go 的“面向对象”到底是什么

很多初学者第一次接触 Go 时会问:

没有 class,Go 还能算面向对象吗?

答案是:可以,但方式不同。

传统面向对象语言通常依赖三大特性:

  • 封装
  • 继承
  • 多态

Go 对这些能力并不是完全不要,而是采用了更轻量、更直接的实现方式:

  • 结构体 + 方法表达对象
  • 组合代替继承
  • 接口实现多态

本节重点先理解前两点。

换句话说,Go 不是“没有面向对象”,而是不鼓励复杂的面向对象层级设计

它更关注:

  • 代码是否简单
  • 依赖关系是否清晰
  • 组合是否灵活
  • 修改是否低成本

这也是 Go 非常适合工程开发的重要原因。

四、结构体嵌套与组合(Composition over Inheritance)

1. 什么是结构体嵌套

结构体的字段本身也可以是另一个结构体,这就叫结构体嵌套

例如,一个人有地址信息,地址本身也可以定义成结构体:

package main

import "fmt"

type Address struct {
    City    string
    District string
}

type Person struct {
    Name    string
    Age     int
    Address Address
}

func main() {
    p := Person{
        Name: "小张",
        Age:  28,
        Address: Address{
            City:    "北京",
            District: "海淀区",
        },
    }

    fmt.Println("姓名:", p.Name)
    fmt.Println("年龄:", p.Age)
    fmt.Println("城市:", p.Address.City)
    fmt.Println("区:", p.Address.District)
}

结构体嵌套的好处是:

  • 数据层次更清晰
  • 模块边界更明确
  • 可以复用已有结构体定义

2. 匿名嵌套与字段提升

Go 还支持把一个结构体类型直接嵌入另一个结构体中,而不写字段名。这叫匿名字段嵌入字段

package main

import "fmt"

type Address struct {
    City    string
    District string
}

type Employee struct {
    Name string
    Address
}

func main() {
    e := Employee{
        Name: "小刘",
        Address: Address{
            City:    "上海",
            District: "浦东新区",
        },
    }

    fmt.Println("姓名:", e.Name)
    fmt.Println("城市:", e.City)
    fmt.Println("区:", e.District)
}

这里 Employee 中嵌入了 Address,因此 e.City 实际上等价于 e.Address.City

这被称为字段提升。它让组合后的使用体验更加自然。

3. 为什么 Go 推崇组合而不是继承

在很多传统面向对象语言里,我们经常会这样设计:

  • 动物是父类
  • 狗继承动物
  • 导盲犬继承狗
  • 某种特殊导盲犬再继续继承……

看起来层次清晰,但实际项目里很容易出现问题:

  1. 继承层级越来越深,理解成本越来越高
  2. 父类变动影响面很大,修改风险高
  3. 子类会继承很多自己并不真正需要的能力
  4. 关系一旦设计错了,后续很难调整

Go 选择了另一条路线:

不强调“是什么”的继承关系,而强调“由哪些能力组合而成”。

例如,与其说“汽车继承交通工具”,不如说:

  • 汽车有引擎
  • 汽车有轮子
  • 汽车有行驶能力

这样的设计更贴近现实,也更灵活。

4. 用组合表达能力

下面我们用一个完整例子来体会 Go 的组合思想。

package main

import "fmt"

type Engine struct {
    Brand string
    Power int
}

func (e Engine) Start() {
    fmt.Printf("%s 引擎启动,马力:%d\n", e.Brand, e.Power)
}

type Car struct {
    Model string
    Engine
}

func main() {
    car := Car{
        Model: "家用轿车",
        Engine: Engine{
            Brand: "BYD",
            Power: 150,
        },
    }

    fmt.Println("车型:", car.Model)
    car.Start()
}

这个例子里:

  • Car 没有继承 Engine
  • Car 只是组合了一个 Engine
  • 但由于嵌入字段的存在,car.Start() 可以直接调用

这就是 Go 很典型的风格:通过组合复用能力,而不是通过继承绑定层级关系。

5. 组合的优势总结

对比项 继承 组合
关系表达 强耦合,层级固定 灵活组合,按需拼装
复用方式 继承父类全部能力 只组合需要的能力
修改影响 父类修改可能波及全部子类 局部改动影响更可控
代码理解 层级深时复杂 结构通常更直观
工程适应性 容易过度设计 更适合演进式开发

对于 Go 来说,组合并不是“退而求其次”的方案,而是一种有明确价值取向的设计哲学。

它的优势主要体现在三点:

  1. 低耦合:各部分职责清晰,不容易互相牵连
  2. 高灵活:不同结构可以组合出不同能力
  3. 易维护:后期重构成本更低,更适合真实项目迭代

这也是为什么 Go 社区经常强调一句话:

Composition over Inheritance

五、方法:让结构体拥有行为

仅有数据还不够。一个“对象”通常既有数据,也有行为。

在 Go 中,结构体可以绑定方法。方法本质上就是带接收者的函数

package main

import "fmt"

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

func main() {
    rect := Rectangle{
        Width:  5,
        Height: 3,
    }

    fmt.Println("面积:", rect.Area())
    fmt.Println("周长:", rect.Perimeter())
}

这里:

  • Area()Rectangle 的方法
  • Perimeter() 也是 Rectangle 的方法

这样写以后,结构体就不再只是“数据袋子”,而是同时具备了与数据相关的操作。

这就是 Go 组织“对象行为”的方式。

六、构造函数模式

1. Go 没有构造函数语法

在 Java、C++ 中,类通常有专门的构造函数语法。但 Go 没有内建的 constructor 关键字。

不过在实际开发中,Go 有一套非常常见的替代方案:

  • 定义一个普通函数
  • 函数名通常以 New 开头
  • 在函数中完成对象初始化
  • 返回结构体或结构体指针

这就叫 构造函数模式

2. 为什么需要构造函数模式

看起来直接写结构体字面量就能创建对象,那为什么还需要 NewXXX 呢?

因为构造函数模式可以帮助我们:

  1. 统一初始化逻辑
  2. 隐藏内部实现细节
  3. 保证对象创建时处于合法状态
  4. 便于未来扩展

例如,某个用户对象要求年龄不能小于 0,注册时间要自动赋值,这种逻辑就适合放到构造函数里。

3. 基础示例:New 函数

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func NewUser(name string, age int) User {
    if age < 0 {
        age = 0
    }

    return User{
        Name: name,
        Age:  age,
    }
}

func main() {
    user := NewUser("小陈", -5)
    fmt.Println(user)
}

输出结果:

{小陈 0}

这里 NewUser 的作用就是保证 User 在创建时符合基本规则。

4. 返回值还是返回指针?

Go 中构造函数既可以返回值,也可以返回指针。两者都合法,但使用场景略有区别。

返回方式 示例 适用场景
返回值 func NewUser(...) User 结构体较小、拷贝成本低、语义简单
返回指针 func NewUser(...) *User 结构体较大、需要共享修改、常用于工程代码

在实际项目中,很多 NewXXX 都会返回指针,因为这样可以:

  • 避免不必要的拷贝
  • 更方便后续修改字段
  • 与方法的指针接收者配合更自然

5. 返回指针的完整示例

package main

import "fmt"

type Counter struct {
    Value int
}

func NewCounter() *Counter {
    return &Counter{Value: 0}
}

func (c *Counter) Add() {
    c.Value++
}

func main() {
    counter := NewCounter()
    counter.Add()
    counter.Add()

    fmt.Println("当前计数:", counter.Value)
}

这里如果不用指针,而是使用值传递,修改可能只发生在副本上。返回指针后,就能更明确地表示:

这是一个会被持续修改的对象。

6. 构造函数模式的工程意义

对于初学者来说,NewXXX 似乎只是写法上的习惯;但在项目中,它其实非常重要。

例如下面这些事情,都适合放在构造函数中:

  • 设置默认值
  • 检查参数是否合法
  • 初始化内部依赖
  • 创建 map、slice、channel 等字段
  • 保证对象处于可用状态

看一个更完整的例子:

package main

import "fmt"

type Config struct {
    Host string
    Port int
}

func NewConfig(host string, port int) *Config {
    if host == "" {
        host = "127.0.0.1"
    }
    if port <= 0 {
        port = 8080
    }

    return &Config{
        Host: host,
        Port: port,
    }
}

func main() {
    config := NewConfig("", 0)
    fmt.Printf("服务启动地址:http://%s:%d\n", config.Host, config.Port)
}

这类模式能让对象在“出生时”就更可靠,调用者也不必每次手动设置默认值。

七、标签(Tag)与序列化基础

1. 什么是结构体标签

Go 的结构体字段后面可以写一段反引号包裹的元信息,这就是标签(Tag)

例如:

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

标签本身不会直接影响结构体字段的存储,但很多标准库和第三方库会读取这些标签,用来完成:

  • JSON 序列化与反序列化
  • 数据库字段映射
  • 表单参数绑定
  • 校验规则定义

也就是说,Tag 是结构体和外部世界沟通的一种“说明书”。

2. 为什么序列化需要 Tag

假设我们要把结构体转换成 JSON,如果没有标签,默认会使用字段名作为键。

但 Go 字段名通常是大写开头的导出字段,例如 NameAge,而 JSON 更常见的风格往往是小写:nameage

这时 Tag 就能发挥作用。

3. JSON 序列化示例

下面是一个完整可运行示例,演示如何把结构体转换成 JSON。

package main

import (
    "encoding/json"
    "fmt"
)

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

func main() {
    user := User{
        Name:  "小林",
        Age:   24,
        Email: "xiaolin@example.com",
    }

    data, err := json.Marshal(user)
    if err != nil {
        fmt.Println("序列化失败:", err)
        return
    }

    fmt.Println(string(data))
}

输出结果:

{"name":"小林","age":24,"email":"xiaolin@example.com"}

这里有两个关键点:

  1. 字段必须是可导出的,也就是首字母大写
  2. json:"name" 指定了 JSON 中对应的键名

如果字段首字母小写,即使写了 Tag,JSON 包通常也无法访问到它。

4. JSON 反序列化示例

序列化是把结构体转成 JSON,反序列化则是把 JSON 解析回结构体。

package main

import (
    "encoding/json"
    "fmt"
)

type Product struct {
    ID    int     `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

func main() {
    jsonStr := `{"id":101,"name":"机械键盘","price":299.5}`

    var product Product
    err := json.Unmarshal([]byte(jsonStr), &product)
    if err != nil {
        fmt.Println("反序列化失败:", err)
        return
    }

    fmt.Printf("商品编号:%d\n", product.ID)
    fmt.Printf("商品名称:%s\n", product.Name)
    fmt.Printf("商品价格:%.2f\n", product.Price)
}

这里要注意:

  • json.Unmarshal 需要传入结构体指针
  • 否则无法把解析结果写回变量

5. 常见 Tag 用法

JSON Tag 不只是改字段名,还可以控制一些序列化行为。

Tag 写法 含义
json:"name" 指定 JSON 字段名为 name
json:"name,omitempty" 当字段为零值时忽略该字段
json:"-" 该字段不参与 JSON 序列化

下面看一个完整示例:

package main

import (
    "encoding/json"
    "fmt"
)

type Account struct {
    Username string `json:"username"`
    Password string `json:"-"`
    Nickname string `json:"nickname,omitempty"`
}

func main() {
    account := Account{
        Username: "admin",
        Password: "123456",
        Nickname: "",
    }

    data, err := json.Marshal(account)
    if err != nil {
        fmt.Println("序列化失败:", err)
        return
    }

    fmt.Println(string(data))
}

输出结果:

{"username":"admin"}

解释如下:

  • Passwordjson:"-" 忽略了
  • Nickname 因为空字符串,属于零值,因此被 omitempty 忽略

这在接口开发中非常常见。

6. Tag 的本质理解

初学者容易把 Tag 理解成“语法装饰”,其实它的价值远不止如此。

你可以把它理解为:

给结构体字段附加额外描述信息,让其他工具或库知道该如何处理这个字段。

也正因为如此,结构体 + Tag 是 Go 中数据建模的基础组合之一。

八、一个综合示例:结构体、组合、构造函数、Tag 一起使用

为了把今天的内容串起来,我们看一个稍微完整一点的例子。

这个例子模拟“用户资料”场景,包含:

  • 结构体定义
  • 嵌套组合
  • 构造函数模式
  • JSON Tag
package main

import (
    "encoding/json"
    "fmt"
)

type Profile struct {
    City  string `json:"city"`
    Hobby string `json:"hobby"`
}

type User struct {
    Name    string  `json:"name"`
    Age     int     `json:"age"`
    Email   string  `json:"email"`
    Profile Profile `json:"profile"`
}

func NewUser(name string, age int, email string, city string, hobby string) *User {
    if age < 0 {
        age = 0
    }

    return &User{
        Name:  name,
        Age:   age,
        Email: email,
        Profile: Profile{
            City:  city,
            Hobby: hobby,
        },
    }
}

func main() {
    user := NewUser("小周", 26, "xiaozhou@example.com", "杭州", "摄影")

    data, err := json.MarshalIndent(user, "", "  ")
    if err != nil {
        fmt.Println("序列化失败:", err)
        return
    }

    fmt.Println(string(data))
}

输出结果类似如下:

{
  "name": "小周",
  "age": 26,
  "email": "xiaozhou@example.com",
  "profile": {
    "city": "杭州",
    "hobby": "摄影"
  }
}

这个例子已经很接近真实项目中的数据模型写法了。

九、本节总结

这一节我们正式进入了 Go 中最重要的数据组织方式:结构体。

请你重点掌握以下几点:

  1. 结构体用于组织相关数据,是 Go 中自定义类型的重要方式
  2. 结构体可以初始化、复制、传值和修改字段
  3. Go 没有传统的类继承体系,但依然支持“面向对象式”的组织方式
  4. 组合优于继承 是 Go 的核心设计哲学之一
  5. NewXXX 是 Go 中非常常见的构造函数模式
  6. Tag 是结构体与 JSON 等外部格式协作的重要机制

尤其要理解的是:

Go 并不是做不到继承,而是有意减少复杂层级带来的设计负担。它鼓励开发者把关注点放在“能力组合”和“职责拆分”上,而不是构建庞大的继承树。

这种设计的实际优势在工程里非常明显:

  • 更容易维护
  • 更容易扩展
  • 更容易重构
  • 更不容易被历史设计绑死

对于刚学 Go 的同学来说,一开始可能会觉得“怎么和我以前学的面向对象不一样”。但当你写的代码越来越多,就会逐渐体会到这种风格的简洁与实用。

下一步,你可以继续思考这样一个问题:

结构体有了数据和方法之后,多个不同类型如果想表现出同一种行为,该怎么做?

这就会自然引出 Go 中另一项非常重要的能力:接口(interface)


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang入门:1.5 函数与方法

下一篇

Golang入门:1.7 接口与多态