Go语言学习(四)

一、rune类型

rune 是 Go 语言特有的数据类型,专门用于处理 Unicode 字符,尤其是多字节字符(如中文、日文、韩文等)。理解 rune 对于正确处理国际化文本至关重要。

在go中,rune本质是int32类型。它的作用是表示一个 Unicode 码点(code point),范围从 00x10FFFF,可以表示世界上几乎所有的字符。

(1)为什么需要rune类型

Go 中的 string 是字节的集合,每个 byte 占 1 个字节(8 位),只能表示 ASCII 字符(0-127)。而 Unicode 字符(如中文)通常需要多个字节表示(UTF-8 编码下占 3 个字节),因此直接用 byte 处理会导致错误。

直接用 byte 处理中文的问题

Go 复制代码
package main

import "fmt"

func main() {
    s := "你好,世界"
    fmt.Println("字符串长度(字节数):", len(s)) // 输出:15(每个中文占3字节,标点也占3字节)
    
    // 尝试用索引访问字符(错误方式)
    for i := 0; i < len(s); i++ {
        fmt.Printf("%c ", s[i]) // 输出乱码:ä ½  å ¥½ ¼ Œ  ä ¸– ç � Œ 
    }
}

len(s) 返回的是字符串的字节数,而非字符数;直接通过索引访问得到的是单个字节,而非完整字符。

(2)将字符串转换为 rune 切片

通过 []rune(s) 可以将字符串转换为 rune 切片,每个元素对应一个 Unicode 字符:

Go 复制代码
package main

import "fmt"

func main() {
    s := "你好,世界"
    runes := []rune(s) // 转换为 rune 切片
    
    fmt.Println("字符数:", len(runes)) // 输出:5(正确的字符数量)
    
    // 遍历 rune 切片(正确访问每个字符)
    for i, r := range runes {
        fmt.Printf("索引 %d: %c(Unicode码点:%U)\n", i, r, r)
    }
}

字符数: 5
索引 0: 你(Unicode码点:U+4F60)
索引 1: 好(Unicode码点:U+597D)
索引 2: ,(Unicode码点:U+FF0C)
索引 3: 世(Unicode码点:U+4E16)
索引 4: 界(Unicode码点:U+754C)
(3)for range 循环遍历字符串

for range 循环会自动将字符串按 rune 解析,每次迭代返回一个 Unicode 字符:

Go 复制代码
package main

import "fmt"

func main() {
    s := "Go语言❤️"
    // for range 自动处理 rune
    for i, r := range s {
        fmt.Printf("位置 %d: 字符 %c(占 %d 字节)\n", i, r, len(string(r)))
    }
}
位置 0: 字符 G(占 1 字节)
位置 1: 字符 o(占 1 字节)
位置 2: 字符 语(占 3 字节)
位置 5: 字符 言(占 3 字节)
位置 8: 字符 ❤(占 3 字节)
位置 11: 字符 ️(占 3 字节)
(4)处理特殊字符(如 emoji、组合字符)
Go 复制代码
package main

import "fmt"

func main() {
    // 笑脸 emoji(占4字节)
    smile := '😊'
    fmt.Printf("笑脸:%c,类型:%T,Unicode码点:%U,字节数:%d\n", 
        smile, smile, smile, len(string(smile)))
    
    // 组合字符(e + 重音符号 → é)
    eAcute := 'é'
    fmt.Printf("组合字符:%c,Unicode码点:%U\n", eAcute, eAcute)
}
笑脸:😊,类型:int32,Unicode码点:U+1F60A,字节数:4
组合字符:é,Unicode码点:U+00E9
(5)字符串截取(避免截断多字节字符)

直接通过字节索引截取字符串可能截断多字节字符,使用 rune 可以安全截取

Go 复制代码
package main

import "fmt"

// 安全截取字符串前n个字符
func safeSubstring(s string, n int) string {
    runes := []rune(s)
    if n > len(runes) {
        n = len(runes)
    }
    return string(runes[:n])
}

func main() {
    s := "Hello,世界!"
    
    // 错误方式:按字节截取(可能截断)
    badSub := s[:7]
    fmt.Println("错误截取:", badSub) // 输出:Hello,(乱码)
    
    // 正确方式:按 rune 截取
    goodSub := safeSubstring(s, 7)
    fmt.Println("正确截取:", goodSub) // 输出:Hello,世
}

