在Go语言的学习过程中,接口 、错误处理 和panic恢复 是三个非常重要的主题。今天我们将通过一个完整的案例------安全的计算器服务,综合运用这些知识,并引入装饰器模式进行日志记录。本文不仅会给出完整的代码实现,还会提供单元测试,帮助你彻底理解这些概念。
一、需求概览
我们需要设计一个计算器系统,满足以下要求:
-
定义
Calculator接口,包含加、减、乘、除、幂运算。 -
定义
AdvancedCalculator接口(嵌入Calculator),增加平方根和阶乘。 -
自定义多种错误类型:除零、负数开方、无效阶乘、溢出。
-
实现
SafeCalculator结构体,完成所有方法。其中阶乘在n > 20时主动panic,并用defer+recover捕获转换为错误。 -
实现
CalculatorLogger装饰器,包装任意Calculator实现并打印日志。 -
实现类型开关函数
DescribeCalculator,用于识别计算器类型。 -
编写单元测试(使用标准
testing包)覆盖所有功能。
二、核心知识点
1. 接口嵌入
Go语言的接口支持嵌入,类似于结构体的嵌入,可以实现接口的组合:
go
type Calculator interface {
Add(a, b float64) float64
// ...
}
type AdvancedCalculator interface {
Calculator // 嵌入
SquareRoot(n float64) (float64, error)
}
AdvancedCalculator 自动拥有 Calculator 的所有方法,这就是接口的"继承"效果。
2. 自定义错误与错误链
使用 errors.New 定义包级错误变量,配合 fmt.Errorf 和 %w 可以包装错误,实现错误链:
go
var ErrDivisionByZero = errors.New("division by zero")
// 使用时:
return 0, fmt.Errorf("%w: cannot divide by zero", ErrDivisionByZero)
调用方可以用 errors.Is(err, ErrDivisionByZero) 判断错误类型。
3. Panic 与 Recover
Go 推荐使用返回错误的方式处理异常,但对于某些不可恢复的情况(如数组越界、超出范围)可以使用 panic。通过 defer + recover 可以将 panic 转换为错误,防止程序崩溃:
go
func (sc *SafeCalculator) Factorial(n int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: recovered from panic: %v", ErrInvalidFactorial, r)
result = 0
}
}()
if n > 20 {
panic("factorial too large")
}
// 正常计算...
}
注意:命名返回值 result, err 使得 defer 中可以修改它们。
4. 装饰器模式
装饰器模式允许在不修改原有类型的基础上动态扩展功能。CalculatorLogger 包装一个 Calculator,在调用前后添加日志:
go
type CalculatorLogger struct {
calc Calculator
}
func (cl *CalculatorLogger) Add(a, b float64) float64 {
fmt.Printf("[LOG] Add(%.2f, %.2f) -> ", a, b)
result := cl.calc.Add(a, b)
fmt.Printf("%.2f\n", result)
return result
}
这样我们可以随时添加日志功能,而不侵入 SafeCalculator 的代码。
5. 类型开关
使用类型断言或者 type switch 可以动态判断接口的底层类型:
go
func DescribeCalculator(calc interface{}) {
switch v := calc.(type) {
case *SafeCalculator:
fmt.Println("This is a SafeCalculator")
case *CalculatorLogger:
fmt.Println("This is a CalculatorLogger")
default:
fmt.Printf("Unknown type: %T\n", v)
}
}
三、完整代码实现
主文件 main.go
go
package main
import (
"errors"
"fmt"
"math"
)
// 错误变量定义
var (
ErrDivisionByZero = errors.New("division by zero")
ErrNegativeSquareRoot = errors.New("negative square root")
ErrInvalidFactorial = errors.New("invalid factorial")
ErrOverflow = errors.New("overflow")
)
// Calculator 接口
type Calculator interface {
Add(a, b float64) float64
Subtract(a, b float64) float64
Multiply(a, b float64) float64
Divide(a, b float64) (float64, error)
Power(base, exp float64) (float64, error)
}
// AdvancedCalculator 接口
type AdvancedCalculator interface {
Calculator
SquareRoot(n float64) (float64, error)
Factorial(n int) (int, error)
}
// SafeCalculator 实现
type SafeCalculator struct{}
func (sc *SafeCalculator) Add(a, b float64) float64 { return a + b }
func (sc *SafeCalculator) Subtract(a, b float64) float64 { return a - b }
func (sc *SafeCalculator) Multiply(a, b float64) float64 { return a * b }
func (sc *SafeCalculator) Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
func (sc *SafeCalculator) Power(base, exp float64) (float64, error) {
r := math.Pow(base, exp)
if math.IsInf(r, 0) || math.IsNaN(r) {
return 0, ErrOverflow
}
return r, nil
}
func (sc *SafeCalculator) SquareRoot(n float64) (float64, error) {
if n < 0 {
return 0, fmt.Errorf("%w: %.2f", ErrNegativeSquareRoot, n)
}
return math.Sqrt(n), nil
}
func (sc *SafeCalculator) Factorial(n int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%w: recovered from panic: %v", ErrInvalidFactorial, r)
result = 0
}
}()
if n < 0 {
return 0, fmt.Errorf("%w: n must be non-negative, got %d", ErrInvalidFactorial, n)
}
if n > 20 {
panic(fmt.Sprintf("factorial %d is too large", n))
}
result = 1
for i := 2; i <= n; i++ {
result *= i
}
return result, nil
}
// CalculatorLogger 装饰器
type CalculatorLogger struct {
calc Calculator
}
func (cl *CalculatorLogger) Add(a, b float64) float64 {
fmt.Printf("[LOG] Add(%.2f, %.2f) -> ", a, b)
r := cl.calc.Add(a, b)
fmt.Printf("%.2f\n", r)
return r
}
// 其他方法类似,省略...
// 完整代码见文末GitHub链接
// DescribeCalculator 类型开关
func DescribeCalculator(calc interface{}) {
fmt.Printf("Type: %T\n", calc)
if _, ok := calc.(Calculator); ok {
fmt.Println(" Implements Calculator")
}
if _, ok := calc.(AdvancedCalculator); ok {
fmt.Println(" Implements AdvancedCalculator")
}
switch calc.(type) {
case *SafeCalculator:
fmt.Println(" It's a SafeCalculator")
case *CalculatorLogger:
fmt.Println(" It's a CalculatorLogger")
}
}
func main() {
// 测试代码(略)
}
测试文件 main_test.go
使用标准库 testing,不依赖第三方:
go
package main
import (
"errors"
"testing"
)
func TestBasicOperations(t *testing.T) {
calc := &SafeCalculator{}
// 子测试用例...
}
func TestDivisionByZero(t *testing.T) {
calc := &SafeCalculator{}
result, err := calc.Divide(10, 0)
if !errors.Is(err, ErrDivisionByZero) {
t.Errorf("expected division by zero error")
}
if result != 0 {
t.Errorf("result should be 0")
}
}
// 更多测试...
四、关键设计思考
为什么阶乘大于20要panic?
计算 20! 已经接近 int 类型的上限(64位系统下约 9.22e18,20! = 2.43e18 尚未溢出,21! = 5.1e19 溢出)。为了演示 panic/recover,我们设置阈值为 20。在实际项目中,应使用 uint64 或 math/big 处理大数。
为什么不直接在Power中检查溢出?
math.Pow 返回 +Inf 或 NaN 表示溢出。我们可以通过 math.IsInf 和 math.IsNaN 检测,这是一种通用的溢出处理方式。
装饰器的优势
如果日后需要将日志输出到文件或远程服务,只需修改 CalculatorLogger 内部实现,所有调用方无需改变。这符合开闭原则。
五、运行结果演示
运行 main.go 输出示例:
text
=== Basic Operations ===
Add(10, 5) = 15.00
Subtract(10, 5) = 5.00
Multiply(6, 7) = 42.00
=== Error Handling ===
Divide(10, 0) error: division by zero
Divide(10, 2) = 5.00
=== Calculator with Logger ===
[LOG] Add(10.00, 5.00) -> 15.00
[LOG] Divide(10.00, 0.00) -> error: division by zero
=== Advanced Operations ===
SquareRoot(16) = 4.00
SquareRoot(-4) error: negative square root: -4.00
Power(1e200, 1e200) error: overflow
=== Panic Recovery ===
Factorial(5) = 120
Factorial(25) error: invalid factorial: recovered from panic: factorial 25 is too large
Factorial(-3) error: invalid factorial: n must be non-negative, got -3
=== Type Checking ===
Type: *SafeCalculator
Implements Calculator
Implements AdvancedCalculator
It's a SafeCalculator
运行单元测试:
bash
$ go test -v
=== RUN TestBasicOperations
--- PASS: TestBasicOperations (0.00s)
...
PASS
六、总结
通过这个练习,我们系统地实践了:
-
接口定义与嵌入:构造清晰的 API 层次。
-
错误处理 :自定义错误变量、错误包装与
errors.Is。 -
Panic/Recover:将不可恢复的 panic 转换为可控的错误返回。
-
装饰器模式:无侵入地增加日志功能。
-
类型断言与类型开关:动态检查接口实现。
-
单元测试 :使用
testing包保证代码质量。
这些技巧在日常 Go 开发中非常常用,希望本文能帮助你牢固掌握。完整的代码可以访问我的 study-go/second/test3 at master · Yao-2005/study-go。
思考题 :你能为 CalculatorLogger 增加性能统计(计算耗时)功能吗?试试看!