二、Go 语言中结构体 Tag 解析

在 Go 语言中,结构体标签(Struct Tag)是一种附着在结构体字段上的元数据,常用于 JSON 序列化、数据库映射等场景。而解析 Tag 的核心技术是 反射(Reflection),它允许程序在运行时检查和操作变量的类型与值。

(1)结构体 Tag 基础

结构体 Tag 是字段声明后用反引号 ````` 包裹的字符串,格式通常为 key:"value" key2:"value2"

Go 复制代码
package main

import "fmt"

// 定义带 Tag 的结构体
type User struct {
    Name string `json:"name" db:"user_name"`
    Age  int    `json:"age,omitempty" db:"user_age"`
}

func main() {
    u := User{Name: "Alice", Age: 30}
    fmt.Printf("%+v\n", u) // 输出:{Name:Alice Age:30}
}

Tag 本身不影响结构体的字段值,仅作为元数据存在,一个字段可以有多个 Tag(用空格分隔不同 key),常见 Tag 用途:json(序列化字段名)、db(数据库列名)、xml(XML 节点名)等。

(2)Tag 依靠反射解析

反射的核心是两个类型:

  • reflect.Type:表示变量的类型信息(静态类型)
  • reflect.Value:表示变量的值信息(动态值)

通过 reflect.TypeOf()reflect.ValueOf() 可获取任意变量的反射对象

Go 复制代码
package main

import "reflect"

func main() {
    u := User{Name: "Bob", Age: 25}
    
    // 获取类型信息(reflect.Type)
    t := reflect.TypeOf(u)
    fmt.Println("类型:", t.Name()) // 输出:User
    
    // 获取值信息(reflect.Value)
    v := reflect.ValueOf(u)
    fmt.Println("值:", v.Field(0).String()) // 输出:Bob
}

解析结构体字段的 Tag 需通过 reflect.TypeField() 方法获取字段信息,再通过 Tag.Get(key) 提取指定 key 的值

Go 复制代码
package main

import (
    "fmt"
    "reflect"
)

type User struct {
    Name string `json:"name" db:"user_name"`
    Age  int    `json:"age,omitempty" db:"user_age"`
}

func parseTags(s interface{}) {
    // 1. 获取反射类型对象(必须传入指针,否则无法修改值,但解析Tag无需修改)
    t := reflect.TypeOf(s)
    
    // 2. 检查是否为结构体(若传入指针,需先获取元素类型)
    if t.Kind() == reflect.Ptr {
        t = t.Elem() // 解引用指针,获取结构体类型
    }
    if t.Kind() != reflect.Struct {
        fmt.Println("输入不是结构体或结构体指针")
        return
    }
    
    // 3. 遍历结构体字段
    for i := 0; i < t.NumField(); i++ {
        field := t.Field(i) // 获取第i个字段的类型信息
        fmt.Printf("字段名:%s\n", field.Name)
        
        // 4. 解析Tag
        jsonTag := field.Tag.Get("json")   // 获取json标签
        dbTag := field.Tag.Get("db")       // 获取db标签
        fmt.Printf("  json标签:%s\n", jsonTag)
        fmt.Printf("  db标签:%s\n", dbTag)
    }
}

func main() {
    u := User{}
    parseTags(&u) // 传入指针(推荐方式)
    // 输出:
    // 字段名:Name
    //   json标签:name
    //   db标签:user_name
    // 字段名:Age
    //   json标签:age,omitempty
    //   db标签:user_age
}
  • reflect.Type.Field(i):获取结构体第 i 个字段的 StructField 对象(包含字段名、类型、Tag 等信息)
  • StructField.Tag:字段的 Tag 信息(类型为 reflect.StructTag
  • StructTag.Get(key):提取 Tag 中指定 key 的值(自动忽略空格和引号)

reflect.StructTag.Get(key) 的底层逻辑是解析 Tag 字符串,按规则提取值。简化的实现思路如下

Go 复制代码
func getTag(tag string, key string) string {
    // Tag 格式示例:`json:"name" db:"user_name"`
    // 1. 按空格分割多个 key-value 对
    // 2. 对每个部分解析为 key:"value" 格式
    // 3. 匹配 key 并返回对应的 value(去除引号)
    // 实际实现需处理各种边缘情况(如引号内的空格、转义字符等)
    return "" // 实际逻辑见 Go 源码:src/reflect/type.go
}

三、反射的核心原理

反射的本质是程序在运行时对自身结构的检查能力,Go 语言的反射系统建立在类型系统之上,核心原理包括

(1)类型信息的存储

Go 语言中,每个变量在编译时都会被赋予一个类型信息,存储在可执行文件中。运行时,通过 reflect.TypeOf 可以获取这个类型信息的副本(reflect.Type)。

  • 静态类型:变量声明时的类型(如 var a intint 是静态类型)
  • 动态类型:接口变量实际指向的值的类型(仅接口有动态类型)
(2)反射的三大法则
  • 从接口值到反射对象
Go 复制代码
var x int = 10
t := reflect.TypeOf(x)  // t 是 int 类型的反射对象
v := reflect.ValueOf(x) // v 是 10 这个值的反射对象
  • 从反射对象到接口值
Go 复制代码
v := reflect.ValueOf(10)
x := v.Interface().(int) // 类型断言,恢复为 int
  • 要修改反射对象,其值必须可设置

反射对象可设置的前提:它持有原始变量的地址(即通过指针获取的反射对象)

Go 复制代码
var x int = 10
v := reflect.ValueOf(x)
fmt.Println(v.CanSet()) // 输出:false(v 是副本,不可修改)

vPtr := reflect.ValueOf(&x).Elem() // 解引用指针,获取原始值的反射对象
fmt.Println(vPtr.CanSet()) // 输出:true(可修改)
vPtr.SetInt(20)
fmt.Println(x) // 输出:20(原始变量被修改)

四、goroutine什么情况下会阻塞

goroutine 是轻量级线程,由 Go 运行时(而非操作系统)调度。虽然 goroutine 设计初衷是高效并发,但在多种场景下会进入阻塞状态 (暂停执行,等待特定条件满足后恢复)。以下是 goroutine 可能阻塞的所有常见情况。

(1)通道操作导致的阻塞

通道是 goroutine 间通信的主要方式,未正确处理的通道操作是阻塞的最常见原因。

1、向无缓冲通道发送数据(ch <- data

阻塞条件 :无缓冲通道(make(chan T))发送数据时,必须有对应的接收操作(<-ch),否则发送方 goroutine 会阻塞,直到有 goroutine 接收数据

Go 复制代码
package main

import "fmt"

func main() {
    ch := make(chan int) // 无缓冲通道

    // 启动发送 goroutine
    go func() {
        fmt.Println("发送前")
        ch <- 10 // 阻塞:等待接收方
        fmt.Println("发送后") // 接收方处理后才执行
    }()

    // 延迟接收(模拟处理耗时)
    // 若注释掉下面一行,发送方会永久阻塞(导致死锁)
    fmt.Println("接收值:", <-ch)
}

发送前
接收值:10
发送后

2、从无缓冲通道接收数据(data := <-ch

阻塞条件 :无缓冲通道接收数据时,若通道中没有数据,接收方 goroutine 会阻塞,直到有 goroutine 发送数据。

Go 复制代码
package main

import "fmt"

func main() {
    ch := make(chan int) // 无缓冲通道

    // 启动接收 goroutine
    go func() {
        fmt.Println("接收前")
        val := <-ch // 阻塞:等待发送方
        fmt.Println("接收值:", val)
    }()

    // 延迟发送(模拟处理耗时)
    ch <- 20 // 发送数据,唤醒接收方
}

接收前
接收值:20

3、向缓冲通道发送数据(缓冲区已满)

阻塞条件 :缓冲通道(make(chan T, n))的缓冲区已满时,发送操作会阻塞,直到有 goroutine 从通道接收数据(腾出缓冲区空间)。

Go 复制代码
package main

import "fmt"
import "time"

func main() {
    ch := make(chan int, 2) // 缓冲区大小为 2

    // 填充缓冲区(不阻塞)
    ch <- 1
    ch <- 2

    // 启动发送 goroutine(缓冲区已满,会阻塞)
    go func() {
        fmt.Println("尝试发送第3个值")
        ch <- 3 // 阻塞:缓冲区已满
        fmt.Println("第3个值发送成功") // 有接收后才执行
    }()

    // 接收一个值(腾出缓冲区空间)
    time.Sleep(3 * time.Second)
    fmt.Println("接收值:", <-ch) // 接收 1
    time.Sleep(3 * time.Second)
}

尝试发送第3个值
接收值: 1
第3个值发送成功

4、从缓冲通道接收数据(缓冲区为空)

阻塞条件 :缓冲通道的缓冲区为空时,接收操作会阻塞,直到有 goroutine 向通道发送数据。

Go 复制代码
package main

import "fmt"
import "time"

func main() {
    ch := make(chan int, 1) // 缓冲通道,初始为空

    // 启动接收 goroutine(缓冲区为空,阻塞)
    go func() {
        fmt.Println("等待接收")
        val := <-ch // 阻塞:缓冲区为空
        fmt.Println("接收到:", val)
    }()
    time.Sleep(1 * time.Second)
    // 发送数据(唤醒接收方)
    ch <- 30
    time.Sleep(1 * time.Second)
}
等待接收
接收到: 30
(2)同步原语导致的阻塞

Go 标准库 sync 包提供的同步工具(如锁、等待组)会导致 goroutine 阻塞,直到同步条件满足。

1、互斥锁(sync.Mutex)与读写锁(sync.RWMutex

  • Mutex.Lock() 阻塞 :当锁已被其他 goroutine 持有,调用 Lock()goroutine 会阻塞,直到锁被释放(Unlock())。
  • RWMutex.Lock() 阻塞:写锁被持有或有读锁时,后续写锁请求会阻塞。
  • RWMutex.RLock() 阻塞:写锁被持有,后续读锁请求会阻塞。
Go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

var mu sync.Mutex

func main() {
    // 第一个 goroutine 持有锁
    go func() {
        mu.Lock()
        defer mu.Unlock()
        fmt.Println("goroutine1: 获得锁,开始处理")
        time.Sleep(2 * time.Second) // 模拟耗时操作
        fmt.Println("goroutine1: 释放锁")
    }()

    // 第二个 goroutine 尝试获取锁(会阻塞)
    go func() {
        fmt.Println("goroutine2: 尝试获取锁")
        mu.Lock() // 阻塞:锁被 goroutine1 持有
        defer mu.Unlock()
        fmt.Println("goroutine2: 获得锁,开始处理")
    }()

    time.Sleep(3 * time.Second) // 等待所有 goroutine 执行
}

输出顺序不一定完全和我这个一样:
goroutine1: 获得锁,开始处理
goroutine2: 尝试获取锁
goroutine1: 释放锁
goroutine2: 获得锁,开始处理

2、等待组(sync.WaitGroup

WaitGroup.Wait() 阻塞 :调用 Wait()goroutine 会阻塞,直到等待组的计数器减为 0(所有 Add(n)goroutine 都调用了 Done())。

Go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(2) // 等待 2 个 goroutine

    // 启动第一个任务
    go func() {
        defer wg.Done()
        time.Sleep(1 * time.Second)
        fmt.Println("任务1完成")
    }()

    // 启动第二个任务
    go func() {
        defer wg.Done()
        time.Sleep(2 * time.Second)
        fmt.Println("任务2完成")
    }()

    fmt.Println("等待所有任务完成...")
    wg.Wait() // 阻塞:等待计数器为 0
    fmt.Println("所有任务完成")
}
等待所有任务完成...
任务一完成
任务二完成
所有任务完成

3、条件变量(sync.Cond

Cond.Wait() 阻塞 :调用 Wait()goroutine 会释放关联的锁并阻塞,直到被 Signal()Broadcast() 唤醒(唤醒后需重新获取锁)。

Go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    mu := &sync.Mutex{}
    cond := sync.NewCond(mu)
    ready := false

    // 等待者 goroutine
    go func() {
        cond.L.Lock()
        defer cond.L.Unlock()

        // 循环等待条件满足(避免虚假唤醒)
        for !ready {
            fmt.Println("等待者:条件不满足,阻塞")
            cond.Wait() // 释放锁并阻塞
        }
        fmt.Println("等待者:条件满足,继续执行")
    }()

    // 通知者 goroutine
    go func() {
        time.Sleep(1 * time.Second) // 模拟准备时间
        cond.L.Lock()
        ready = true // 满足条件
        cond.L.Unlock()
        fmt.Println("通知者:发送唤醒信号")
        cond.Signal() // 唤醒一个等待的 goroutine
    }()

    time.Sleep(2 * time.Second)
}
等待者:条件不满足,阻塞
通知者:发送唤醒信号
等待者:条件满足,继续执行

cond.Wait() 内部做了三件事:

a. 释放已持有的锁 (cond.L.Unlock())。

b. 阻塞当前 goroutine,等待通知。

c. 当被唤醒时,重新获取锁 (cond.L.Lock())。

cond.Signal() 唤醒的是谁?

cond.Signal() 会唤醒正在等待(即正在执行 cond.Wait())的 goroutine 中的任意一个,这些 goroutine 都绑定在同一个 cond 变量上。它唤醒的不是"拥有这个cond的goroutine",而是"等待这个cond的goroutine"。

拥有者 :条件变量本身没有"拥有者"的概念。锁 (mutex) 有"持有者",但条件变量没有。

等待队列 :每个 sync.Cond 实例内部维护了一个等待队列 ,所有在这个 cond 上调用 Wait() 的 goroutine 都会被加入到这个队列中。

cond.Signal() 的工作流程是:

  1. 从与该 cond 关联的等待队列中,取出第一个(Go 运行时不保证是严格FIFO,但可以理解为最先等待的或任意一个)goroutine。

  2. 将这个 goroutine 标记为可唤醒状态。

下面给出有多个等待者的情况:

Go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	mu := &sync.Mutex{}
	cond := sync.NewCond(mu)
	ready := false

	// 创建 3 个等待者 goroutine
	for i := 1; i <= 3; i++ {
		id := i
		go func() {
			cond.L.Lock()
			defer cond.L.Unlock()

			for !ready {
				fmt.Printf("等待者 %d:条件不满足,阻塞\n", id)
				cond.Wait()
				fmt.Printf("等待者 %d:被唤醒,检查条件\n", id)
			}
			fmt.Printf("等待者 %d:条件满足,继续执行\n", id)
		}()
	}

	// 通知者 goroutine
	go func() {
		time.Sleep(1 * time.Second)
		cond.L.Lock()
		ready = true
		cond.L.Unlock()
		fmt.Println("通知者:发送唤醒信号 (Signal)")
		cond.Signal() // 只唤醒一个等待者!
	}()

	time.Sleep(3 * time.Second)
	fmt.Println("\n--- 使用 Broadcast 的例子 ---")

	// 重置,演示 Broadcast
	mu2 := &sync.Mutex{}
	cond2 := sync.NewCond(mu2)
	ready2 := false

	for i := 1; i <= 3; i++ {
		id := i
		go func() {
			cond2.L.Lock()
			defer cond2.L.Unlock()

			for !ready2 {
				fmt.Printf("等待者 %d:条件不满足,阻塞\n", id)
				cond2.Wait()
				fmt.Printf("等待者 %d:被唤醒,检查条件\n", id)
			}
			fmt.Printf("等待者 %d:条件满足,继续执行\n", id)
		}()
	}

	time.Sleep(500 * time.Millisecond)
	go func() {
		time.Sleep(1 * time.Second)
		cond2.L.Lock()
		ready2 = true
		cond2.L.Unlock()
		fmt.Println("通知者:发送广播信号 (Broadcast)")
		cond2.Broadcast() // 唤醒所有等待者!
	}()

	time.Sleep(3 * time.Second)
}

等待者 1:条件不满足,阻塞
等待者 2:条件不满足,阻塞
等待者 3:条件不满足,阻塞
通知者:发送唤醒信号 (Signal)
等待者 1:被唤醒,检查条件
等待者 1:条件满足,继续执行

--- 使用 Broadcast 的例子 ---
等待者 3:条件不满足,阻塞
等待者 2:条件不满足,阻塞
等待者 1:条件不满足,阻塞
通知者:发送广播信号 (Broadcast)
等待者 1:被唤醒,检查条件
等待者 1:条件满足,继续执行
等待者 3:被唤醒,检查条件
等待者 3:条件满足,继续执行
等待者 2:被唤醒,检查条件
等待者 2:条件满足,继续执行

当使用 cond.Signal() 时,只有一个等待者会被唤醒并继续执行。

当使用 cond.Broadcast() 时,所有三个等待者都会被唤醒并继续执行。

(3)网络与 I/O 操作导致的阻塞

goroutine 执行网络请求、文件读写等 I/O 操作时,会进入阻塞状态,直到 I/O 完成。

1、网络请求(HTTP、TCP 等)

Go 复制代码
package main

import (
    "fmt"
    "net/http"
)

func main() {
    // 启动 goroutine 发起网络请求(会阻塞)
    go func() {
        fmt.Println("开始请求网页...")
        resp, err := http.Get("https://www.baidu.com") // 阻塞:等待响应
        if err == nil {
            fmt.Printf("请求完成,状态码:%d\n", resp.StatusCode)
            resp.Body.Close()
        }
    }()

    // 等待请求完成
    var input string
    fmt.Scanln(&input) // 阻塞主线程,避免程序退出
}

开始请求网页...
请求完成,状态码:200

2、文件读写

阻塞条件 :读写文件时,goroutine 会阻塞,直到操作完成(数据读取 / 写入完毕)。

Go 复制代码
package main

import (
    "fmt"
    "os"
)

func main() {
    // 启动 goroutine 读取文件(会阻塞)
    go func() {
        fmt.Println("开始读取文件...")
        data, err := os.ReadFile("test.txt") // 阻塞:等待文件读取完成
        if err == nil {
            fmt.Printf("文件读取完成,长度:%d 字节\n", len(data))
        } else {
            fmt.Println("读取错误:", err)
        }
    }()

    // 等待操作完成
    var input string
    fmt.Scanln(&input)
}
(4)其他阻塞场景

1、time.Sleep()

阻塞条件goroutine 会暂停执行指定的时间,期间处于阻塞状态。

Go 复制代码
package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        fmt.Println("开始休眠...")
        time.Sleep(2 * time.Second) // 阻塞 2 秒
        fmt.Println("休眠结束")
    }()

    time.Sleep(3 * time.Second) // 等待子 goroutine 执行
}

2、等待操作系统信号(os.Signal

Go 复制代码
package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // 创建信号通道
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) // 监听 Ctrl+C 等信号

    // 启动 goroutine 等待信号(阻塞)
    go func() {
        fmt.Println("等待信号(按 Ctrl+C 发送 SIGINT)...")
        sig := <-sigChan // 阻塞:等待信号
        fmt.Printf("收到信号:%v,退出程序\n", sig)
        os.Exit(0)
    }()

    // 主线程无限循环
    for {
        time.Sleep(1 * time.Second)
    }
}

// 等待信号(按 Ctrl+C 发送 SIGINT)...
// ^C收到信号:interrupt,退出程序
相关推荐
沐知全栈开发3 小时前
C# 枚举(Enum)
开发语言
秦禹辰3 小时前
轻量级开源文件共享系统PicoShare本地部署并实现公网环境文件共享
开发语言·后端·golang
脑子慢且灵3 小时前
C语言与Java语言编译过程及文件类型
java·c语言·开发语言·汇编·编辑器
蒙奇D索大3 小时前
【C语言加油站】C语言文件操作详解:从“流”的概念到文件的打开与关闭
c语言·开发语言·笔记·学习·改行学it
数据库生产实战4 小时前
Oracle LOB使用入门和简单使用,提供学习用的测试用例!
数据库·学习·oracle
武子康4 小时前
Java-144 深入浅出 MongoDB BSON详解:MongoDB核心存储格式与JSON的区别与应用场景
java·开发语言·数据库·mongodb·性能优化·json·bjson
爱喝水的鱼丶4 小时前
SAP-ABAP:SAP中的用户确认对话框:深入理解与实践POPUP_TO_CONFIRM
运维·开发语言·学习·sap·abap
小此方4 小时前
C语言自定义变量类型结构体理论:从初见到精通(上)
c语言·开发语言
努力也学不会java4 小时前
【Java并发】揭秘Lock体系 -- 深入理解ReentrantReadWriteLock
java·开发语言·python·机器学习