100 个 Go 错误以及如何避免:1~4

协议:CC BY-NC-SA 4.0

译者:飞龙

本文来自【OpenDocCN 饱和式翻译计划】,采用译后编辑(MTPE)流程来尽可能提升效率。

真相一旦入眼,你就再也无法视而不见。——《黑客帝国》

一、GO:学起来简单,但很难掌握

本章涵盖

  • 是什么让 Go 成为一门高效、可扩展和多产的语言
  • 探究为什么GO简单易学却难精通
  • 展示开发人员常见的错误类型

犯错是每个人生活的一部分。正如阿尔伯特·爱因斯坦曾经说过的,

一个从未犯过错误的人也从未尝试过新事物。

最终重要的不是我们犯了多少错误,而是我们从错误中学习的能力。这个断言也适用于编程。我们在一门语言中获得的资历并不是一个神奇的过程;它包括犯许多错误,并从中吸取教训。这本书的目的就是围绕这个想法。它将帮助你,读者,成为一个更熟练的 Go 开发者,通过观察和学习人们在语言的许多领域中犯的 100 个常见错误。

这一章快速回顾了为什么GO这么多年来成为主流。我们将讨论为什么尽管GO被认为简单易学,但掌握它的细微差别却很有挑战性。最后,我们将介绍本书涵盖的概念。

1.1 Go 大纲

如果你正在读这本书,很可能你已经爱上了 Go。因此,本节提供了一个简短的提示,是什么让 Go 成为如此强大的语言。

在过去的几十年里,软件工程有了长足的发展。大多数现代系统不再是由一个人编写的,而是由多个程序员组成的团队编写的——有时甚至是数百人,如果不是数千人的话。如今,代码必须具有可读性、表达性和可维护性,以保证系统的持久性。同时,在我们这个快速发展的世界中,最大限度地提高灵活性和缩短上市时间对于大多数组织来说至关重要。编程也应该遵循这一趋势,公司努力确保软件工程师在阅读、编写和维护代码时尽可能地高效。

为了应对这些挑战,谷歌在 2007 年创建了 Go 编程语言。从那时起,许多组织已经采用这种语言来支持各种用例:API、自动化、数据库、CLI(命令行界面)等等。今天许多人认为 Go 是云的语言。

就特性而言,Go 没有类型继承、没有异常、没有宏、没有部分函数、不支持惰性变量求值或不变性、没有运算符重载、没有模式匹配等等。为什么语言中缺少这些特性?官方的 Go FAQ (go.dev/doc/faq)给了我们一些启示:

为什么 Go 没有特征 X?您最喜欢的功能可能会丢失,因为它不合适,因为它影响编译速度或设计的清晰度,或者因为它会使基本的系统模型太难。

通过特性的数量来判断编程语言的质量可能不是一个准确的标准。至少,这不是GO的目标。相反,当组织大规模采用一种语言时,Go 利用了一些基本特征。其中包括以下内容:

  • 稳定性——虽然 Go 经常更新(包括改进和安全补丁),但它仍然是一种稳定的语言。有些人甚至认为这是这门语言最好的特性之一。

  • 表现性——我们可以通过我们如何自然和直观地编写和读取代码来定义编程语言中的表现性。数量减少的关键字和解决常见问题的有限方法使 Go 成为大型代码库的一种表达性语言。

  • 编译——作为开发人员,还有什么比等待构建来测试我们的应用更让人恼火的呢?快速编译一直是语言设计者有意识的目标。这反过来又提高了生产率。

  • 安全 ——Go 是一种强大的静态类型语言。因此,它有严格的编译时规则,确保代码在大多数情况下是类型安全的。

Go 是从底层开始构建的,具有可靠的特性,比如具有 goroutines 和通道的出色的并发原语。不太需要依赖外部库来构建高效的并发应用。观察并发性在这些日子里是多么重要,也证明了为什么 Go 对于现在和可预见的将来都是如此合适的语言。

一些人也认为 Go 是一种简单的语言。从某种意义上说,这并不一定是错的。例如,一个新手可以在不到一天的时间里学会这门语言的主要特征。那么,如果GO很简单,为什么要读一本以错误概念为中心的书呢?

1.2 简单并不意味着容易

简单和容易是有细微差别的。简单,应用于一项技术,意思是学习或理解起来不复杂。然而,容易意味着我们不需要太多努力就可以实现任何事情。GO学起来简单,但不一定容易掌握。

让我们以并发性为例。2019 年,一项专注于并发 bug 的研究发表了:“理解 Go 中真实世界的并发 bug。¹” 这项研究是首次对并发 bug 的系统分析。它关注多个流行的 Go 存储库,比如 Docker、gRPC 和 Kubernetes。这项研究中最重要的一点是,大多数阻塞错误都是由通过通道的消息传递范式的不正确使用引起的,尽管人们认为消息传递比共享内存更容易处理,更不容易出错。

对于这样的外卖,应该有什么合适的反应?我们应该认为语言设计者在消息传递方面是错误的吗?我们是否应该重新考虑如何处理项目中的并发性?当然不是。

这不是一个对抗信息传递和共享内存并决定谁是赢家的问题。然而,作为 Go 开发人员,我们需要彻底了解如何使用并发性,它对现代处理器的影响,何时支持一种方法,以及如何避免常见的陷阱。这个例子强调了虽然像通道和 goroutines 这样的概念很容易学习,但在实践中却不是一个容易的话题。

这个主题——简单并不意味着容易——可以推广到 Go 的许多方面,而不仅仅是并发性。因此,要成为精通GO的开发者,我们必须对这门语言的许多方面有透彻的理解,这需要时间、精力和错误。

这本书旨在通过深入研究 100 个 Go 错误来帮助我们加速迈向熟练的旅程。

1.3 100 个 Go 错误

我们为什么要读一本关于常见GO错误的书?为什么不用一本挖掘不同主题的普通书来加深我们的知识呢?

在 2011 年的一篇文章中,神经科学家证明了大脑生长的最佳时间是我们面临错误的时候。我们都经历过从一个错误中学习的过程,并且在几个月甚至几年后回忆起那个事件,当一些背景与它相关时?正如珍妮特·梅特卡夫(Janet Metcalfe)在另一篇文章中介绍的那样,这种情况的发生是因为错误具有促进效应。主要意思是我们不仅能记住错误,还能记住错误周围的上下文。这是从错误中学习如此高效的原因之一。

为了加强这种促进作用,本书尽可能多地用真实世界的例子来说明每个错误。这本书不仅仅是关于理论;它还帮助我们更好地避免错误,做出更明智、更有意识的决策,因为我们现在理解了它们背后的基本原理。

告诉我,我会忘记。教我,我会记住。让我参与进来,我会学到东西。

——未知

这本书提出了七大类错误。总的来说,这些错误可以归类为

  • BUG

  • 不必要的复杂

  • 可读性较弱

  • 次优或不完善的组织

  • 缺乏 API 便利性

  • 优化不足的代码

  • 缺乏生产力

接下来我们介绍每一个错误类别。

1.3.1 错误

第一种错误可能也是最明显的错误是软件错误。2020 年,Synopsys 进行的一项研究估计,仅在美国,软件错误的成本就超过 2 万亿美元⁴。

此外,错误还会导致悲剧性的影响。例如,我们可以提到加拿大原子能有限公司(AECL)生产的 Therac-25 放射治疗机。由于比赛条件,这台机器给病人的辐射剂量超过预期数百倍,导致三名病人死亡。因此,软件错误不仅仅是钱的问题。作为开发人员,我们应该记住我们的工作是多么有影响力。

这本书涵盖了大量可能导致各种软件错误的案例,包括数据竞争、泄漏、逻辑错误和其他缺陷。虽然准确的测试应该是尽早发现这类 bug 的一种方式,但我们有时可能会因为时间限制或复杂性等不同因素而错过案例。因此,作为一名 Go 开发者,确保我们避免常见的错误是至关重要的。

1.3.2 不必要的复杂性

下一类错误与不必要的复杂性有关。软件复杂性的一个重要部分来自于这样一个事实,即作为开发人员,我们努力思考想象中的未来。与其现在就解决具体的问题,不如构建进化的软件来解决未来出现的任何用例。然而,在大多数情况下,这样做弊大于利,因为这会使代码库变得更加复杂,难以理解和推理。

回到过去,我们可以想到许多用例,在这些用例中,开发人员可能倾向于为未来需求设计抽象,比如接口或泛型。这本书讨论了我们应该小心不要用不必要的复杂性伤害代码库的主题。

1.3.3 可读性较弱

另一种错误是削弱可读性。正如 Robert C. Martin 在他的书《Clean Code:A Handbook of Agile Software crafts》中所写的,花在阅读和写作上的时间比远远超过 10 比 1。我们大多数人开始在可读性不那么重要的单独项目上编程。然而,今天的软件工程是有时间维度的编程:确保我们在几个月、几年,甚至几十年后仍然可以使用和维护应用。

在用 Go 编程时,我们可能会犯很多会损害可读性的错误。这些错误可能包括嵌套代码、数据类型表示,或者在某些情况下没有使用命名结果参数。通过这本书,我们将学习如何编写可读的代码,并关心未来的读者(包括我们未来的自己)。

1.3.4 次优或不适应的组织

无论是在进行一个新项目时,还是因为我们获得了不准确的反应,另一种错误是次优地和单向地组织我们的代码和项目。这样的问题会使项目更难推理和维护。这本书涵盖了GO中的一些常见错误。例如,我们将了解如何构建一个项目,以及如何处理实用工具包或init函数。总之,查看这些错误应该有助于我们更有效、更习惯地组织我们的代码和项目。

1.3.5 缺乏 API 便利性

另一种类型的错误是犯一些削弱 API 对客户的便利性的常见错误。如果一个 API 不是用户友好的,它将缺乏表现力,因此更难理解,更容易出错。

我们可以考虑许多情况,比如过度使用any类型,使用错误的创建模式来处理选项,或者盲目应用影响我们 API 可用性的面向对象编程的标准实践。这本书涵盖了一些常见的错误,这些错误阻止我们向用户公开方便的 API。

1.3.6 优化不足的代码

优化不足的代码是开发人员犯的另一种错误。这可能是由于各种原因造成的,比如不理解语言特征,甚至缺乏基础知识。性能是这个错误最明显的影响之一,但不是唯一的。

我们可以考虑为其他目标优化代码,比如准确性。例如,这本书提供了一些确保浮点运算准确的常用技术。与此同时,我们将讨论大量可能对性能代码产生负面影响的情况,例如,由于并行化执行不佳,不知道如何减少分配,或者数据对齐的影响。我们将通过不同的棱镜解决优化问题。

1.3.7 缺乏生产力

在大多数情况下,当我们着手一个新项目时,我们能选择的最佳语言是什么?我们工作效率最高的一个。熟悉一门语言的工作方式并充分利用它是达到熟练的关键。

在本书中,我们将介绍许多案例和具体的例子,这些案例和例子将帮助我们在 Go 中工作时更有效率。例如,我们将着眼于编写高效的测试来确保我们的代码工作,依靠标准库来提高效率,并充分利用分析工具和 linters。现在,是时候深入研究这 100 个常见的GO错误了。

总结

  • Go 是一种现代编程语言,能够提高开发人员的工作效率,这对于当今大多数公司来说至关重要。

  • GO学起来简单,但不容易掌握。这就是为什么我们需要加深我们的知识来最有效地使用语言。

  • 通过错误和具体的例子来学习是精通一门语言的有效方法。这本书将通过探究 100 个常见错误来加快我们的熟练程度。


¹ T. Tu,X. Liu 等,“理解 Go 中真实世界的并发 bug”,发表于 2019 年 4 月 13 日-17 日的 ASPLOS 2019。

J. S. Moser,H. S. Schroder 等人,“注意你的错误:将成长心态与适应性后错误调整联系起来的神经机制的证据”,《心理科学》,第 22 卷,第 12 期,第 1484-1489 页,2011 年 12 月。

³ J. Metcalfe,“从错误中学习”,《心理学年度评论》,第 68 卷,第 465–489 页,2017 年 1 月。

⁴ Synopsys,“美国软件质量差的代价:2020 年报告。”2020. news.synopsys.com/2021-01-06-Synopsys-Sponsored-CISQ-Research-Estimates-Cost-of-Poor-Software-Quality-in-the-US-2-08-Trillion-in-2020

R. C. Martin,《干净的代码:敏捷软件工艺手册》。普伦蒂斯霍尔,2008 年。

二、代码和项目组织

本章涵盖

  • 习惯性地组织我们的代码
  • 有效处理抽象:接口和泛型
  • 关于如何构建项目的最佳实践

以一种干净、惯用和可维护的方式组织 Go 代码库并不是一件容易的事情。理解所有与代码和项目组织相关的最佳实践需要经验,甚至是错误。要避免哪些陷阱(例如,变量隐藏和嵌套代码滥用)?我们如何构造包?我们何时何地使用接口或泛型、init函数和实用工具包?在这一章中,我们检查常见的组织错误。

2.1 #1:意外的变量隐藏

变量的作用域指的是变量可以被引用的地方:换句话说,就是应用中名字绑定有效的部分。在 Go 中,块中声明的变量名可以在内部块中重新声明。这个原理叫做变量隐藏,容易出现常见错误。

以下示例显示了由于隐藏变量而产生的意外副作用。它以两种不同的方式创建 HTTP 客户端,这取决于一个tracing布尔值:

var client *http.Client                          // ❶
if tracing {
    client, err := createClientWithTracing()     // ❷
    if err != nil {
        return err
    }
    log.Println(client)
} else {
    client, err := createDefaultClient()         // ❸
    if err != nil {
        return err
    }
    log.Println(client)
}
// Use client

❶ 声明了一个client变量

❷ 创建一个启用了跟踪的 HTTP 客户端。(client变量在此块中被隐藏。)

❸ 创建一个默认的 HTTP 客户端。(client变量在这个块中也被隐藏。)

在这个例子中,我们首先声明一个client变量。然后,我们在两个内部块中使用短变量声明操作符(:=)将函数调用的结果分配给内部client变量——而不是外部变量。因此,外部变量总是nil

注意这段代码会编译,因为内部的client变量会在日志调用中使用。如果没有,我们就会出现client declared and not used等编译错误。

我们如何确保给原始的client变量赋值呢?有两种不同的选择。

第一个选项以这种方式在内部块中使用临时变量:

var client *http.Client
if tracing {
    c, err := createClientWithTracing()    // ❶
    if err != nil {
        return err
    }
    client = c                             // ❷
} else {
    // Same logic
}

❶ 创建了一个临时变量c

❷ 将这个临时变量分配给client

这里,我们将结果赋给一个临时变量c,它的范围只在if块内。然后,我们将它赋回给client变量。同时,我们对else部分做同样的工作。

第二个选项使用内部程序块中的赋值运算符(=)将函数结果直接赋给client变量。然而,这需要创建一个error变量,因为赋值操作符只有在已经声明了变量名的情况下才起作用。例如:

var client *http.Client
var err error                                  // ❶
if tracing {
    client, err = createClientWithTracing()    // ❷
    if err != nil {
        return err
    }
} else {
    // Same logic
}

❶ 声明了一个err变量

❷ 使用赋值操作符给*http赋值。客户端直接返回到client变量

不用先赋给一个临时变量,我们可以直接把结果赋给client

两种选择都完全有效。这两个选项之间的主要区别是,我们在第二个选项中只执行一个赋值,这可能被认为更容易阅读。同样,使用第二个选项,我们可以在if / else语句之外共同化和实现错误处理,如下例所示:

if tracing {
    client, err = createClientWithTracing()
} else {
    client, err = createDefaultClient()
}
if err != nil {
    // Common error handling
}

当在内部块中重新声明变量名时,会出现变量隐藏,但是我们看到这种做法容易出错。强加一个禁止隐藏变量的规则取决于个人喜好。例如,有时重用现有的变量名(如err)来处理错误会很方便。然而,总的来说,我们应该保持谨慎,因为我们现在知道我们可能会面临这样的场景:代码可以编译,但是接收值的变量不是预期的变量。在本章的后面,我们还将看到如何检测隐藏变量,这可能有助于我们发现可能的错误。

下一节展示了避免滥用嵌套代码的重要性。

2.2 #2:不必要的嵌套代码

应用于软件的心智模型是系统行为的内部表示。在编程时,我们需要维护心智模型(例如,关于整体代码交互和功能实现)。基于多种标准,如命名、一致性、格式等,代码被限定为可读的。可读代码需要较少的认知努力来维护心智模型;因此,它更容易阅读和维护。

可读性的一个重要方面是嵌套层次的数量。让我们做一个练习。假设我们正在进行一个新项目,需要理解下面的join函数是做什么的:

func join(s1, s2 string, max int) (string, error) {
    if s1 == "" {
        return "", errors.New("s1 is empty")
    } else {
        if s2 == "" {
            return "", errors.New("s2 is empty")
        } else {
            concat, err := concatenate(s1, s2)     // ❶
            if err != nil {
                return "", err
            } else {
                if len(concat) > max {
                    return concat[:max], nil
                } else {
                    return concat, nil
                }
            }
        }
    }
}

func concatenate(s1 string, s2 string) (string, error) {
    // ...
}

❶ 调用concatenate函数来执行某些特定的连接,但可能会返回错误

这个join函数连接两个字符串,如果长度大于max,则返回一个子字符串。同时,它处理对s1s2的检查,以及对concatenate的调用是否返回错误。

从实现的角度来看,这个函数是正确的。然而,建立一个包含所有不同情况的心智模型可能不是一件简单的任务。为什么?因为嵌套层次的数量。

现在,让我们使用相同的函数,但以不同的方式再次尝试这个练习:

func join(s1, s2 string, max int) (string, error) {
    if s1 == "" {
        return "", errors.New("s1 is empty")
    }
    if s2 == "" {
        return "", errors.New("s2 is empty")
    }
    concat, err := concatenate(s1, s2)
    if err != nil {
        return "", err
    }
    if len(concat) > max {
        return concat[:max], nil
    }
    return concat, nil
}

func concatenate(s1 string, s2 string) (string, error) {
    // ...
}

你可能已经注意到,尽管做着和以前一样的工作,但构建这个新版本的心智模型需要的认知负荷更少。这里我们只维护两个嵌套层次。正如 Mat Ryer 在 Go Time 播客(medium.com/@matryer/line-of-sight-in-code-186dd7cdea88)中提到的:

向左对齐幸福路径;您应该很快能够向下扫描一列,以查看预期的执行流。

由于嵌套的if / else语句,在第一个版本中很难区分预期的执行流。相反,第二个版本需要向下扫描一列来查看预期的执行流,向下扫描第二列来查看边缘情况是如何处理的,如图 2.1 所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jLhqM96X-1684395314253)(…/…/OEBPS/Images/CH02_F01_Harsanyi.png)]

图 2.1 为了理解预期的执行流程,我们只需浏览一下快乐路径列。

一般来说,函数需要的嵌套层次越多,阅读和理解起来就越复杂。让我们看看这条规则的一些不同应用,以优化我们的代码可读性:

  • 当一个if块返回时,我们应该在所有情况下省略else块。例如,我们不应该写

    if foo() {
        // ...
        return true
    } else {
        // ...
    }
    

    相反,我们像这样省略了else块:

    if foo() {
        // ...
        return true
    }
    // ...
    

    在这个新版本中,先前在else块中的代码被移到顶层,使其更容易阅读。

  • 我们也可以沿着这个逻辑走一条不快乐的路:

    if s != "" {
        // ...
    } else {
        return errors.New("empty string")
    }
    

    这里,空的s代表非快乐路径。因此,我们应该像这样翻转条件:

    if s == "" {                           // ❶
        return errors.New("empty string")
    }
    // ...
    

    ❶翻转了if条件

    这个新版本更容易阅读,因为它将快乐路径保留在左边,并减少了块数。

编写可读的代码对每个开发人员来说都是一个重要的挑战。努力减少嵌套块的数量,将快乐路径放在左边,尽可能早地返回,这些都是提高代码可读性的具体方法。

在下一节中,我们将讨论 Go 项目中一个常见的误用:init函数。

2.3 #3:误用init函数

有时我们会在 Go 应用中误用init函数。潜在的后果是糟糕的错误管理或更难理解的代码流。让我们重温一下什么是init函数。然后,我们将会看到它的用法是否被推荐。

2.3.1 概念

init函数是用于初始化应用状态的函数。它不接受任何参数,也不返回任何结果(一个func()函数)。当一个包被初始化时,包中所有的常量和变量声明都会被求值。然后,执行init函数。下面是一个初始化main包的例子:

package main

import "fmt"

var a = func() int {
    fmt.Println("var")        // ❶
    return 0
}()

func init() {
    fmt.Println("init")       // ❷
}

func main() {
    fmt.Println("main")       // ❸
}

❶ 首先被执行

❷ 其次被执行

❸ 最后被执行

运行此示例将打印以下输出:

var
init
main

初始化软件包时会执行init函数。在下面的例子中,我们定义了两个包,mainredis,其中main依赖于redis。首先,主要的。从main包开始:

package main

import (
    "fmt"

    "redis"
)

func init() {
    // ...
}

func main() {
    err := redis.Store("foo", "bar")    // ❶
    // ...
}

❶ 依赖于redis实现

然后从redis包中redis.go:

package redis

// imports

func init() {
    // ...
}

func Store(key, value string) error {
    // ...
}

因为main依赖于redis,所以首先执行redis包的init函数,然后是main包的init,然后是的main函数本身。图 2.2 显示了这个顺序。

我们可以为每个包定义多个init函数。当我们这样做时,包内init函数的执行顺序是基于源文件的字母顺序。例如,如果一个包包含一个a.go文件和一个b.go文件,并且这两个文件都有一个init函数,则首先执行a.go init函数。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0T7jwTdA-1684395314255)(…/…/OEBPS/Images/CH02_F02_Harsanyi.png)]

图 2.2 首先执行redis包的init函数,然后是maininit函数,最后是的main函数。

我们不应该依赖包中init函数的排序。事实上,这可能很危险,因为源文件可能会被重命名,从而潜在地影响执行顺序。

我们也可以在同一个源文件中定义多个init函数。例如,这段代码完全有效:

package main

import "fmt"

func init() {               // ❶
    fmt.Println("init 1")
}

func init() {               // ❷
    fmt.Println("init 2")
}

func main() {
}

❶ 第一个init函数

❷ 第二个init函数

执行的第一个init函数是源代码顺序中的第一个。以下是输出结果:

init 1
init 2

我们也可以使用init函数来产生副作用。在下一个例子中,我们定义了一个main包,它对foo没有很强的依赖性(例如,没有直接使用公共函数)。然而,这个例子需要初始化foo包。我们可以这样使用_操作符:

package main

import (
    "fmt"

    _ "foo"    // ❶
)

func main() {
    // ...
}

❶ 导入foo有副作用

在这种情况下,foo包在main之前初始化。因此,执行fooinit函数。

init函数的另一个特点是它不能被直接调用,如下例所示:

package main

func init() {}

func main() {
    init()       // ❶
}

❶ 无效引用

这段代码会产生以下编译错误:

$ go build .
./main.go:6:2: undefined: init

既然我们已经了解了init函数是如何工作的,那么让我们看看什么时候应该使用或者不使用它们。下一节将对此进行阐述。

2.3.2 何时使用init函数

首先,让我们看一个使用init函数被认为不合适的例子:持有数据库连接池。在示例中的init函数中,我们使用sql.Open打开一个数据库。我们使这个数据库成为一个全局变量,其他函数以后可以使用:

var db *sql.DB

func init() {
    dataSourceName :=
        os.Getenv("MYSQL_DATA_SOURCE_NAME")       // ❶
    d, err := sql.Open("mysql", dataSourceName)
    if err != nil {
        log.Panic(err)
    }
    err = d.Ping()
    if err != nil {
        log.Panic(err)
    }
    db = d                                        // ❷
}

❶ 环境变量

❷ 将数据库连接分配给全局db变量

在本例中,我们打开数据库,检查是否可以 ping 它,然后将它赋给全局变量。我们应该如何看待这个实现?让我们描述三个主要的缺点。

首先,init函数中的错误管理是有限的。事实上,由于init函数不返回错误,发出错误信号的唯一方式就是恐慌,导致应用停止。在我们的例子中,如果打开数据库失败,无论如何停止应用也是可以的。然而,不应该由包本身来决定是否停止应用。也许调用者可能更喜欢实现重试或使用回退机制。在这种情况下,在init函数中打开数据库会阻止客户端包实现它们的错误处理逻辑。

另一个重要的缺点与测试有关。如果我们向这个文件添加测试,init函数将在运行测试用例之前执行,这不一定是我们想要的(例如,如果我们在一个不需要创建这个连接的实用函数上添加单元测试)。因此,本例中的init函数使编写单元测试变得复杂。

最后一个缺点是,该示例要求将数据库连接池分配给一个全局变量。全局变量有一些严重的缺点;例如:

  • 任何函数都可以改变包内的全局变量。

  • 单元测试可能会更复杂,因为依赖于全局变量的函数不再是孤立的。

在大多数情况下,我们应该倾向于封装一个变量,而不是保持它的全局。

出于这些原因,之前的初始化可能应该作为普通旧函数的一部分来处理,如下所示:

func createClient(dsn string) (*sql.DB, error) {    // ❶
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err                             // ❷
    }
    if err = db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

❶ 接受数据源名称并返回一个*sql.DB和一个错误

❷ 返回一个错误

使用这个函数,我们解决了前面讨论的主要缺点。方法如下:

  • 错误处理的责任留给了调用者。

  • 可以创建一个集成测试来检查该函数是否有效。

  • 连接池封装在函数中。

有必要不惜一切代价避免init函数吗?不完全是。在一些用例中,init函数仍然是有用的。例如,官方的 Go 博客(mng.bz/PW6w)使用init函数来设置静态 HTTP 配置:

func init() {
    redirect := func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, "/", http.StatusFound)
    }
    http.HandleFunc("/blog", redirect)
    http.HandleFunc("/blog/", redirect)

    static := http.FileServer(http.Dir("static"))
    http.Handle("/favicon.ico", static)
    http.Handle("/fonts.css", static)
    http.Handle("/fonts/", static)

    http.Handle("/lib/godoc/", http.StripPrefix("/lib/godoc/",
        http.HandlerFunc(staticHandler)))
}

在这个例子中,init函数不会失败(http.HandleFunc可能会恐慌,但只有在处理器是nil的情况下才会恐慌,但这里的情况不是这样)。同时,不需要创建任何全局变量,该函数不会影响可能的单元测试。因此,这个代码片段提供了一个很好的例子,说明了init函数的用处。总之,我们看到init函数会导致一些问题:

  • 他们可以限制错误管理。

  • 它们会使如何实现测试变得复杂(例如,必须建立一个外部依赖,这对于单元测试的范围来说可能是不必要的)。

  • 如果初始化需要我们设置一个状态,那必须通过全局变量来完成。

我们应该谨慎使用init函数。然而,在某些情况下,它们会很有帮助,比如定义静态配置,正如我们在本节中看到的。否则,在大多数情况下,我们应该通过特殊函数来处理初始化。

2.4 #4:过度使用获取器和设置器

在编程中,数据封装是指隐藏一个对象的值或状态。获取器和设置器是通过在未导出的对象字段上提供导出的方法来启用封装的方法。

在 Go 中,没有像我们在一些语言中看到的那样自动支持获取器和设置器。使用获取器和设置器来访问结构字段也被认为既不强制也不习惯。例如,标准库实现了这样的结构,其中一些字段可以直接访问,例如作为time.Timer结构:

timer := time.NewTimer(time.Second)
<-timer.C                             // ❶

❶ C 是一个<–chan Time字段

尽管不推荐,我们甚至可以直接修改C(但是我们不会再接收事件了)。然而,这个例子说明了标准的 Go 库并不强制使用获取器和/或设置器,即使我们不应该修改一个字段。

另一方面,使用获取器和设置器有一些优点,包括:

  • 它们封装了与获取或设置字段相关的行为,允许以后添加新功能(例如,验证字段、返回计算值或用互斥体包装对字段的访问)。

  • 它们隐藏了内部表现,让我们在展示时更加灵活。

  • 它们为运行时属性的改变提供了一个调试拦截点,使得调试更加容易。

如果我们陷入这些情况或者预见到一个可能的用例,同时保证向前兼容,使用获取器和设置器可以带来一些价值。例如,如果我们将它们用于一个名为balance的字段,我们应该遵循这些命名约定:

  • 获取器方法应该命名为Balance(不是GetBalance)。

  • 设置器方法应该命名为SetBalance

这里有一个例子:

currentBalance := customer.Balance()     // ❶
if currentBalance < 0 {
    customer.SetBalance(0)               // ❷
}

❶ 获取器

❷ 设置器

总之,如果结构上的获取器和设置器没有带来任何价值,我们就不应该用它们来淹没我们的代码。我们应该务实,努力在效率和遵循习惯用法之间找到正确的平衡,这些习惯用法在其他编程范例中有时被认为是无可争议的。

请记住,Go 是一种独特的语言,它具有许多特性,包括简单性。然而,如果我们发现需要获取器和设置器,或者,如前所述,在保证向前兼容性的同时预见到未来的需要,使用它们没有任何问题。

接下来,我们将讨论过度使用接口的问题。

2.5 #5:接口污染

在设计和构建我们的代码时,接口是 Go 语言的基石之一。然而,像许多工具或概念一样,滥用它们通常不是一个好主意。接口污染就是用不必要的抽象来淹没我们的代码,使代码更难理解。这是来自不同习惯的另一种语言的开发人员经常犯的错误。在深入探讨这个话题之前,我们先来回顾一下 Go 的接口。然后,我们将看到什么时候使用接口是合适的,什么时候它可能被认为是污染。

2.5.1 概念

接口提供了一种指定对象行为的方式。我们使用接口来创建多个对象可以实现的公共抽象。使 Go 接口如此不同的是它们被隐式地满足了。没有像implements这样明确的关键字来标记一个对象X实现了接口Y

为了理解是什么让接口如此强大,我们将从标准库中挖掘两个流行的接口:io.Readerio.Writerio包为 I/O 原语提供了抽象。在这些抽象中,io.Reader与从数据源读取数据有关,io.Writer与向目标写入数据有关,如图 2.3 所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-50NZjSB4-1684395314255)(…/…/OEBPS/Images/CH02_F03_Harsanyi.png)]

图 2.3 io.Reader从数据源读取并填充一个字节切片,而io.Writer从一个字节切片写入目标。

io.Reader包含一个单个Read方法:

type Reader interface {
    Read(p []byte) (n int, err error)
}

接口的定制实现应该接受一个字节切片,用它的数据填充它,并返回读取的字节数或一个错误。

另一方面,io.Writer定义了单个方法,Write:

type Writer interface {
    Write(p []byte) (n int, err error)
}

io.Writer的定制实现应该将来自一个片的数据写入一个目标,并返回写入的字节数或一个错误。因此,这两个接口都提供了基本的抽象:

  • io.Reader从数据源读取数据。

  • io.Writer将数据写入目标。

语言中有这两个接口的基本原理是什么?创建这些抽象的目的是什么?

假设我们需要实现一个将一个文件的内容复制到另一个文件的函数。我们可以创建一个特定的函数,将两个*os.File作为输入。或者,我们可以选择使用io.Readerio.Writer抽象来创建一个更加通用的函数:

func copySourceToDest(source io.Reader, dest io.Writer) error {
    // ...
}

这个函数将与*os.File参数一起工作(因为*os.File实现了io.Readerio.Writer)以及实现这些接口的任何其他类型。例如,我们可以创建自己的写入数据库的io.Writer,而代码保持不变。它增加了函数的通用性;因此,它的可重用性。

此外,为这个函数编写单元测试更加容易,因为我们可以使用stringsbytes包,而提供了有用的实现,而不是处理文件:

func TestCopySourceToDest(t *testing.T) {
    const input = "foo"
    source := strings.NewReader(input)            // ❶
    dest := bytes.NewBuffer(make([]byte, 0))      // ❷

    err := copySourceToDest(source, dest)         // ❸
    if err != nil {
        t.FailNow()
    }

    got := dest.String()
    if got != input {
        t.Errorf("expected: %s, got: %s", input, got)
    }
}

❶ 创建了一个io.Reader

❷ 创建了一个io.Writer

❸ 从*stringsio.Readerio.Writer调用copySourceToDest

在本例中,source是一个*strings.Reader,而dest是一个*bytes.Buffer。这里,我们在不创建任何文件的情况下测试copySourceToDest的行为。

在设计接口时,粒度(接口包含多少方法)也是需要记住的。Go (www.youtube.com/watch?v=PAAkCSZUG1c&t=318s)中一个众所周知的谚语与一个接口应该有多大有关:

接口越大,抽象越弱。

——罗布·派克

事实上,向接口添加方法会降低接口的可重用性。io.Readerio.Writer是强大的抽象,因为它们不能再简单了。此外,我们还可以结合细粒度的接口来创建更高级别的抽象。io.ReadWriter就是这种情况,它结合了读者和作者的行为:

type ReadWriter interface {
    Reader
    Writer
}

注正如爱因斯坦所说,“一切都应该尽可能简单,但不能再简单了。”应用于接口,这意味着找到接口的完美粒度不一定是一个简单的过程。

现在让我们讨论推荐接口的常见情况。

2.5.2 何时使用接口

我们应该什么时候在 Go 中创建接口?让我们看三个具体的用例,在这些用例中,接口通常被认为是带来价值的。请注意,我们的目标并不是详尽无遗的,因为我们添加的案例越多,它们就越依赖于上下文。然而,这三个案例应该给我们一个大致的概念:

  • 普通行为

  • 解耦

  • 限制行为

普通行为

我们将讨论的第一个选项是当多个类型实现一个公共行为时使用接口。在这种情况下,我们可以分析出接口内部的行为。如果我们看看标准库,我们可以找到许多这样的用例的例子。例如,可以通过三种方法对集合进行排序:

  • 检索集合中元素的数量

  • 报告一个元素是否必须在另一个元素之前排序

  • 交换两个元素

因此,以下接口被添加到sort包中:

type Interface interface {
    Len() int               // ❶
    Less(i, j int) bool     // ❷
    Swap(i, j int)          // ❸
}

元素的❶数

❷ 检查了两个要素

❸ 互换了两个元素

这个接口具有很强的可重用性,因为它包含了对任何基于索引的集合进行排序的通用行为。

纵观sort包,我们可以找到几十个实现。例如,如果在某个时候我们计算了一个整数集合,并且我们想对它进行排序,我们有必要对实现类型感兴趣吗?排序算法是归并排序还是快速排序重要吗?很多时候,我们并不在意。因此,排序行为可以被抽象出来,我们可以依赖于sort.Interface

找到正确的抽象来分解行为也可以带来很多好处。例如,sort包提供了同样依赖于sort.Interface的实用函数,比如检查集合是否已经排序。举个例子,

func IsSorted(data Interface) bool {
    n := data.Len()
    for i := n - 1; i > 0; i-- {
        if data.Less(i, i-1) {
            return false
        }
    }
    return true
}

因为sort.Interface是正确的抽象层次,所以它非常有价值。

现在让我们看看使用接口的另一个主要用例。

退耦

另一个重要的用例是关于从实现中分离我们的代码。如果我们依赖一个抽象而不是一个具体的实现,实现本身可以被另一个代替,甚至不需要改变我们的代码。这就是利斯科夫替代原理(Robert C. Martin 的 SOLID 设计原理中的 L)。

解耦的一个好处与单元测试有关。让我们假设我们想要实现一个CreateNewCustomer方法来创建一个新客户并存储它。我们决定直接依赖于具体的实现(比如说一个mysql.Store结构):

type CustomerService struct {
    store mysql.Store          // ❶
}

func (cs CustomerService) CreateNewCustomer(id string) error {
    customer := Customer{id: id}
    return cs.store.StoreCustomer(customer)
}

❶ 取决于具体的实现

现在,如果我们想测试这个方法呢?因为customerService依赖于实际的实现来存储一个Customer,我们不得不通过集成测试来测试它,这需要构建一个 MySQL 实例(除非我们使用另一种技术,比如go-sqlmock,但这不是本节的范围)。尽管集成测试很有帮助,但这并不总是我们想要做的。为了给我们更多的灵活性,我们应该将CustomerService从实际的实现中分离出来,这可以通过这样的接口来实现:

type customerStorer interface {      // ❶
    StoreCustomer(Customer) error
}

type CustomerService struct {
    storer customerStorer            // ❷
}

func (cs CustomerService) CreateNewCustomer(id string) error {
    customer := Customer{id: id}
    return cs.storer.StoreCustomer(customer)
}

❶ 创建了存储抽象

❷ 将客户服务从实际实现中分离出来

因为存储一个客户现在是通过一个接口完成的,这给了我们更多的灵活性来测试这个方法。例如,我们可以

  • 通过集成测试使用具体实现

  • 通过单元测试使用模拟(或任何类型的双测试)

  • 或者两者都有

现在让我们讨论另一个用例:限制一个行为。

限制行为

我们将讨论的最后一个用例乍一看可能非常违反直觉。它是关于将一个类型限制到一个特定的行为。假设我们实现了一个定制的配置包来处理动态配置。我们通过一个IntConfig结构为int配置创建一个特定的容器,该结构还公开了两个方法:GetSet。下面是代码的样子:

type IntConfig struct {
    // ...
}

func (c *IntConfig) Get() int {
    // Retrieve configuration
}

func (c *IntConfig) Set(value int) {
    // Update configuration
}

现在,假设我们收到一个IntConfig,它保存了一些特定的配置,比如一个阈值。然而,在我们的代码中,我们只对检索配置值感兴趣,并且我们希望防止更新它。如果我们不想改变我们的配置包,我们怎么能强制这个配置在语义上是只读的呢?通过创建一个抽象,将行为限制为仅检索配置值:

type intConfigGetter interface {
    Get() int
}

然后,在我们的代码中,我们可以依靠intConfigGetter而不是具体的实现:

type Foo struct {
    threshold intConfigGetter
}

func NewFoo(threshold intConfigGetter) Foo {    // ❶
    return Foo{threshold: threshold}
}

func (f Foo) Bar()  {
    threshold := f.threshold.Get()              // ❷
    // ...
}

intConfigGetter

❷ 读取配置

在这个例子中,配置获取器被注入到NewFoo工厂方法中。它不会影响这个函数的客户端,因为它仍然可以在实现intConfigGetter时传递一个IntConfig结构。然后,我们只能读取Bar方法中的配置,不能修改。因此,出于各种原因,我们也可以使用接口将类型限制为特定的行为,例如语义强制。

在本节中,我们看到了三个潜在的用例,其中接口通常被认为是有价值的:分解出一个公共行为,创建一些解耦,以及将一个类型限制到某个特定的行为。同样,这个列表并不详尽,但是它应该让我们对接口在 Go 中的作用有一个大致的了解。

现在,让我们结束这一节,讨论接口污染的问题。

2.5.3 接口污染

在 Go 项目中过度使用接口是很常见的。也许开发人员的背景是 C#或 Java,他们发现在具体类型之前创建接口是很自然的。然而,这并不是GO的工作方式。

正如我们所讨论的,接口是用来创建抽象的。当编程遇到抽象时,主要的警告是记住抽象应该被发现,而不是被创建。这是什么意思?这意味着如果没有直接的理由,我们就不应该开始在代码中创建抽象。我们不应该设计接口,而应该等待具体的需求。换句话说,我们应该在需要的时候创建接口,而不是在预见到可能需要的时候。

如果我们过度使用接口,主要问题是什么?答案是它们使代码流更加复杂。增加一个无用的间接层不会带来任何价值;它创建了一个毫无价值的抽象,使得代码更难阅读、理解和推理。如果我们没有添加接口的充分理由,并且不清楚接口如何使代码更好,我们应该质疑这个接口的用途。为什么不直接调用实现?

注意当我们通过一个接口调用一个方法时,我们也可能经历性能开销。它需要在哈希表的数据结构中查找,以找到接口指向的具体类型。但是在很多情况下这不是问题,因为开销很小。

总之,在我们的代码中创建抽象时,我们应该谨慎——抽象应该被发现,而不是被创建。对于我们这些软件开发人员来说,基于我们认为以后可能需要的东西,通过试图猜测什么是完美的抽象层次来过度工程化我们的代码是很常见的。应该避免这个过程,因为在大多数情况下,它用不必要的抽象污染了我们的代码,使其阅读起来更加复杂。

不要设计接口,去发现它们。

——抢派克

让我们不要试图抽象地解决问题,而是解决现在必须解决的问题。最后,但同样重要的是,如果不清楚一个接口如何使代码变得更好,我们可能应该考虑删除它以使我们的代码更简单。

下一节继续这个主题,并讨论一个常见的接口错误:在生成器端创建接口。

2.6 #6:生产者方面的接口

我们在上一节中看到了接口被认为是有价值的。但是 Go 开发者经常会误解一个问题:一个接口应该活在哪里?

在深入探讨这个主题之前,让我们确保我们在本节中使用的术语是清楚的:

  • 生产者端——与具体实现定义在同一个包中的接口(见图 2.4)。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HB8cn5w2-1684395314256)(…/…/OEBPS/Images/CH02_F04_Harsanyi.png)]

    图 2.4 接口是在具体实现的旁边定义的。

  • 消费者端——在使用它的外部包中定义的接口(参见图 2.5)。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Et6tl6rc-1684395314256)(…/…/OEBPS/Images/CH02_F05_Harsanyi.png)]

    图 2.5 接口是在使用的地方定义的。

常见的是,开发人员在具体实现的同时,在生产者端创建接口。这种设计可能是具有 C#或 Java 背景的开发人员的习惯。但在GO中,大多数情况下这并不是我们应该做的。

让我们讨论下面的例子。这里,我们创建一个特定的包来存储和检索客户数据。同时,仍然在同一个包中,我们决定所有的调用都必须通过以下接口:

package store

type CustomerStorage interface {
    StoreCustomer(customer Customer) error
    GetCustomer(id string) (Customer, error)
    UpdateCustomer(customer Customer) error
    GetAllCustomers() ([]Customer, error)
    GetCustomersWithoutContract() ([]Customer, error)
    GetCustomersWithNegativeBalance() ([]Customer, error)
}

我们可能认为我们有一些很好的理由在生产者端创建和公开这个接口。也许这是将客户端代码从实际实现中分离出来的好方法。或者,也许我们可以预见它将帮助客户创建测试替身。不管是什么原因,这都不是GO的最佳实践。

如前所述,接口在 Go 中是隐式满足的,与具有显式实现的语言相比,Go 往往是游戏规则的改变者。在大多数情况下,要遵循的方法类似于我们在上一节中描述的:抽象应该被发现,而不是被创建。这意味着不能由生产者来为所有客户强制一个给定的抽象。相反,由客户决定是否需要某种形式的抽象,然后确定满足其需求的最佳抽象级别。

在前面的例子中,也许一个客户端对解耦它的代码不感兴趣。也许另一个客户想要解耦它的代码,但是只对GetAllCustomers方法感兴趣。在这种情况下,这个客户机可以用一个方法创建一个接口,从外部包中引用Customer结构:

package client

type customersGetter interface {
    GetAllCustomers() ([]store.Customer, error)
}

从一个包组织,图 2.6 显示了结果。有几点需要注意:

  • 因为customersGetter接口只在client包中使用,所以可以不导出。

  • 视觉上,在图中,看起来像是循环依赖。然而,从storeclient没有依赖性,因为接口是隐式满足的。这就是为什么这种方法在具有显式实现的语言中并不总是可行的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0tpYhCdH-1684395314256)(…/…/OEBPS/Images/CH02_F06_Harsanyi.png)]

图 2.6client包通过创建自己的接口定义了它需要的抽象。

主要的一点是client包现在可以为它的需求定义最精确的抽象(这里,只有一个方法)。它涉及到接口分离原则的概念(SOLID 中的 I),该原则声明不应该强迫任何客户端依赖它不使用的方法。因此,在这种情况下,最好的方法是在生产者端公开具体的实现,让客户决定如何使用它以及是否需要抽象。

为了完整起见,让我们提一下这种方法——生产者端的接口——有时在标准库中使用。例如,encoding包定义了由其他子包如encoding/jsonencoding/binary实现的接口。encoding包装在这点上有错吗?肯定不是。在这种情况下,encoding包中定义的抽象在标准库中使用,语言设计者知道预先创建这些抽象是有价值的。我们回到上一节的讨论:如果你认为抽象在想象的未来可能是有帮助的,或者至少,如果你不能证明这个抽象是有效的,就不要创建它。

在大多数情况下,接口应该位于消费者端。然而,在特定的环境中(例如,当我们知道——而不是预见——一个抽象将对消费者有帮助时),我们可能希望它在生产者一方。如果我们这样做了,我们应该努力使它尽可能的小,增加它的可重用性,使它更容易组合。

让我们在函数签名的上下文中继续讨论接口。

2.7 #7:返回接口

在设计函数签名时,我们可能需要返回一个接口或者一个具体的实现。让我们来理解为什么返回一个接口在很多情况下被认为是 Go 中的一个坏习惯。

我们刚刚介绍了为什么接口通常存在于消费者端。图 2.7 显示了如果一个函数返回一个接口而不是一个结构,依赖关系会发生什么。我们会看到它会导致一些问题。

我们将考虑两种方案:

  • client,其中包含一个Store接口

  • store,包含Store的一个实现

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LZDLgHCL-1684395314257)(…/…/OEBPS/Images/CH02_F07_Harsanyi.png)]

图 2.7 从store包到client包有一个依赖关系。

store包中,我们定义了一个实现Store接口的InMemoryStore结构。同时,我们创建一个NewInMemoryStore函数来返回一个Store接口。在这个设计中,从实现包到客户机包有一个依赖关系,这听起来可能有点奇怪。

比如client包已经不能调用NewInMemoryStore函数了;否则,就会出现循环依赖。一个可能的解决方案是从另一个包中调用这个函数,并将一个Store实现注入到client。然而,被迫这样做意味着设计应该受到质疑。

此外,如果另一个客户机使用了InMemoryStore结构会怎么样?在这种情况下,也许我们想将Store接口移动到另一个包中,或者回到实现包中——但是我们讨论了为什么在大多数情况下,这不是最佳实践。这看起来像代码的味道。

因此,一般来说,返回一个接口会限制灵活性,因为我们强迫所有的客户端使用一种特定类型的抽象。大多数情况下,我们可以从 Postel 定律(datatracker.ietf.org/doc/html/rfc761)中得到启发:

做自己的事要保守,接受别人的东西要开明。

——传输控制协议

如果我们把这个习语用到GO上,那就意味着

  • 返回结构而不是接口

  • 如果可能的话接受接口

当然,也有一些例外。作为软件工程师,我们熟悉这样一个事实:规则从来不是 100%正确的。最相关的是类型,一个由许多函数返回的接口。我们还可以用包io检查标准库中的另一个异常:

func LimitReader(r Reader, n int64) Reader {
    return &LimitedReader{r, n}
}

这里,函数返回一个导出的结构,io.LimitedReader。然而,函数签名是一个接口io.Reader。打破我们到目前为止讨论的规则的基本原理是什么?io.Reader是一个预先的抽象概念。它不是由客户定义的,但它是强制的,因为语言设计者事先知道这种抽象级别会有帮助(例如,在可重用性和可组合性方面)。

总而言之,大多数情况下,我们不应该返回接口,而应该返回具体的实现。否则,由于包的依赖性,它会使我们的设计更加复杂,并且会限制灵活性,因为所有的客户端都必须依赖相同的抽象。同样,结论类似于前面的章节:如果我们知道(不是预见)一个抽象对客户有帮助,我们可以考虑返回一个接口。否则,我们不应该强迫抽象;他们应该被客户发现。如果客户端出于某种原因需要抽象一个实现,它仍然可以在客户端这样做。

在下一节中,我们将讨论一个与使用any相关的常见错误。

2.8 #8:any什么都不代表

在 Go 中,指定零方法的接口类型被称为空接口,interface{}。到了 Go 1.18,预声明的类型any变成了空接口的别名;因此,所有的interface{}事件都可以用any代替。在很多情况下,any可以认为是一种过度概括;而且就像罗布派克说的,不传达任何东西(www.youtube.com/watch?v=PAAkCSZUG1c&t=7m36s)。让我们先提醒自己核心概念,然后我们可以讨论潜在的问题。

一个any类型可以保存任何值类型:

func main() {
    var i any

    i = 42           // ❶
    i = "foo"        // ❷
    i = struct {     // ❸
        s string
    }{
        s: "bar",
    }
    i = f            // ❹

    _ = i            // ❺
}

func f() {}

国际// ❶

❷ 字符串

❸ 结构

❹ 函数

❺ 赋值给空白标识符,以便该示例编译

在给和any类型赋值时,我们丢失了所有的类型信息,这需要一个类型断言来从i变量中获取任何有用的信息,就像前面的例子一样。让我们看另一个例子,这里使用any是不准确的。在下面,我们实现了一个Store结构和两个方法GetSet的框架。我们使用这些方法来存储不同的结构类型,CustomerContract:

package store

type Customer struct{
    // Some fields
}
type Contract struct{
    // Some fields
}

type Store struct{}

func (s *Store) Get(id string) (any, error) {     // ❶
    // ...
}

func (s *Store) Set(id string, v any) error {     // ❷
    // ...
}

❶ 返回any

❷ 接受any

虽然Store在编译方面没有任何问题,但是我们应该花一分钟来考虑一下方法签名。因为我们接受并返回any参数,所以这些方法缺乏表现力。如果未来的开发人员需要使用Store结构,他们可能需要钻研文档或阅读代码来理解如何使用这些方法。因此,接受或返回一个any类型并不能传达有意义的信息。此外,因为在编译时没有安全措施,所以没有什么可以阻止调用者用任何数据类型调用这些方法,比如一个int:

s := store.Store{}
s.Set("foo", 42)

通过使用any,我们失去了 Go 作为静态类型语言的一些好处。相反,我们应该避免any类型,尽可能使我们的签名显式化。对于我们的例子,这可能意味着为每个类型复制GetSet方法:

func (s *Store) GetContract(id string) (Contract, error) {
    // ...
}

func (s *Store) SetContract(id string, contract Contract) error {
    // ...
}

func (s *Store) GetCustomer(id string) (Customer, error) {
    // ...
}

func (s *Store) SetCustomer(id string, customer Customer) error {
    // ...
}

在这个版本中,这些方法很有表现力,减少了不理解的风险。拥有更多的方法不一定是问题,因为客户也可以使用一个接口创建他们自己的抽象。例如,如果一个客户只对Contract方法感兴趣,它可以写这样的东西:

type ContractStorer interface {
    GetContract(id string) (store.Contract, error)
    SetContract(id string, contract store.Contract) error
}

有哪些any有帮助的情况?让我们看看标准库,看看函数或方法接受any参数的两个例子。第一个例子是在即encoding/json包中。因为我们可以封送任何类型,Marshal函数接受any参数:

func Marshal(v any) ([]byte, error) {
    // ...
}

另一个例子是在的database/sql包中。如果查询是参数化的(例如,SELECT * FROM FOO WHERE id = ?),参数可以是任何种类。因此,它也使用any参数:

func (c *Conn) QueryContext(ctx context.Context, query string,
    args ...any) (*Rows, error) {
    // ...
}

总之,如果确实需要接受或返回任何可能的类型(例如,当涉及到封送或格式化时),any会很有帮助。一般来说,我们应该不惜一切代价避免过度概括我们编写的代码。也许少量的重复代码偶尔会更好,如果它改善了其他方面,比如代码的表达能力。

接下来,我们将讨论另一种类型的抽象:泛型。

2.9 #9:对何时使用泛型感到困惑

Go 1.18 在语言中加入了泛型。简而言之,这允许用可以在以后指定并在需要时实例化的类型来编写代码。然而,什么时候使用泛型,什么时候不使用泛型可能会令人困惑。在这一节中,我们将描述 Go 中泛型的概念,然后看看常见的用法和误用。

2.9.1 概念

考虑以下从map[string]int类型中提取所有键的函数:

func getKeys(m map[string]int) []string {
    var keys []string
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

如果我们想对另一种映射类型(如map[int]string)使用类似的函数,该怎么办?在泛型出现之前,Go 开发者有几个选择:使用代码生成、反射或复制代码。例如,我们可以编写两个函数,每个函数对应一种映射类型,或者甚至尝试扩展getKeys来接受不同的映射类型:

func getKeys(m any) ([]any, error) {                      // ❶
    switch t := m.(type) {
    default:
        return nil, fmt.Errorf("unknown type: %T", t)     // ❷
    case map[string]int:
        var keys []any
        for k := range t {
            keys = append(keys, k)
        }
        return keys, nil
    case map[int]string:
        // Copy the extraction logic
    }
}

❶ 接受并返回任何参数

❷ 如果类型还没有实现,处理运行时错误

通过这个例子,我们开始注意到一些问题。首先,它增加了样板代码。事实上,当我们想要添加一个案例时,它需要复制的range循环。同时,函数现在接受了和any类型,这意味着我们失去了 Go 作为类型化语言的一些好处。事实上,检查一个类型是否被支持是在运行时而不是编译时完成的。因此,如果提供的类型未知,我们也需要返回一个错误。最后,因为键类型可以是intstring,我们必须返回一部分any类型来提取键类型。这种方法增加了调用方的工作量,因为客户端可能还需要执行键的类型检查或额外的转换。多亏了泛型,我们现在可以使用类型参数重构这段代码。

类型参数是我们可以在函数和类型中使用的泛型类型。例如,以下函数接受类型参数:

func foo[T any](t T) {     // ❶
    // ...
}

T是一个类型参数。

调用foo时,我们传递一个any类型的类型实参。提供类型参数是调用实例化,工作在编译时完成。这使得类型安全成为核心语言特性的一部分,并避免了运行时开销。

让我们回到getKeys函数,使用类型参数编写一个通用版本,它可以接受任何类型的映射:

func getKeys[K comparable, V any](m map[K]V) []K {   // ❶
    var keys []K                                     // ❷
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

❶ 键是可比较的,而值是任意类型的。

❷ 创建了键的切片

为了处理映射,我们定义了两种类型参数。首先,值可以是any类型:V any。然而,在 Go 中,映射键不能是和any类型。例如,我们不能使用切片:

var m map[[]byte]int

这段代码导致编译错误:invalid map key type []byte。因此,我们不接受任何键类型,而是必须限制类型参数,以便键类型满足特定的要求。这里的要求是键的类型必须具有可比性(我们可以用==或者!=)。因此,我们将K定义为comparable而不是any

限制类型参数以匹配特定的需求被称为约束。约束是一种接口类型,可以包含

  • 一套行为(方法)

  • 任意类型

让我们来看看后者的一个具体例子。假设我们不想为map键类型接受任何comparable类型。例如,我们希望将限制为的intstring类型。我们可以这样定义自定义约束:

type customConstraint interface {
    ~int | ~string                   // ❶
}

func getKeys[K customConstraint,     // ❷
         V any](m map[K]V) []K {
    // Same implementation
}

❶ 定义了一个自定义类型,将类型限制为intstring

❷ 将类型参数k更改为customConstraint类型

首先,我们定义一个customConstraint接口,使用联合操作符|将和类型限制为intstring(稍后我们将讨论~的用法)。K现在是customConstraint而不是之前的comparable

getKeys的签名要求我们可以用任何值类型的映射来调用它,但是键类型必须是intstring——例如,在调用者端:

m = map[string]int{
    "one":   1,
    "two":   2,
    "three": 3,
}
keys := getKeys(m)

注意 Go 可以推断出getKeys是用string类型参数调用的。前面的调用相当于:

keys := getKeys[string](m)

~int vs. int

使用~int的约束和使用int的约束有什么区别?使用int将其限制为该类型,而~int则限制所有底层类型为int的类型。为了说明,让我们设想一个约束,我们希望将一个类型限制为实现String()``string方法的任何int类型:

type customConstraint interface {
    ~int
    String() string
}

使用此约束将类型参数限制为自定义类型。举个例子,

type customInt int

func (i customInt) String() string {
    return strconv.Itoa(int(i))
}

因为customInt是一个int并实现了String() string方法,所以customInt类型满足定义的约束。然而,如果我们改变约束来包含一个int而不是~int,使用customInt会导致编译错误,因为类型int没有实现String() string

到目前为止,我们已经讨论了对函数使用泛型的例子。然而,我们也可以使用数据结构的泛型。例如,我们可以创建一个包含任何类型值的链表。为此,我们将编写一个Add方法来追加一个节点:

type Node[T any] struct {                // ❶
    Val  T
    next *Node[T]
}

func (n *Node[T]) Add(next *Node[T]) {   // ❷
    n.next = next
}

❶ 使用类型参数

❷ 实例化一个类型接收器

在示例中,我们使用类型参数来定义T,并在Node中使用这两个字段。关于该方法,接收器被实例化。事实上,因为Node是泛型的,所以它也必须遵循定义的类型参数。

关于类型参数需要注意的最后一点是,它们不能与方法参数一起使用,只能与函数参数或方法接收器一起使用。例如,下面的方法不会编译:

type Foo struct {}

func (Foo) bar[T any](t T) {}
./main.go:29:15: methods cannot have type parameters

如果我们想在方法中使用泛型,那么接收者需要成为类型参数。

现在,让我们检查一下我们应该和不应该使用泛型的具体情况。

2.9.2 常见用途和误用

泛型什么时候有用?让我们讨论一些建议使用泛型的常见用法:

  • 数据结构——例如,如果我们实现了二叉树、链表或堆,我们可以使用泛型来提取元素类型。

  • 处理任何类型的切片、贴图和通道的函数——例如,合并两个通道的函数可以处理任何类型的通道。因此,我们可以使用类型参数来提取通道类型:

    func merge[T any](ch1, ch2 <-chan T) <-chan T {
        // ...
    }
    
  • 分解出行为而不是类型——sort包,例如,包含一个接口和三个方法:

    type Interface interface {
        Len() int
        Less(i, j int) bool
        Swap(i, j int)
    }
    

    该接口由sort.Intssort .Float64s等不同的函数使用。使用类型参数,我们可以分解出排序行为(例如,通过定义一个包含切片和比较函数的结构):

    type SliceFn[T any] struct {    // ❶
        S       []T
        Compare func(T, T) bool     // ❷
    }
    
    func (s SliceFn[T]) Len() int           { return len(s.S) }
    func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) }
    func (s SliceFn[T]) Swap(i, j int)      { s.S[i], s.S[j] = s.S[j], s.S[i] }
    

    ❶使用类型参数

    ❷比较了两个元素

    然后,因为SliceFn结构实现了sort.Interface,我们可以使用的sort.Sort(sort.Interface)函数对提供的切片进行排序:

    s := SliceFn[int]{
        S: []int{3, 2, 1},
        Compare: func(a, b int) bool {
            return a < b
        },
    }
    sort.Sort(s)
    fmt.Println(s.S)
    [1 2 3]
    

    在这个例子中,分解出一个行为允许我们避免为每个类型创建一个函数。

反过来说,什么时候建议我们不要使用泛型?

  • 当调用类型参数的方法时——考虑一个接收io.Writer并调用的Write方法的函数,例如:

    func foo[T io.Writer](w T) {
        b := getBytes()
        _, _ = w.Write(b)
    }
    

    在这种情况下,使用泛型不会给我们的代码带来任何价值。我们应该把w直接变成io.Writer

  • 当它让我们的代码变得更复杂的时候——泛型从来都不是强制性的,作为 Go 开发者,我们已经没有它们十多年了。如果我们正在编写通用的函数或结构,并且我们发现它并没有使我们的代码更清晰,我们可能应该重新考虑我们对于这个特殊用例的决定。

虽然泛型在特定的情况下会有帮助,但是我们应该小心什么时候使用它们,什么时候不使用它们。一般来说,如果我们想回答什么时候不使用泛型,我们可以找到与什么时候不使用接口的相似之处。事实上,泛型引入了一种抽象形式,我们必须记住,不必要的抽象引入了复杂性。

同样,让我们不要用不必要的抽象污染我们的代码,现在让我们专注于解决具体的问题。这意味着我们不应该过早地使用类型参数。让我们等到要写样板代码的时候再考虑使用泛型。

在下一节中,我们将讨论使用类型嵌入时可能出现的问题。

2.10 #10:不知道类型嵌入可能存在的问题

当创建一个结构时,Go 提供了嵌入类型的选项。但是如果我们不理解类型嵌入的所有含义,这有时会导致意想不到的行为。在这一节中,我们将探讨如何嵌入类型,它们会带来什么,以及可能出现的问题。

在 Go 中,如果一个结构字段没有名字就被声明,那么它就被称为嵌入的。举个例子,

type Foo struct {
    Bar              // ❶
}

type Bar struct {
    Baz int
}

❶ 嵌入字段

Foo结构中,Bar类型是在没有关联名称的情况下声明的;因此,它是一个嵌入式字段。

我们使用嵌入来提升嵌入类型的字段和方法。因为Bar包含一个Baz字段,这个字段被提升为Foo(见图 2.8)。因此,BazFoo开始变为可用:

foo := Foo{}
foo.Baz = 42

请注意,Baz可从两个不同的路径获得:要么从使用Foo.Baz的提升路径获得,要么通过BarFoo.Bar.Baz从名义路径获得。两者都涉及同一个字段。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TVimEnQ6-1684395314257)(…/…/OEBPS/Images/CH02_F08_Harsanyi.png)]

图 2.8 baz被提升,因此可直接从S进入。

接口和嵌入

嵌入也用在接口中,与其他接口组成一个接口。在下面的例子中,io.ReadWriter由一个io.Reader和一个io.Writer组成:

type ReadWriter interface {
    Reader
    Writer
}

但是本节的范围只与结构中的嵌入字段相关。

现在我们已经提醒自己什么是嵌入类型,让我们看一个错误用法的例子。在下面的代码中,我们实现了一个保存一些内存数据的结构,我们希望使用互斥锁来保护它免受并发访问:

type InMem struct {
    sync.Mutex         // ❶
    m map[string]int
}

func New() *InMem {
    return &InMem{m: make(map[string]int)}
}

❶ 嵌入字段

我们决定不导出映射,这样客户端就不能直接与它交互,只能通过导出的方法。同时,互斥字段被嵌入。因此,我们可以这样实现一个Get方法:

func (i *InMem) Get(key string) (int, bool) {
    i.Lock()                     // ❶
    v, contains := i.m[key]
    i.Unlock()                   // ❷
    return v, contains
}

❶ 直接访问Lock方法

Unlock方法也是如此。

因为互斥体是嵌入的,所以我们可以从i接收器直接访问LockUnlock方法。

我们提到过这样的例子是类型嵌入的错误用法。这是什么原因呢?由于sync.Mutex是一个嵌入式类型,所以LockUnlock方法将被提升。因此,这两种方法对于使用InMem的外部客户端都是可见的:

m := inmem.New()
m.Lock() // ??

这种提升可能是不可取的。在大多数情况下,互斥体是我们希望封装在一个结构中并对外部客户端不可见的东西。因此,在这种情况下,我们不应该将其作为嵌入字段:

type InMem struct {
    mu sync.Mutex      // ❶
    m map[string]int
}

❶ 指定sync.Mutex不是嵌入的

因为互斥体没有嵌入也没有导出,所以它不能从外部客户端访问。现在让我们看另一个例子,但是这次嵌入被认为是一种正确的方法。

我们想要编写一个定制的日志记录器,它包含一个io.WriteCloser并公开两个方法WriteClose。如果io.WriteCloser没有嵌入,我们需要这样写:

type Logger struct {
    writeCloser io.WriteCloser
}

func (l Logger) Write(p []byte) (int, error) {
    return l.writeCloser.Write(p)     // ❶
}

func (l Logger) Close() error {
    return l.writeCloser.Close()      // ❶
}
func main() {
    l := Logger{writeCloser: os.Stdout}
    _, _ = l.Write([]byte("foo"))
    _ = l.Close()
}

❶ 将调用转发给writeCloser

Logger必须为提供一个Write和一个Close方法,该方法只能将调用转发给io.WriteCloser。但是,如果该字段现在变成嵌入的,我们可以删除这些转发方法:

type Logger struct {
    io.WriteCloser       // ❶
}

func main() {
    l := Logger{WriteCloser: os.Stdout}
    _, _ = l.Write([]byte("foo"))
    _ = l.Close()
}

❶ 指定io.WriteCloser是嵌入的

对于具有两个导出的WriteClose方法的客户端来说是一样的。但是该示例阻止实现这些附加方法来简单地转移调用。同样,随着WriteClose被提升,意味着Logger满足的io.WriteCloser接口。

嵌入与 OOP 子类化

区分嵌入和 OOP 子类有时会令人困惑。主要的区别与方法接收者的身份有关。我们来看下图。左手边代表嵌入在Y中的类型X,而右手边的Y延伸出X

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Lh235EDE-1684395314257)(…/…/OEBPS/Images/CH02_F08_UN01_Harsanyi.png)]

对于嵌入,嵌入类型仍然是方法的接收者。相反,有了子类化,子类就变成了方法的接收者。

通过嵌入,Foo的接收者仍然是X。然而,通过子类化,Foo的接收者变成了子类,Y。嵌入是构图,不是继承。

关于类型嵌入我们应该得出什么结论?首先,让我们注意到这很少是必要的,这意味着无论什么用例,我们都可以不用类型嵌入来解决它。类型嵌入主要是为了方便:在大多数情况下,是为了促进行为。

如果我们决定使用类型嵌入,我们需要记住两个主要约束:

  • 它不应该仅仅作为某种语法糖来简化对字段的访问(比如用Foo.Baz()代替Foo.Bar.Baz())。如果这是唯一的理由,让我们不要嵌入内部类型,而是使用字段。

  • 它不应该促进我们想要对外部隐藏的数据(字段)或行为(方法):例如,如果它允许客户端访问一个锁定行为,该行为应该对该结构保持私有。

注意,有些人可能会认为,在导出结构的上下文中,使用类型嵌入会导致额外的维护工作。事实上,在导出的结构中嵌入一个类型意味着当这个类型发展时要保持谨慎。例如,如果我们向内部类型添加一个新方法,我们应该确保它不会破坏后面的约束。因此,为了避免这种额外的工作,团队还可以防止在公共结构中嵌入类型。

通过记住这些约束,有意识地使用类型嵌入有助于避免带有额外转发方法的样板代码。然而,让我们确保我们不仅仅是为了化妆品而这样做,也不宣传那些应该隐藏的元素。

在下一节中,我们将讨论处理可选配置的常见模式。

2.11 #11:不使用函数式选项模式

设计 API 时,可能会出现一个问题:我们如何处理可选配置?有效地解决这个问题可以提高我们的 API 的便利性。这一节将通过一个具体的例子来介绍处理可选配置的不同方法。

对于这个例子,假设我们必须设计一个库,它公开一个函数来创建一个 HTTP 服务器。这个函数接受不同的输入:一个地址和一个端口。下面显示了该函数的框架:

func NewServer(addr string, port int) (*http.Server, error) {
    // ...
}

我们库的客户端已经开始使用这个函数了,大家都很高兴。但是在某个时候,我们的客户开始抱怨这个函数有些受限,并且缺少其他参数(例如,写超时和连接上下文)。然而,我们注意到添加新的函数参数破坏了兼容性,迫使客户端修改它们调用NewServer的方式。同时,我们希望以这种方式丰富与端口管理相关的逻辑(图 2.9):

  • 如果未设置端口,则使用默认端口。

  • 如果端口为负,则返回错误。

  • 如果端口等于 0,则使用随机端口。

  • 否则,使用客户端提供的端口。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6wG4NGbb-1684395314258)(…/…/OEBPS/Images/CH02_F09_Harsanyi.png)]

图 2.9 与端口选项相关的逻辑

我们如何以一种 API 友好的方式实现这个功能?让我们看看不同的选项。

2.11.1 配置结构

因为 Go 不支持函数签名中的可选参数,第一种可能的方法是使用配置结构来传达什么是强制的,什么是可选的。例如,强制参数可以作为函数参数存在,而可选参数可以在Config结构中处理:

type Config struct {
    Port        int
}

func NewServer(addr string, cfg Config) {
}

此解决方案解决了兼容性问题。事实上,如果我们添加新的选项,它不会在客户端中断。然而,这种方法不能解决我们与端口管理相关的需求。事实上,我们应该记住,如果没有提供结构字段,它将被初始化为零值:

  • 整数为 0

  • 浮点型为 0.0

  • 字符串为""

  • 对于切片、映射、通道、指针、接口和函数,为nil

因此,在下面的示例中,两个结构是相等的:

c1 := httplib.Config{
    Port: 0,              // ❶
}
c2 := httplib.Config{
                          // ❷
}

❶ 将端口初始化为 0

❷ 端口丢失,所以它被初始化为 0。

在我们的例子中,我们需要找到一种方法来区分故意设置为 0 的端口和丢失的端口。也许一种选择是以这种方式将配置结构的所有参数作为指针来处理:

type Config struct {
    Port        *int
}

使用整数指针,在语义上,我们可以突出显示值0和缺失值(零指针)之间的差异。

这种选择是可行的,但也有一些缺点。首先,客户端提供一个整数指针并不方便。客户端必须创建一个变量,然后以这种方式传递指针:

port := 0
config := httplib.Config{
    Port: &port,             // ❶
}

❶ 提供一个整数指针

它本身并不引人注目,但是整体的 API 使用起来有点不方便。同样,我们添加的选项越多,代码就变得越复杂。

第二个缺点是,使用默认配置的库的客户端需要以这种方式传递一个空结构:

httplib.NewServer("localhost", httplib.Config{})

这段代码看起来不怎么样。读者必须理解这个神奇的结构是什么意思。

另一种选择是使用经典的构建器模式,这将在下一节中介绍。

2.11.2 构建器模式

builder 模式最初是四人组设计模式的一部分,它为各种对象创建问题提供了灵活的解决方案。Config的构造与结构本身是分离的。它需要一个额外的结构ConfigBuilder,该结构接收配置和构建Config的方法。

让我们看一个具体的例子,看看它如何帮助我们设计一个友好的 API 来满足我们的所有需求,包括端口管理:

type Config struct {                                 // ❶
    Port int
}

type ConfigBuilder struct {                          // ❷
    port *int
}

func (b *ConfigBuilder) Port(
    port int) *ConfigBuilder {                       // ❸
    b.port = &port
    return b
}

func (b *ConfigBuilder) Build() (Config, error) {    // ❹
    cfg := Config{}

    if b.port == nil {                               // ❺
        cfg.Port = defaultHTTPPort
    } else {
        if *b.port == 0 {
            cfg.Port = randomPort()
        } else if *b.port < 0 {
            return Config{}, errors.New("port should be positive")
        } else {
            cfg.Port = *b.port
        }
    }

    return cfg, nil
}

func NewServer(addr string, config Config) (*http.Server, error) {
    // ...
}

❶ 配置结构

❷ 配置生成器结构,包含可选端口

❸ 公共端口的设置方法

创建配置结构的❹构建方法

❺ 与港口管理相关的主要逻辑

ConfigBuilder结构保存客户端配置。它公开了一个设置端口的Port方法。通常,这样的配置方法会返回构建器本身,以便我们可以使用方法链接(例如,builder.Foo("foo").Bar("bar"))。它还公开了一个Build方法,该方法保存初始化端口值的逻辑(指针是否为nil等等)。)并在创建后返回一个Config结构。

请注意,构建器模式没有单一的可能实现。例如,有些人可能喜欢定义最终端口值的逻辑在Port方法中而不是在Build中的方法。本节的范围是呈现构建器模式的概述,而不是查看所有不同的可能变体。

然后,一个客户会以下面的方式使用我们的基于构建器的 API(我们假设我们已经把代码放在了一个httplib包中):

builder := httplib.ConfigBuilder{}                   // ❶
builder.Port(8080)                                   // ❷
cfg, err := builder.Build()                          // ❸
if err != nil {
    return err
}

server, err := httplib.NewServer("localhost", cfg)   // ❹
if err != nil {
    return err
}

❶ 创建一个生成器配置

❷ 设置端口

❸ 构建配置结构

❹ 传递配置结构

首先,客户端创建一个ConfigBuilder并使用它来设置一个可选字段,比如端口。然后,它调用Build方法并检查错误。如果正常,配置被传递到NewServer

这种方法使得端口管理更加方便。不需要传递整数指针,因为Port方法接受整数。但是,如果客户端想要使用默认配置,我们仍然需要传递一个可以为空的配置结构:

server, err := httplib.NewServer("localhost", nil)

在某些情况下,另一个缺点与错误管理有关。在抛出异常的编程语言中,如果输入无效,像Port这样的构建器方法可以引发异常。如果我们想保持链接调用的能力,函数就不能返回错误。因此,我们不得不延迟在Build方法中的验证。如果一个客户端可以传递多个选项,但是我们想要精确地处理端口无效的情况,这使得错误处理变得更加复杂。

现在让我们看看另一种方法,叫做函数选项模式,它依赖于变量参数。

2.11.3 函数式选项模式

我们将讨论的最后一种方法是函数式选项模式(图 2.10)。虽然有不同的实现,但有细微的变化,主要思想如下:

  • 未导出的结构保存配置:options

  • 每个选项都是返回相同类型的函数:type Option func(options *options) error。例如,WithPort接受一个代表端口的int参数,并返回一个代表如何更新options结构的Option类型。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TWUU5ISx-1684395314258)(…/…/OEBPS/Images/CH02_F10_Harsanyi.png)]

图 2.10WithPort选项更新最终的options结构。

下面是options结构、Option类型和WithPort选项的 Go 实现:

type options struct {                          // ❶
    port *int
}

type Option func(options *options) error       // ❷

func WithPort(port int) Option {               // ❸
    return func(options *options) error {
        if port < 0 {
            return errors.New("port should be positive")
        }
        options.port = &port
        return nil
    }
}

❶ 配置结构

❷ 表示更新配置结构的函数类型

❸ 更新端口的配置函数

这里,WithPort返回一个闭包。一个闭包是一个匿名函数,从它的正文外部引用变量;在这种情况下,port变量。闭包遵循Option类型并实现端口验证逻辑。每个配置字段都需要创建一个公共函数(按照惯例,以前缀With开始),包含类似的逻辑:如果需要,验证输入并更新配置结构。

让我们看看提供者端的最后一部分:NewServer实现。我们将把选项作为变量参数传递。因此,我们必须迭代这些选项来改变options配置结构:

func NewServer(addr string, opts ...Option) (     // ❶
    *http.Server, error) {
    var options options                           // ❷
    for _, opt := range opts {                    // ❸
        err := opt(&options)                      // ❹
        if err != nil {
            return nil, err
        }
    }

    // At this stage, the options struct is built and contains the config
    // Therefore, we can implement our logic related to port configuration
    var port int
    if options.port == nil {
        port = defaultHTTPPort
    } else {
        if *options.port == 0 {
            port = randomPort()
        } else {
            port = *options.port
        }
    }

    // ...
}

❶ 接受可变选项参数

❷ 创建了一个空的选项结构

❸ 迭代所有的输入选项

❹ 调用每个选项,这导致修改公共选项结构

我们首先创建一个空的options结构。然后,我们迭代每个Option参数并执行它们来改变options结构(记住Option类型是一个函数)。一旦构建了options结构,我们就可以实现关于端口管理的最终逻辑。

因为NewServer接受可变的Option参数,客户端现在可以通过在强制地址参数后传递多个选项来调用这个 API。举个例子,

server, err := httplib.NewServer("localhost",
        httplib.WithPort(8080),
        httplib.WithTimeout(time.Second))

但是,如果客户机需要默认配置,它不必提供参数(例如,一个空结构,正如我们在前面的方法中看到的)。客户端的调用现在可能看起来像这样:

server, err := httplib.NewServer("localhost")

这种模式就是函数式选项模式。它提供了一种方便且 API 友好的方式来处理选项。尽管构建者模式可能是一个有效的选项,但是它有一些小的缺点,这使得函数可选项模式成为 Go 中处理这个问题的惯用方法。我们还要注意,这种模式在 gRPC 等不同的 Go 库中使用。

下一节将讨论另一个常见的错误:组织不当。

2.12 #12:项目组织不当

组织一个GO项目并不是一件容易的事情。因为 Go 语言在设计包和模块方面提供了很大的自由度,所以最佳实践并没有像它们应该的那样普遍存在。本节首先讨论构建项目的一种常见方法,然后讨论一些最佳实践,展示改进我们如何组织项目的方法。

2.12.1 项目结构

Go 语言维护者对于在 Go 中构建项目没有很强的约定。然而,这些年来出现了一种布局:项目布局(github.com/golang-standards/project-layout)。

如果我们的项目足够小(只有几个文件),或者如果我们的组织已经创建了它的标准,它可能不值得使用或者迁移到project-layout。否则,可能值得考虑。让我们看一下这个布局,看看主要目录是什么:

  • /cmd——主源文件。foo应用的main.go应该位于/cmd/foo/main.go中。

  • /internal——我们不希望其他人为他们的应用或库导入的私有代码。

  • /pkg——我们要公开给别人的公共代码。

  • /test——附加外部测试和测试数据。中的单元测试与源文件放在同一个包中。但是,公共 API 测试或集成测试应该位于/test中。

  • /configs——配置文件。

  • /docs——设计和用户文档。

  • /examples——我们的应用和/或公共库的示例。

  • /api——API 合同文件(Swagger、协议缓冲区等)。

  • /web——特定于 Web 应用的资产(静态文件等)。

  • /build——打包和持续集成(CI)文件。

  • /script——用于分析、安装等的脚本。

  • /vendor——应用依赖关系(例如,Go 模块依赖关系)。

不像其他语言那样有/src目录。理由是/src太通用了;因此,这种布局倾向于使用/cmd/internal/pkg这样的目录。

注 2021 年,GO核心维护者之一 Russ Cox 批评了这种布局。尽管不是官方标准,但一个项目主要隶属于 GitHub golang 标准组织。无论如何,我们必须记住,关于项目结构,没有强制性的约定。这种布局可能对你有帮助,也可能没有,但这里重要的是,优柔寡断是唯一错误的决定。因此,在布局上达成一致,以保持组织中的一致性,这样开发人员就不会浪费时间从一个存储库切换到另一个存储库。

现在,让我们讨论如何组织 Go 存储库的主要逻辑。

2,12,2 包组织

在 Go 中,没有子包的概念。然而,我们可以决定在子目录中组织包。如果我们看一下标准库,net目录是这样组织的:

/net
    /http
        client.go
        ...
    /smtp
        auth.go
        ...
    addrselect.go
    ...

net既作为一个包,又作为包含其他包的目录。但是net/http并不从net继承,也没有对net包的特定访问权限。net/http内的元素只能看到导出的net元素。子目录的主要好处是保持包在一个地方,在那里它们有很高的内聚性。

关于整体组织,有不同的学派。例如,我们应该按上下文还是按层来组织我们的应用?这取决于我们的喜好。我们可能倾向于按上下文(如客户上下文、合同上下文等)对代码进行分组。),或者我们可能倾向于遵循六边形架构原则并按技术层分组。如果我们做出的决策符合我们的用例,只要我们保持一致,它就不会是一个错误的决策。

关于包,有许多我们应该遵循的最佳实践。首先,我们应该避免过早打包,因为这可能会导致项目过于复杂。有时,最好使用简单的组织,当我们理解了项目包含的内容时,让我们的项目发展,而不是强迫我们自己预先构建完美的结构。

粒度是另一个需要考虑的基本问题。我们应该避免几十个只包含一两个文件的 nano 包。如果我们这样做了,那是因为我们可能错过了这些包之间的一些逻辑联系,使得读者更难理解我们的项目。反过来,我们也应该避免淡化包装名称意义的巨大包装。

包命名也应该仔细考虑。众所周知(作为开发者),命名很难。为了帮助客户理解一个 Go 项目,我们应该根据它们提供的东西来命名我们的包,而不是它们包含的内容。还有,命名要有意义。因此,包名应该简短,有表现力,按照惯例,应该是一个小写的单词。

关于导出什么,规则非常简单。我们应该尽可能地减少应该导出的内容,以减少包之间的耦合,并隐藏不必要的导出元素。如果我们不确定是否要导出一个元素,我们应该默认不导出它。稍后,如果我们发现我们需要导出它,我们可以调整我们的代码。让我们记住一些例外,比如导出字段,以便可以用encoding/json解组一个结构。

组织一个项目并不简单,但是遵循这些规则应该有助于使它更容易维护。然而,记住一致性对于简化可维护性也是至关重要的。因此,让我们确保代码库中的东西尽可能保持一致。

在下一节中,我们将讨论实用工具包。

2.13 #13:创建实用工具包

本节讨论一个常见的不好的实践:创建共享的包,比如utilscommonbase。我们将用这种方法来检查问题,并学习如何改进我们的组织。

让我们看一个受 Go 官方博客启发的例子。它是关于实现一个集合数据结构(一个值被忽略的映射)。在 Go 中惯用的方法是通过一个带有Kmap[K]struct{}类型来处理它,它可以是映射中允许的任何类型作为键,而值是一个struct{}类型。事实上,值类型为struct{}的映射表明我们对值本身不感兴趣。让我们在一个util包中公开两个方法:

package util

func NewStringSet(...string) map[string]struct{} {    // ❶
    // ...
}

func SortStringSet(map[string]struct{}) []string {    // ❷
    // ...
}

❶ 创建了一个字符串集合

❷ 返回一个排序的键列表

客户端将像这样使用这个包:

set := util.NewStringSet("c", "a", "b")
fmt.Println(util.SortStringSet(set))

这里的问题是util没有意义。我们可以称它为commonsharedbase,但是它仍然是一个没有意义的名字,不能提供任何关于这个包提供了什么的信息。

我们应该创建一个表达性的包名,比如stringset,而不是一个实用工具包。举个例子,

package stringset

func New(...string) map[string]struct{} { ... }
func Sort(map[string]struct{}) []string { ... }

在本例中,我们删除了NewStringSetSortStringSet的后缀,它们分别变成了NewSort。在客户端,现在看起来是这样的:

set := stringset.New("c", "a", "b")
fmt.Println(stringset.Sort(set))

注:在上一节中,我们讨论了纳米封装的概念。我们提到了在一个应用中创建几十个 nano 包会使代码路径变得更加复杂。然而,纳米包装的想法本身并不一定是坏的。如果一个小的代码组具有很高的内聚性,并且不属于其他地方,那么将它组织到一个特定的包中是完全可以接受的。没有严格的规则可以适用,通常,挑战在于找到正确的平衡。

我们甚至可以更进一步。我们可以创建一个特定的类型并将Sort作为方法公开,而不是公开实用函数,如下所示:

package stringset

type Set map[string]struct{}
func New(...string) Set { ... }
func (s Set) Sort() []string { ... }

这一变化使得客户端更加简单。只有一个对stringset包的引用:

set := stringset.New("c", "a", "b")
fmt.Println(set.Sort())

通过这个小小的重构,我们去掉了一个无意义的包名,公开了一个有表现力的 API。正如 Dave Cheney(Go 的项目成员)提到的,我们经常合理地找到处理公共设施的实用工具包。例如,如果我们决定有一个客户机和一个服务器包,那么我们应该把公共类型放在哪里呢?在这种情况下,也许一个解决方案是将客户机、服务器和公共代码组合成一个包。

命名包是应用设计的一个关键部分,我们也应该对此保持谨慎。根据经验,创建没有有意义的名字的共享包不是一个好主意;这包括实用工具包,如utilscommonbase。此外,请记住,以包提供的内容而不是包包含的内容来命名包是增加其表达性的有效方法。

在下一节中,我们将讨论包和包冲突。

2.14 #14:忽略包名冲突

当一个变量名与一个已存在的包名冲突时,包冲突就会发生,阻止包被重用。让我们看一个具体的例子,一个库公开了一个 Redis 客户机:

package redis

type Client struct { ... }

func NewClient() *Client { ... }

func (c *Client) Get(key string) (string, error) { ... }

现在,让我们跳到客户端。尽管包名为redis,但在 Go 中创建一个名为redis的变量是完全有效的:

redis := redis.NewClient()     // ❶
v, err := redis.Get("foo")     // ❷

❶ 从redis包中调用NewClient

❷ 使用redis变量

这里,redis变量名与redis包名冲突。即使这是允许的,也应该避免。事实上,在redis变量的整个范围内,redis包将不会被访问。

假设一个限定符在整个函数中同时引用了变量和包名。在这种情况下,对于代码读者来说,知道限定符指的是什么可能是不明确的。有什么选择可以避免这样的碰撞?第一种选择是使用不同的变量名。举个例子,

redisClient := redis.NewClient()
v, err := redisClient.Get("foo")

这可能是最直接的方法。然而,如果出于某种原因,我们希望保留名为redis的变量,我们可以使用包导入。使用包导入,我们可以使用别名来改变限定符来引用redis包。举个例子,

import redisapi "mylib/redis"    // ❶

// ...

redis := redisapi.NewClient()    // ❷
v, err := redis.Get("foo")

❶ 为redis包创建了一个别名

❷ 通过redisapi别名访问redis

这里,我们使用了redisapi导入别名来引用redis包,这样就可以保留我们的变量名redis

注一个选择也可以是使用点导入来访问一个包的所有公共元素,而不用包限定符。但是,这种方法会增加混乱,在大多数情况下应该避免。

还要注意,我们应该避免变量和内置函数之间的命名冲突。例如,我们可以这样做:

copy := copyFile(src, dst)     // ❶

❶ 复制变量与复制内置函数冲突。

在这种情况下,只要copy变量存在,内置函数copy就不会被访问。总之,我们应该防止变量名冲突,以避免歧义。如果我们面临冲突,我们应该找到另一个有意义的名称或使用导入别名。

在下一节中,我们将看到一个与代码文档相关的常见错误。

2.15 #15:缺少代码文档

文档是编码的一个重要方面。它简化了客户使用 API 的方式,但也有助于维护项目。在 Go 中,我们应该遵循一些规则来使我们的代码符合习惯。让我们检查一下这些规则。

首先,必须记录每个导出的元素。不管是结构、接口、函数,还是别的什么,如果导出来了,就必须有文档记录。惯例是添加注释,从导出元素的名称开始。举个例子,

// Customer is a customer representation.
type Customer struct{}

// ID returns the customer identifier.
func (c Customer) ID() string { return "" }

按照惯例,每个注释都应该是一个完整的句子,以标点符号结尾。还要记住,当我们记录一个函数(或者一个方法)时,我们应该强调函数打算做什么,而不是它是如何做的;这属于函数和注释的核心,而不是文档。此外,理想情况下,文档应该提供足够的信息,使用户不必查看我们的代码就能理解如何使用导出的元素。

不推荐使用的元素

可以这样使用// Deprecated:注释来废弃导出的元素:

// ComputePath returns the fastest path between two points.
// Deprecated: This function uses a deprecated way to compute
// the fastest path. Use ComputeFastestPath instead.
func ComputePath() {}

然后,如果开发人员使用了ComputePath函数,他们应该会得到一个警告。(大多数 ide 处理不赞成使用的注释。)

当涉及到记录变量或常数时,我们可能对传达两个方面感兴趣:它的目的和它的内容。前者应该作为代码文档存在,以便对外部客户有用。不过,后者不一定是公开的。举个例子,

// DefaultPermission is the default permission used by the store engine.
const DefaultPermission = 0o644 // Need read and write accesses.

此常数表示默认权限。代码文档传达了它的目的,而常量旁边的注释描述了它的实际内容(读写访问)。

为了帮助客户和维护者理解一个包的范围,我们也应该记录每个包。惯例是以// Package开始注释,后跟包名:

// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results
// across architectures.
package math

包注释的第一行应该简洁。那是因为它会出现在包里(图 2.11 提供了一个例子)。然后,我们可以在下面几行中提供我们需要的所有信息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yFwoGbIN-1684395314258)(…/…/OEBPS/Images/CH02_F11_Harsanyi.png)]

图 2.11 生成的 Go 标准库示例

可以在任何 Go 文件中记录一个包;没有规则。一般来说,我们应该将包文档放在与包同名的相关文件中,或者放在特定的文件中,比如doc.go

关于包文档最后要提到的一点是,与声明不相邻的注释被省略了。例如,以下版权注释在生成的文档中不可见:

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package math provides basic constants and mathematical functions.
//                                                                        // ❶
// This package does not guarantee bit-identical results
// across architectures.
package math

❶ 空行。之前的注释将不包括在文档中。

总之,我们应该记住,每个导出的元素都需要被记录。记录我们的代码不应该成为一种约束。我们应该抓住机会,确保它有助于客户和维护人员理解我们代码的目的。

最后,在本章的最后一节,我们将看到一个关于工具的常见错误:不使用linter。

2.16 #16:不使用linter

一个 linter 是一个自动分析代码和捕捉错误的工具。本节的范围不是给出现有linter的详尽列表;否则,它很快就会被弃用。但是我们应该理解并记住为什么linter对于大多数GO项目是必不可少的。

为了理解为什么linter很重要,让我们举一个具体的例子。在错误#1,“意外的变量阴影”,我们讨论了与变量阴影相关的潜在错误。使用vet(Go 工具集中的一个标准工具)和shadow,我们可以检测隐藏的变量:

package main

import "fmt"

func main() {
    i := 0
    if true {
        i := 1          // ❶
        fmt.Println(i)
    }
    fmt.Println(i)
}

❶ 阴影变量

因为vet包含在 Go 二进制文件中,所以让我们首先安装shadow,将其与 Go vet链接,然后在前面的例子中运行它:

$ go install \
  golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow     // ❶
$ go vet -vettool=$(which shadow)                             // ❷
./main.go:8:3:
  declaration of "i" shadows declaration at line 6            // ❸

❶ 安装阴影

❷ 使用vettol参数链接到 Go vet

❸ GO兽医检测影子变量。

正如我们所看到的,vet通知我们在这个例子中变量i被隐藏了。使用适当的 linters 可以帮助我们的代码更加健壮,并检测潜在的错误。

注意短评没有涵盖本书中的所有错误。所以,建议你只是继续读下去;).

同样,本节的目标不是列出所有可用的linter。然而,如果你不是 linters 的经常用户,这里有一个你可能想每天使用的列表:

除了 linters,我们还应该使用代码格式化程序来修复代码风格。这里有一些代码格式化程序供您尝试:

同时,我们还应该看看golangci-lintgithub.com/golangci/golangci-lint)。这是一个林挺工具,在许多有用的 linters 和排版工具之上提供了一个门面。此外,它允许并行运行 linters 以提高分析速度,这非常方便。

Linters 和排版工具是提高我们代码库的质量和一致性的一个强大的方法。让我们花点时间来理解我们应该使用哪一个,并确保我们自动执行它们(例如 CI 或 Git 预提交钩子)。

总结

  • 避免隐藏变量有助于防止出现错误,比如引用错误的变量或迷惑读者。

  • 避免嵌套层次并保持快乐路径在左侧对齐使得构建心理代码模型更容易。

  • 初始化变量时,记住init函数的错误处理有限,会使状态处理和测试更加复杂。在大多数情况下,初始化应该作为特定的函数来处理。

  • 在 Go 中强制使用获取器和设置器并不符合习惯。务实一点,在效率和盲从某些习惯用法之间找到合适的平衡点,应该是应该走的路。

  • 抽象应该被发现,而不是被创建。为了避免不必要的复杂性,在你需要的时候创建一个接口,而不是在你预见到需要的时候,或者如果你至少能证明抽象是有效的,就创建一个接口。

  • 在客户端保留接口可以避免不必要的抽象。

  • 为了防止在灵活性方面受到限制,在大多数情况下,函数不应该返回接口,而应该返回具体的实现。相反,函数应该尽可能接受接口。

  • 只在需要接受或返回任何可能的类型时才使用any,比如json. Marshal。否则,any不会提供有意义的信息,并且会导致编译时问题,因为它允许调用者调用任何数据类型的方法。

  • 依赖泛型和类型参数可以防止编写样板代码来提取元素或行为。但是,不要过早地使用类型参数,只有当您看到对它们的具体需求时才使用。否则,它们会引入不必要的抽象和复杂性。

  • 使用类型嵌入还有助于避免样板代码;但是,要确保这样做不会导致一些本应隐藏的字段出现可见性问题。

  • 为了以 API 友好的方式方便地处理选项,请使用函数选项模式。

  • 遵循诸如project-layout这样的布局是开始构建 Go 项目的好方法,尤其是如果你正在寻找现有的约定来标准化一个新项目。

  • 命名是应用设计的关键部分。创建出commonutilshared这样的包装,并不能给读者带来多少价值。将这样的包重构为有意义的、特定的包名。

  • 为了避免变量和包之间的命名冲突,导致混乱甚至错误,为每个变量使用唯一的名字。如果这不可行,可以使用导入别名来更改限定符,以区分包名和变量名,或者想一个更好的名称。

  • 为了帮助客户和维护者理解你的代码的目的,记录导出的元素。

  • 为了提高代码质量和一致性,使用 linters 和排版工具。

三、数据类型

本章涵盖

  • 与基本类型相关的常见错误
  • 切片和映射的基本概念,以防止可能的错误、泄漏或不准确
  • 比较数值

处理数据类型是软件工程师的一项经常性操作。本章深入探讨了与基本类型、切片和贴图相关的最常见错误。我们省略的唯一数据类型是字符串,因为后面的章节将专门讨论这种类型。

3.1 #17:用八进制字面值制造混乱

我们先来看一个对八进制字面值表示的常见误解,这种误解会导致混乱甚至 bug。您认为以下代码的输出应该是什么?

sum := 100 + 010
fmt.Println(sum)

乍一看,我们可能期望这段代码打印出100 + 10 = 110的结果。但是它打印的是 108。这怎么可能呢?

在 Go 中,以 0 开头的整数字面值被视为八进制整数(基数为 8),因此基数为 8 的 10 等于基数为 10 的 8。因此,上例中的总和等于100 + 8 = 108。这是需要记住的整数字面值的一个重要属性——例如,在读取现有代码时避免混淆。

八进制整数在不同的场景中都很有用。例如,假设我们想使用os.OpenFile打开一个文件。这个函数需要传递一个权限作为uint32。如果我们想匹配一个 Linux 权限,为了可读性,我们可以传递一个八进制数,而不是十进制数:

file, err := os.OpenFile("foo", os.O_RDONLY, 0644)

在这个例子中,0644代表一个特定的 Linux 权限(对所有人都是读的,对当前用户只写)。也可以在零后面添加一个o字符(小写字母):

file, err := os.OpenFile("foo", os.O_RDONLY, 0o644)

使用0o作为前缀,而不是仅仅使用0,意思是一样的。但是,它有助于使代码更加清晰。

注意我们也可以使用大写的O字符来代替小写的o。但是传递0O644可能会增加混乱,因为根据字符字体的不同,0可能看起来与O非常相似。

我们还应该注意其他整数字面值表示:

  • 二进制——使用0b0B前缀(例如,0b100在 10 进制中等于 4)

  • 十六进制——使用0x0X前缀(例如,0xF在十进制中等于 15)

  • 虚数——使用一个i后缀(例如3i)

最后,我们还可以使用下划线字符(_)作为分隔符,以提高可读性。比如我们可以这样写 10 亿:1_000_000_000。我们也可以在其他表示中使用下划线字符(例如,0b00_00_01)。

总之,Go 处理二进制、十六进制、虚数和八进制数。八进制数以 0 开始。然而,为了提高可读性并避免未来代码读者的潜在错误,使用前缀0o使八进制数显式化。

下一节深入探讨整数,我们将讨论如何在 Go 中处理溢出。

3.2 #18:忽略整数溢出

不了解 Go 中如何处理整数溢出会导致严重的错误。本节将深入探讨这个主题。但首先,让我们提醒自己一些与整数相关的概念。

3.2.1 概念

Go 一共提供了 10 种整数类型。有四种有符号整数类型和四种无符号整数类型,如下表所示。

有符号整数无符号整数
int8 (8 位)uint8 (8 位)
int16 (16 位)uint16 (16 位)
int32 (32 位)uint32 (32 位)
int64 (64 位)uint64 (64 位)

另外两个整数类型是最常用的:和int。这两种类型的大小取决于系统:在 32 位系统上是 32 位,在 64 位系统上是 64 位。

现在让我们讨论溢出。假设我们想要初始化一个int32到它的最大值,然后递增它。这段代码的行为应该是什么?

var counter int32 = math.MaxInt32
counter++
fmt.Printf("counter=%d\n", counter)

这段代码可以编译,并且在运行时不会恐慌。然而,counter++语句会产生一个整数溢出:

counter=-2147483648

当算术运算创建的值超出了可以用给定字节数表示的范围时,就会发生整数溢出。使用 32 位来表示一个int32。下面是最大int32值(math.MaxInt32)的二进制表示:

01111111111111111111111111111111
 |------31 bits set to 1-------|

因为一个int32是一个有符号的整数,左边的位代表整数的符号:0 代表正数,1 代表负数。如果我们增加这个整数,就没有空间来表示新值了。因此,这会导致整数溢出。从二进制角度来看,新值如下:

10000000000000000000000000000000
 |------31 bits set to 0-------|

正如我们所看到的,位号现在等于 1,意味着负。该值是用 32 位表示的有符号整数的最小可能值。

注意最小可能的负值不是111111111111111111111111 11111111。事实上,大多数系统依靠二进制补码运算来表示二进制数(反转每一位并加 1)。这个操作的主要目标是使x+(–x)等于 0,而不管x

在 Go 中,可以在编译时检测到的整数溢出会产生编译错误。举个例子,

var counter int32 = math.MaxInt32 + 1
constant 2147483648 overflows int32

然而,在运行时,整数溢出或下溢是无声的;这不会导致应用恐慌。将这种行为牢记在心是很重要的,因为它会导致偷偷摸摸的错误(例如,导致负结果的整数递增或正整数相加)。

在深入研究如何用常见操作检测整数溢出之前,让我们考虑一下什么时候应该关注它。在大多数情况下,比如处理请求的计数器或者基本的加法/乘法,如果使用了正确的整数类型,我们不应该太担心。但是在某些情况下,比如使用较小整数类型的内存受限项目,处理较大的数字,或者进行转换,我们可能想要检查可能的溢出。

请注意,1996 年阿丽亚娜 5 号发射失败(www.bugsnag.com/blog/bug-day-ariane-5-disaster)是由于将 64 位浮点转换为 16 位有符号整数导致溢出。

3.2.2 递增时检测整数溢出

如果我们想在基于定义的大小(int8int16int32int64uint8uint16uint32uint64)的类型的递增操作期间检测整数溢出,我们可以对照math常量检查该值。例如,用一个int32:

func Inc32(counter int32) int32 {
    if counter == math.MaxInt32 {    // ❶
        panic("int32 overflow")
    }
    return counter + 1
}

❶ 与math.MaxInt32作比较。

该函数检查输入是否已经等于math.MaxInt32。我们知道增量是否会导致溢出,如果是这样的话。

intuint类型有哪些?在 Go 1.17 之前,我们必须手动构建这些常量。现在,math.MaxIntmath.MinIntmath.MaxUint是包math的一部分。如果我们必须在和int类型上测试溢出,我们可以使用math.MaxInt来完成:

func IncInt(counter int) int {
    if counter == math.MaxInt {
        panic("int overflow")
    }
    return counter + 1
}

对于uint来说,逻辑是相同的。我们可以使用math.MaxUint:

func IncUint(counter uint) uint {
    if counter == math.MaxUint {
        panic("uint overflow")
    }
    return counter + 1
}

在这一节中,我们学习了如何在增量运算后检查整数溢出。那么,加法呢?

3.2.3 加法期间检测整数溢出

如何检测加法运算中的整数溢出?答案是重用math.MaxInt:

func AddInt(a, b int) int {
    if a > math.MaxInt-b {       // ❶
        panic("int overflow")
    }

    return a + b
}

❶ 检查是否会发生整数溢出

在示例中,ab是两个操作数。如果a大于math.MaxInt - b,运算将导致整数溢出。现在,让我们看看乘法运算。

3.2.4 在乘法期间检测整数溢出

乘法处理起来有点复杂。我们必须根据最小整数math.MinInt进行检查:

func MultiplyInt(a, b int) int {
    if a == 0 || b == 0 {                       // ❶
        return 0
    }

    result := a * b
    if a == 1 || b == 1 {                       // ❷
        return result
    }
    if a == math.MinInt || b == math.MinInt {   // ❸
        panic("integer overflow")
    }
    if result/b != a {                          // ❹
        panic("integer overflow")
    }
    return result
}

❶ 如果其中一个操作数等于 0,它直接返回 0。

❷ 检查操作数之一是否等于 1

❸ 检查是否有一个操作数等于数学。米尼特

❹ 检查乘法运算是否会导致整数溢出

用乘法检查整数溢出需要多个步骤。首先,我们需要测试操作数之一是否等于01math.MinInt。然后我们将乘法结果除以b。如果结果不等于原始因子(a,则意味着发生了整数溢出。

总之,整数溢出(和下溢)是 Go 中的无声操作。如果我们想检查溢出以避免偷偷摸摸的错误,我们可以使用本节中描述的实用函数。还要记住 Go 提供了一个处理大数的包:math/big。如果一个int还不够,这可能是一个选择。

我们将在下一节继续讨论浮点的基本 Go 类型。

3.3 #19:不理解浮点

在GO中,有两种浮点类型(如果我们省略虚数的话):float32float64。发明浮点的概念是为了解决整数的主要问题:它们不能表示小数值。为了避免糟糕的意外,我们需要知道浮点运算是实数运算的近似。让我们来看看使用近似值的影响以及如何提高精确度。为此,我们来看一个乘法示例:

var n float32 = 1.0001
fmt.Println(n * n)

我们可能期望这段代码打印出1.0001 * 1.0001 = 1.00020001的结果,对吗?但是,在大多数 x86 处理器上运行它会打印出 1.0002。我们该如何解释?我们需要先了解浮点运算。

让我们以float64型为例。请注意,在math.SmallestNonzeroFloat64(最小值float64)和math.MaxFloat64(最大值float64)之间有无限多个实数值。相反,float64类型有有限的位数:64。因为让无限的值适合一个有限的空间是不可能的,我们必须使用近似值。因此,我们可能会失去精度。同样的逻辑也适用于和float32型。

Go 中的浮点遵循 IEEE-754 标准,一些位代表尾数,其他位代表指数。尾数是基值,而指数是应用于尾数的乘数。在单精度浮点类型(float32)中,8 位表示指数,23 位表示尾数。在双精度浮点类型(float64)中,指数和尾数的值分别是 11 位和 52 位。剩余的位用于符号。要将浮点转换为小数,我们使用以下计算方法:

sign * 2^exponent * mantissa

图 3.1 将 1.0001 表示为一个float32。指数使用 8 位超额/偏差符号:01111111 指数值表示2^0,而尾数等于 1.000100016593933。(注意,本节的范围不是解释转换是如何工作的。)因此,十进制值等于1 × 2^0 × 1.000100016593933。因此,我们在单精度浮点值中存储的不是 1.0001,而是 1.000100016593933。缺乏精度会影响存储值的准确性。

图 3.1float32中 1.0001 的表示

一旦我们理解了float32float64是近似值,这对我们作为开发者意味着什么呢?第一个含义与比较有关。使用==操作符来比较两个浮点数会导致不准确。相反,我们应该比较它们的差异,看它是否小于某个小错误值。例如,testify测试库(github.com/stretchr/testify)有一个InDelta函数来断言两个值在彼此给定的增量内。

还要记住,浮点计算的结果取决于实际的处理器。大多数处理器都有一个浮点单元(FPU)来处理这样的计算。不能保证在一台机器上执行的结果在另一台具有不同 FPU 的机器上是相同的。使用 delta 比较两个值是在不同机器上实现有效测试的一种解决方案。

浮点数的种类

Go 还有三种特殊的浮点数:

  • 正无穷大

  • 负无穷大

  • NaN(非数字),是未定义或不可表示的运算的结果

根据 IEEE-754,NaN 是唯一满足f != f的浮点数。下面是一个构建这些特殊类型的数字以及输出的示例:

var a float64
positiveInf := 1 / a
negativeInf := -1 / a
nan := a / a
fmt.Println(positiveInf, negativeInf, nan)
+Inf -Inf NaN

我们可以用math.IsInf检查一个浮点数是否无穷大,用math.IsNaN检查它是否为 NaN。

到目前为止,我们已经看到十进制到浮点的转换会导致精度的损失。这是转换造成的错误。还要注意,错误会在一系列浮点运算中累积。

让我们来看一个例子,其中有两个函数以不同的顺序执行相同的操作序列。在我们的例子中,f1通过将一个float64初始化为 10,000 开始,然后重复地将 1.0001 加到这个结果上(n次)。反之,f2执行相同的操作,但顺序相反(最后加 10,000):

func f1(n int) float64 {
    result := 10_000.
    for i := 0; i < n; i++ {
        result += 1.0001
    }
    return result
}

func f2(n int) float64 {
    result := 0.
    for i := 0; i < n; i++ {
        result += 1.0001
    }
    return result + 10_000.
}

现在,让我们在 x86 处理器上运行这些函数。然而这一次,我们将改变n

n确切的结果f1f2
1010010.00110010.00099999999310010.001
1k11000.111000.09999999929311000.099999999982
1m1.0101e+061.0100999999761417e+061.010099999766762 e+06

注意n越大,不精确性越大。不过我们也可以看到f2的精度比f1好。请记住,浮点计算的顺序会影响结果的准确性。

当执行一连串的加法和减法时,我们应该将运算分组,以便在加或减幅度不接近的值之前加或减幅度相似的值。因为f2加了 10000,最后产生的结果比f1更准确。

乘法和除法呢?假设我们想要计算以下内容:

a × (b + c)

我们知道,这个计算等于

a × b + a × c

让我们用与bc不同数量级的a来运行这两个计算:

a := 100000.001
b := 1.0001
c := 1.0002

fmt.Println(a * (b + c))
fmt.Println(a*b + a*c)
200030.00200030004
200030.0020003

精确的结果是 200,030.002。因此,第一种计算的准确性最差。事实上,当执行涉及加、减、乘或除的浮点计算时,我们必须首先完成乘法和除法运算才能获得更好的精度。有时,这可能会影响执行时间(在前面的示例中,它需要三个操作,而不是两个)。在这种情况下,这是准确性和执行时间之间的选择。

Go 的float32float64是近似值。因此,我们必须牢记一些规则:

  • 比较两个浮点数时,检查它们的差值是否在可接受的范围内。

  • 在执行加法或减法时,将具有相似数量级的运算分组,以获得更高的精度。

  • 为了提高准确性,如果一系列运算需要加、减、乘或除,请先执行乘法和除法运算。

下一节开始我们对切片的研究。它讨论了两个至关重要的概念:切片的长度和容量。

3.4 #20:不了解切片长度和容量

Go 开发者混淆切片长度和容量或者没有彻底理解它们是很常见的。吸收这两个概念对于有效处理核心操作是必不可少的,比如切片初始化和用append添加元素、复制或切片。这种误解可能导致次优地使用切片,甚至导致内存泄漏(我们将在后面的章节中看到)。

在 Go 中,一个切片由一个数组支持。这意味着切片的数据连续存储在一个数组数据结构中。切片还处理在后备数组已满时添加元素或在后备数组几乎为空时收缩后备数组的逻辑。

在内部,一个片包含一个指向后备数组的指针,加上一个长度和一个容量。长度是切片包含的元素数量,而容量是支持数组中的元素数量。让我们来看几个例子,让事情更清楚。首先,让我们用给定的长度和容量初始化一个切片:

s := make([]int, 3, 6)     // ❶

❶ 长度为三,容量为六的切片

第一个参数代表长度,是必需的。但是,代表容量的第二个参数是可选的。图 3.2 显示了这段代码在内存中的结果。

图 3.2 一个三长度、六容量的切片

在本例中,make创建了一个包含六个元素(容量)的数组。但是因为长度被设置为3,Go 只初始化前三个元素。此外,因为切片是一个[]int类型的,前三个元素被初始化为一个int : 0的零值。灰色元素已分配但尚未使用。

如果我们打印这个切片,我们得到长度范围内的元素,[0 0 0]。如果我们将s[1]设置为1,切片的第二个元素会更新,而不会影响其长度或容量。图 3.3 说明了这一点。

图 3.3 更新切片的第二个元素:s[1] = 1

然而,访问长度范围之外的元素是被禁止的,即使它已经在内存中被分配了。例如,s[4] = 0会导致以下恐慌:

panic: runtime error: index out of range [4] with length 3

如何利用切片剩余的空间?通过使用的append内置函数:

s = append(s, 2)

这段代码向现有的s切片追加一个新元素。它使用第一个灰显的元素(已分配但尚未使用)来存储元素2,如图 3.4 所示。

图 3.4 将元素添加到s

切片的长度从 3 更新为 4,因为切片现在包含四个元素。现在,如果我们再添加三个元素,使得支持数组不够大,会发生什么呢?

s = append(s, 3, 4, 5)
fmt.Println(s)

如果我们运行这段代码,我们会看到切片能够处理我们的请求:

[0 1 0 2 3 4 5]

因为数组是固定大小的结构,所以它可以存储新元素,直到元素 4。当我们想要插入元素 5 时,数组已经满了:Go 内部通过将容量加倍,复制所有元素,然后插入元素 5 来创建另一个数组。图 3.5 显示了这个过程。

图 3.5 由于初始后备数组已满,Go 创建另一个数组并复制所有元素。

注意在 Go 中,一个切片的大小增加一倍,直到它包含 1,024 个元素,之后增长 25%。

切片现在引用新的支持数组。之前的后备数组会怎么样?如果不再被引用,如果被分配到堆上,它最终会被垃圾收集器(GC)释放。(我们在错误#95“不理解栈和堆”中讨论堆内存,我们在错误#99“不理解 GC 如何工作”中查看 GC 如何工作)

切片会发生什么?切片是在数组或切片上做的操作,提供半开范围;包括第一个索引,而排除第二个索引。以下示例显示了影响,图 3.6 显示了内存中的结果:

s1 := make([]int, 3, 6)    // ❶
s2 := s1[1:3]              // ❷

❶ 长度为三,容量为六的切片

❷ 从索引 1 到 3 的切片

图 3.6 切片s1s2引用了具有不同长度和容量的相同支持数组。

首先,s1被创建为三长度、六容量的切片。当通过切片s1创建s2时,两个切片引用同一个后备数组。然而,s2从不同的索引 1 开始。所以它的长度和容量(一个两长度,五容量切片)和s1不一样。如果我们更新s1[1]s2[0],变化是对同一个数组进行的,因此,在两个切片中都可见,如图 3.7 所示。

图 3.7 因为s1s2是由同一个数组支持的,更新一个公共元素会使变化在两个切片中都可见。

现在,如果我们向s2追加一个元素会发生什么?下面的代码也改变了s1吗?

s2 = append(s2, 2)

共享后备数组被修改,但只有s2的长度改变。图 3.8 显示了向s2追加一个元素的结果。

图 3.8 将元素添加到s2

s1仍然是三长度、六容量的切片。因此,如果我们打印s1s2,添加的元素仅对s2可见:

s1=[0 1 0], s2=[1 0 2]

理解这种行为很重要,这样我们在使用append时就不会做出错误的假设。

注意在这些例子中,支持数组是内部的,Go 开发者不能直接使用。唯一的例外是通过对现有数组切片来创建切片。

最后要注意的一点是:如果我们一直将元素追加到s2直到后备数组满了会怎么样?就内存而言,状态会是什么?让我们再添加三个元素,这样后备数组将没有足够的容量:

s2 = append(s2, 3)
s2 = append(s2, 4)
s2 = append(s2, 5)     // ❶

❶ 在这个阶段,后备数组已经满了。

这段代码导致创建另一个后备数组。图 3.9 显示了内存中的结果。

图 3.9 将元素追加到s2直到后备数组已满

s1s2现在引用了两个不同的数组。由于s1仍然是一个三长度、六容量的片,它仍然有一些可用的缓冲区,所以它继续引用初始数组。此外,新的支持数组是通过从s2的第一个索引复制初始数组制成的。这就是为什么新数组从元素1开始,而不是0

总而言之,切片长度是切片中可用元素的数量,而切片容量是后备数组中元素的数量。将一个元素添加到一个完整的片(length == capacity)会导致创建一个具有新容量的新后备数组,从以前的数组中复制所有元素,并将片指针更新到新数组。

在下一节中,我们将长度和容量的概念用于片初始化。

3.5 #21:低效的切片初始化

在使用make初始化一个片时,我们看到我们必须提供一个长度和一个可选容量。忘记为这两个参数传递合适的值是一个普遍的错误。让我们精确地看看什么时候这被认为是合适的。

假设我们想要实现一个convert函数,将一个Foo的片映射到一个Bar的片,两个片将具有相同数量的元素。这是第一个实现:

func convert(foos []Foo) []Bar {
    bars := make([]Bar, 0)                   // ❶

    for _, foo := range foos {
        bars = append(bars, fooToBar(foo))   // ❷
    }
    return bars
}

❶ 创建结果切片

❷ 将一个Foo转换成一个Bar,并将其添加到切片中

首先,我们使用make([]Bar, 0)初始化一个空的Bar元素片段。然后,我们使用append来添加Bar元素。起初,bars是空的,所以添加第一个元素会分配一个大小为 1 的后备数组。每当后备数组满了,Go 就通过加倍其容量来创建另一个数组(在上一节中讨论过)。

当我们添加第三个元素、第五个元素、第九个元素等等时,这种因为当前数组已满而创建另一个数组的逻辑会重复多次。假设输入切片有 1,000 个元素,该算法需要分配 10 个后备数组,并将总共 1,000 多个元素从一个数组复制到另一个数组。这导致了 GC 清理所有这些临时后备数组的额外工作。

就性能而言,没有什么好的理由不帮助 Go 运行时。对此有两种不同的选择。第一种选择是重用相同的代码,但分配给定容量的片:

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, 0, n)                // ❶

    for _, foo := range foos {
        bars = append(bars, fooToBar(foo))   // ❷
    }
    return bars
}

❶ 用零长度和给定的容量初始化

❷ 追加一个新元素并更新bar

唯一的变化是创建容量等于n、长度为foosbars

在内部,Go 预分配了一个由n个元素组成的数组。因此,增加 n 个元素意味着重用相同的后备数组,从而大大减少分配的数量。第二种选择是分配给定长度的bars:

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, n)         // ❶

    for i, foo := range foos {
        bars[i] = fooToBar(foo)    // ❷
    }
    return bars
}

❶ 用给定的长度初始化

❷ 设置切片的元素i

因为我们用长度初始化切片,所以已经分配了n个元素并将其初始化为零值Bar。因此,要设置元素,我们必须使用bars[i]而不是append

哪个选项最好?让我们用这三个解决方案和 100 万个元素的输入片段运行一个基准测试:

BenchmarkConvert_EmptySlice-4        22     49739882 ns/op     // ❶
BenchmarkConvert_GivenCapacity-4     86     13438544 ns/op     // ❷
BenchmarkConvert_GivenLength-4       91     12800411 ns/op     // ❸

❶ 第一个解决方案是空切片

❷ 第二个解决方案使用给定容量并追加

❸ 第三个解决方案使用给定长度,并设置元素i

正如我们所看到的,第一个解决方案对性能有重大影响。当我们不断分配数组和复制元素时,第一个基准测试比另外两个几乎慢了 400%。比较第二个和第三个解决方案,第三个方案大约快 4%,因为我们避免了重复调用内置的append函数,与直接赋值相比,它的开销很小。

如果设置一个容量并使用append比设置一个长度并分配给一个直接索引效率更低,为什么我们看到这种方法在 Go 项目中使用?我们来看 Pebble 中的一个具体例子,这是蟑螂实验室(github.com/cockroachdb/pebble)开发的一个开源键值存储。

一个名为collectAllUserKeys的函数需要遍历一片结构来格式化一个特定的字节切片。结果切片的长度将是输入切片的两倍:

func collectAllUserKeys(cmp Compare,
    tombstones []tombstoneWithLevel) [][]byte {
    keys := make([][]byte, 0, len(tombstones)*2)
    for _, t := range tombstones {
        keys = append(keys, t.Start.UserKey)
        keys = append(keys, t.End)
    }
    // ...
}

这里,有意识的选择是使用给定的容量和append。有什么道理?如果我们使用给定的长度而不是容量,代码将如下所示:

func collectAllUserKeys(cmp Compare,
    tombstones []tombstoneWithLevel) [][]byte {
    keys := make([][]byte, len(tombstones)*2)
    for i, t := range tombstones {
        keys[i*2] = t.Start.UserKey
        keys[i*2+1] = t.End
    }
    // ...
}

注意处理切片索引的代码看起来有多复杂。鉴于这个函数对性能不敏感,我们决定选择最容易读取的选项。

切片和条件

如果不能精确知道切片的未来长度会怎样?例如,如果输出切片的长度取决于某个条件,那该怎么办?

func convert(foos []Foo) []Bar {
    // bars initialization

    for _, foo := range foos {
        if something(foo) {         // ❶
            // Add a bar element
        }
    }
    return bars
}

❶ 只有在特定条件有效时才添加Foo元素。

在这个例子中,一个Foo元素被转换成一个Bar,并仅在特定条件下(if something(foo))被添加到切片中。我们应该将bars初始化为一个空片还是给定长度或容量?

这里没有严格的规定。这是一个传统的软件问题:CPU 和内存哪个更好交易?也许如果something(foo)在 99%的情况下为真,那么用一个长度或容量初始化bars是值得的。这取决于我们的用例。

将一种切片类型转换成另一种切片类型是 Go 开发人员经常进行的操作。正如我们所看到的,如果未来片的长度是已知的,就没有理由先分配一个空片。我们的选择是分配具有给定容量或给定长度的存储片。在这两种解决方案中,我们已经看到第二种方案要稍微快一些。但是在某些情况下,使用给定的容量和append会更容易实现和读取。

下一节将讨论nil和空切片之间的区别,以及为什么它对 Go 开发者很重要。

3.6 #22:对nil切片和空切片感到困惑

Go 开发者相当频繁地混合nil和空切片。根据具体的使用情况,我们可能希望使用其中的一个。同时,一些库对两者进行了区分。要精通切片,我们需要确保不混淆这些概念。在查看示例之前,让我们先讨论一些定义:

  • 如果切片长度等于0,则切片为空。

  • 如果切片等于nil,则该片为零。

现在,让我们看看初始化切片的不同方法。你能猜出下面代码的输出吗?每次,我们将打印切片是空还是零:

func main() {
    var s []string         // ❶
    log(1, s)

    s = []string(nil)      // ❷
    log(2, s)

    s = []string{}         // ❸
    log(3, s)

    s = make([]string, 0)  // ❹
    log(4, s)
}

func log(i int, s []string) {
    fmt.Printf("%d: empty=%t\tnil=%t\n", i, len(s) == 0, s == nil)
}

❶ 选项 1 (nil值)

❷ 选项 2

❸ 选项 3

❹ 选项 4

此示例打印以下内容:

1: empty=true   nil=true
2: empty=true   nil=true
3: empty=true   nil=false
4: empty=true   nil=false

所有切片都是空的,意味着长度等于0。因此,nil切片也是一个空切片。但是,只有前两个是nil切片。如果我们有多种初始化切片的方法,我们应该选择哪一种?有两件事需要注意:

  • nil切片和空切片的主要区别之一是分配。初始化一个nil切片不需要任何分配,而对于一个空的片来说就不是这样了。

  • 不管一个片是否为零,调用append内置函数都有效。举个例子,

var s1 []string
fmt.Println(append(s1, "foo")) // [foo]

因此,如果一个函数返回一个片,我们不应该像在其他语言中那样,出于防御原因返回一个非零集合。因为一个零片不需要任何分配,我们应该倾向于返回一个零片而不是一个空片。让我们看看这个函数,它返回一段字符串:

func f() []string {
    var s []string
    if foo() {
        s = append(s, "foo")
    }
    if bar() {
        s = append(s, "bar")
    }
    return s
}

如果foobar都为假,我们得到一个空切片。为了防止在没有特殊原因的情况下分配一个空片,我们应该选择选项 1 ( var s []string)。我们可以将选项 4 ( make([]string, 0))与零长度字符串一起使用,但是这样做与选项 1 相比并不会带来任何价值;它需要一个分配。

但是,在我们必须生成一个已知长度的切片的情况下,我们应该使用选项 4,s := make([]string, length),如本例所示:

func intsToStrings(ints []int) []string {
    s := make([]string, len(ints))
    for i, v := range ints {
        s[i] = strconv.Itoa(v)
    }
    return s
}

正如错误#21“低效的片初始化”中所讨论的,我们需要在这样的场景中设置长度(或容量),以避免额外的分配和拷贝。现在,示例中剩下了两个选项,这两个选项研究了初始化切片的不同方法:

  • 选项 2: s := []string(nil)

  • 选项 3: s := []string{}

选项 2 并不是使用最广泛的。但是它作为语法糖是有帮助的,因为我们可以在一行中传递一个nil切片——例如,使用append:

s := append([]int(nil), 42)

如果我们使用选项 1 ( var s []string),它将需要两行代码。这可能不是有史以来最重要的可读性优化,但仍然值得了解。

请注意,在错误#24“没有正确制作切片副本”中,我们将看到一个附加到nil切片的基本原理。

现在,我们来看选项 3: s := []string{}。建议使用此表单创建具有初始元素的切片:

s := []string{"foo", "bar", "baz"}

但是,如果我们不需要用初始元素创建切片,我们就不应该使用这个选项。它带来了与选项 1 ( var s []string)相同的好处,只是切片不是零;因此,它需要分配。因此,应避免没有初始要素的选项 3。

注意,有些 linters 可以在没有初始值的情况下捕捉选项 3,并建议将其更改为选项 1。然而,我们应该记住,这也将语义从非零片改变为零片。

我们还应该提到,一些库区分nil和空片。例如,encoding/json包装就是这种情况。下面的示例封送两个结构,一个包含nil切片,另一个包含非零的空切片:

var s1 []float32                 // ❶
    customer1 := customer{
    ID:         "foo",
    Operations: s1,
}
b, _ := json.Marshal(customer1)
fmt.Println(string(b))

s2 := make([]float32, 0)         // ❷
    customer2 := customer{
    ID:         "bar",
    Operations: s2,
}
b, _ = json.Marshal(customer2)
fmt.Println(string(b))

nil切片

❷ 非nil,空切片

运行此示例时,请注意这两个结构的封送处理结果是不同的:

{"ID":"foo","Operations":null}
{"ID":"bar","Operations":[]}

这里,一个nil切片作为一个null元素被封送,而一个非nil的空片作为一个空数组被封送。如果我们在区分null[]的严格 JSON 客户端的环境中工作,记住这种区别是很重要的。

encoding/json包并不是标准库中唯一做出这种区分的包。例如,如果我们比较一个nil和一个非零的空片,那么reflect.DeepEqual返回false,这是在单元测试的上下文中需要记住的。在任何情况下,当使用标准库或外部库时,我们应该确保当使用一个或另一个版本时,我们的代码不会导致意外的结果。

总结一下,在GO中,nil和空切片是有区别的。nil切片等于nil,而空切片的长度为零。nil切片是空的,但空切片不一定是nil。同时,nil切片不需要任何分配。在本节中,我们已经看到了如何通过使用

  • var s []string如果不确定最终长度,切片可以为空

  • []string(nil)作为语法糖创建一个nil和空切片

  • make([]string, length)如果未来长度已知

如果我们初始化没有元素的切片,那么应该避免最后一个选项[]string{}。最后,让我们检查一下我们使用的库是否区分了nil和空片以防止意外行为。

在下一节中,我们将继续这一讨论,并了解在调用函数后检查空片的最佳方式。

3.7 #23:未正确检查切片是否为空

我们在上一节看到了nil和空切片是有区别的。记住这些概念后,检查切片是否包含元素的惯用方法是什么?没有明确的答案会导致微妙的错误。

在这个例子中,我们调用一个返回一部分float32getOperations函数。只有当切片包含元素时,我们才希望调用一个handle函数。这是第一个(错误的)版本:

func handleOperations(id string) {
    operations := getOperations(id)
    if operations != nil {                  // ❶
        handle(operations)
    }
}

func getOperations(id string) []float32 {
    operations := make([]float32, 0)        // ❷

    if id == "" {
        return operations                   // ❸
    }

    // Add elements to operations

    return operations
}

❶ 检查operations切片是否为nil

❷ 初始化operations切片

❸ 如果提供的id为空,将返回operations

我们通过检查operations切片是否不是nil来确定切片是否有元素。但是这段代码有一个问题:getOperations从不返回一个nil切片;相反,它返回一个空切片。因此,operations != nil检查将始终为true

在这种情况下我们该怎么办?一种方法可能是修改getOperations以在id为空时返回一个nil切片:

func getOperations(id string) []float32 {
    operations := make([]float32, 0)

    if id == "" {
        return nil      // ❶
    }

    // Add elements to operations

    return operations
}

❶ 返回nil而不是operations

如果id为空,我们返回nil,而不是返回operations。这样,我们实现的关于测试片无效匹配的检查。然而,这种方法并不适用于所有情况——我们并不总是处于可以改变被调用者的环境中。例如,如果我们使用一个外部库,我们就不会创建一个拉取请求来将空变成nil切片。

那么我们如何检查一个片是空的还是零呢?解决方法是检查长度:

func handleOperations(id string) {
    operations := getOperations(id)
    if len(operations) != 0 {          // ❶
        handle(operations)
    }
}

❶ 检查切片长度

我们在上一节中提到,根据定义,空切片的长度为零。同时,nil切片总是空的。因此,通过检查切片的长度,我们涵盖了所有场景:

  • 如果切片为nillen(operations) != 0false

  • 如果切片不是nil而是空的,len(operations) != 0也是false

因此,检查长度是最好的选择,因为我们不能总是控制我们调用的函数所采用的方法。与此同时,正如 Go wiki 所言,在设计接口时,我们应该避免区分nil和空切片,这会导致微妙的编程错误。当返回切片时,如果我们返回一个nil或空的切片,应该不会产生语义或技术上的差异。对于调用者来说,这两个词的意思应该是一样的。这个原理同样适用于映射。要检查映射是否为空,要检查它的长度,而不是它是否是nil

在下一节中,我们将了解如何正确制作切片副本。

3.8 #24:没有正确制作切片副本

copy内置函数允许将元素从源片复制到目标片。虽然它是一个方便的内置函数,但 Go 开发者有时会误解它。让我们来看一个导致复制错误数量的元素的常见错误。

在下面的示例中,我们创建了一个切片,并将其元素复制到另一个切片中。这段代码的输出应该是什么?

src := []int{0, 1, 2}
var dst []int
copy(dst, src)
fmt.Println(dst)

如果我们运行这个例子,它打印的是[],而不是[0 1 2]。我们错过了什么?

为了有效地使用copy,必须了解复制到目标切片的元素数量对应于以下值中的最小值:

  • 源切片的长度

  • 目标切片的长度

在前面的例子中,src是一个三长度切片,但是dst是一个零长度切片,因为它被初始化为零值。因此,copy函数复制了最小数量的元素(在 3 和 0 之间):在这种情况下为 0。结果切片是空的。

如果我们要执行完整拷贝,目标切片的长度必须大于或等于源切片的长度。这里,我们根据源切片设置长度:

src := []int{0, 1, 2}
dst := make([]int, len(src))     // ❶
copy(dst, src)
fmt.Println(dst)

❶ 创建一个dst切片,但具有给定的长度

因为dst现在是一个长度等于 3 的初始化切片,所以它复制了三个元素。这一次,如果我们运行代码,它会打印出[0 1 2]

注意另一个常见的错误是在调用copy时颠倒参数的顺序。请记住,目的地是前一个参数,而来源是后一个参数。

我们还要提到,使用copy内置函数并不是复制切片元素的唯一方式。有不同的选择,最著名的可能是下面的,它使用了append:

src := []int{0, 1, 2}
dst := append([]int(nil), src...)

我们将源切片中的元素添加到一个nil切片中。因此,这段代码创建了一个三长度、三容量的切片副本。这种方法的优点是可以在一行中完成。然而,使用copy更符合习惯,因此更容易理解,尽管它需要额外的一行。

将元素从一个片复制到另一个片是相当频繁的操作。使用copy时,我们必须记住复制到目的地的元素数量对应于两个切片长度之间的最小值。还要记住,复制切片还有其他选择,所以如果我们在代码库中找到它们,我们也不应该感到惊讶。

我们继续讨论使用append时常见错误的切片。

3.9 #25:使用切片附加的意外副作用

本节讨论使用append时的一个常见错误,在某些情况下可能会产生意想不到的副作用。在下面的例子中,我们初始化一个s1切片,通过切片s1创建s2,通过向s2追加一个元素创建s3:

s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)

我们初始化一个包含三个元素的s1切片,从切片s1中创建s2。然后我们在s3上调用append。这段代码结尾的这三个切片应该是什么状态?你能猜到吗?

在第二行之后,创建了s2之后,图 3.10 显示了内存中两个片的状态。s1是一个三长度、三容量的片,s2是一个一长度、两容量的片,两者都由我们已经提到的相同数组支持。使用append添加一个元素检查切片是否已满(长度==容量)。如果未满,append函数通过更新后备数组并返回长度增加 1 的切片来添加元素。

图 3.10 两个存储片都由相同的数组支持,但长度和容量不同。

在这个例子中,s2还没有满;它可以再接受一个元素。图 3.11 显示了这三个切片的最终状态。

图 3.11 所有切片都由同一个数组支持。

在后备数组中,我们更新了最后一个元素来存储10。因此,如果我们打印所有切片,我们会得到以下输出:

s1=[1 2 10], s2=[2], s3=[2 10]

虽然我们没有直接更新s1[2]s2[1],但是s1切片的内容已经被修改。我们应该记住这一点,以避免意想不到的后果。

让我们通过将切片操作的结果传递给一个函数来看看这个原则的影响。在下面的例子中,我们用三个元素初始化一个切片,并调用一个只有前两个元素的函数:

func main() {
    s := []int{1, 2, 3}

    f(s[:2])
    // Use s
}

func f(s []int) {
    // Update s
}

在这个实现中,如果f更新了前两个元素,那么这些变化对于main中的片是可见的。然而,如果f调用append,它会更新切片的第三个元素,尽管我们只传递了两个元素。举个例子,

func main() {
    s := []int{1, 2, 3}

    f(s[:2])
    fmt.Println(s) // [1 2 10]
}

func f(s []int) {
    _ = append(s, 10)
}

如果我们出于防御原因想要保护第三个元素,意思是确保f不会更新它,我们有两个选择。

第一种方法是传递切片的副本,然后构造结果切片:

func main() {
    s := []int{1, 2, 3}
    sCopy := make([]int, 2)
    copy(sCopy, s)                    // ❶

    f(sCopy)
    result := append(sCopy, s[2])     // ❷
    // Use result
}

func f(s []int) {
    // Update s
}

❶ 把s的前两个元素复制到sCopy

❷ 将s[2]附加到sCopy上以构建结果切片

因为我们传递了一个副本给f,所以即使这个函数调用了append,也不会导致前两个元素范围之外的副作用。这个选项的缺点是,它使代码阅读起来更复杂,并且增加了一个额外的副本,如果切片很大,这可能是一个问题。

第二个选项可用于将潜在副作用的范围仅限于前两个元素。这个选项涉及到所谓的全切片表达式 : s[low:high:max]。该语句创建一个类似于用s[low:high]创建的片,除了产生的片的容量等于max - low。这里有一个调用f时的例子:

func main() {
    s := []int{1, 2, 3}
    f(s[:2:2])            // ❶
    // Use s
}

func f(s []int) {
    // Update s
}

❶ 使用完整切片表达式传递子切片

这里,传递给f的切片不是s[:2]而是s[:2:2]。因此,切片的容量为 2–0 = 2,如图 3.12 所示。

图 3.12 s[0:2]创建了一个两长度、三容量的切片,而s[0:2:2]创建了一个两长度、两容量的切片。

当通过s[:2:2]时,我们可以将效果范围限制在前两个元素。这样做还可以避免我们必须执行切片拷贝。

使用切片时,我们必须记住,我们可能会面临导致意外副作用的情况。如果结果切片的长度小于其容量,append可以改变原始切片。如果我们想限制可能的副作用的范围,我们可以使用切片复制或完整切片表达式,这将阻止我们进行复制。

在下一节中,我们将继续讨论片,但是是在潜在内存泄漏的背景下。

3.10 #26:切片和内存泄漏

本节说明了在某些情况下,对现有切片或数组进行切片会导致内存泄漏。我们讨论两种情况:一种是容量泄漏,另一种与指针有关。

3.10.1 泄漏容量

对于第一种情况,泄漏容量,让我们设想实现一个定制的二进制协议。一条消息可以包含 100 万字节,前 5 个字节代表消息类型。在我们的代码中,我们使用这些消息,出于审计目的,我们希望在内存中存储最新的 1,000 种消息类型。这是我们功能的框架:

func consumeMessages() {
    for {
        msg := receiveMessage()                  // ❶
        // Do something with msg
        storeMessageType(getMessageType(msg))    // ❷
    }
}

func getMessageType(msg []byte) []byte {         // ❸
    return msg[:5]
}

❶ 收到赋值给msg的新[]byte切片

❷ 在内存中存储了最新的 1000 种消息类型

❸ 通过对消息进行切片来计算消息类型

getMessageType函数通过对输入切片进行切片来计算消息类型。我们测试了这个实现,一切正常。然而,当我们部署应用时,我们注意到应用消耗了大约 1 GB 的内存。这怎么可能呢?

使用msg[:5]msg上的切片操作创建了一个五长度切片。但是,其容量与初始切片保持不变。剩余的元素仍然分配在内存中,即使最终msg没有被引用。让我们看一个例子,它有一个 100 万字节的大消息,如图 3.13 所示。

图 3.13 一次新的循环迭代后,msg不再使用。但是它的后备数组还是会被msg[:5]使用。

切片操作后,切片的支持数组仍包含 100 万字节。因此,如果我们在内存中保存 1,000 条消息,而不是存储大约 5 KB,我们将保存大约 1 GB。

我们能做些什么来解决这个问题?我们可以制作切片副本来代替切片msg:

func getMessageType(msg []byte) []byte {
    msgType := make([]byte, 5)
    copy(msgType, msg)
    return msgType
}

因为我们执行复制,msgType是一个五长度、五容量的片段,不管接收到的消息有多大。因此,我们每种消息类型只存储 5 个字节。

全切片表达式和容量泄漏

用全切片表达式来解决这个问题怎么样?让我们看看这个例子:

func getMessageType(msg []byte) []byte {
    return msg[:5:5]
}

这里,getMessageType返回初始切片的缩小版本:一个五长度、五容量的切片。但是 GC 能够从字节 5 中回收不可访问的空间吗?Go 规范没有正式指定行为。然而,通过使用runtime.Memstats,我们可以记录关于内存分配器的统计数据,比如在堆上分配的字节数:

func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}

如果我们在调用getMessageTyperuntime.GC()之后调用这个函数来强制运行垃圾收集,不可访问的空间不会被回收。整个后备数组仍然存在于内存中。因此,使用完整切片表达式不是一个有效的选项(除非 Go 的未来更新解决了这个问题)。

根据经验,记住对一个大的切片或数组进行切片可能会导致潜在的高内存消耗。剩余的空间不会被 GC 回收,我们可以保留一个大的后备数组,尽管只使用了很少的元素。使用切片拷贝是防止这种情况的解决方案。

3.10.2 切片和指针

我们已经看到切片会因为切片容量而导致泄漏。但是元素呢,它们仍然是支持数组的一部分,但是在长度范围之外。GC 收集它们吗?

让我们使用一个包含字节切片的Foo结构来研究这个问题:

type Foo struct {
    v []byte
}

我们希望在每个步骤之后检查内存分配,如下所示:

  1. 分配 1000 个Foo元素的切片。

  2. 迭代每个Foo元素,对于每个元素,为v片分配 1 MB。

  3. 调用keepFirstTwoElementsOnly,它使用切片只返回前两个元素,然后调用一个 GC。

我们想看看内存在调用keepFirstTwoElementsOnly和垃圾收集之后的表现。下面是 Go 中的场景(我们重用了前面提到的printAlloc函数):

func main() {
    foos := make([]Foo, 1_000)              // ❶
    printAlloc()

    for i := 0; i < len(foos); i++ {        // ❷
        foos[i] = Foo{
            v: make([]byte, 1024*1024),
        }
    }
    printAlloc()

    two := keepFirstTwoElementsOnly(foos)   // ❸
    runtime.GC()                            // ❹
    printAlloc()
    runtime.KeepAlive(two)                  // ❺
}

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    return foos[:2]
}

❶ 分配 1000 个元素的切片

❷ 为每个元素分配一个 1 MB 的切片

❸ 只保留了前两个元素

❹ 运行 GC 来强制清理堆

❺ 保留了对这两个变量的引用

在这个例子中,我们分配了foos片,为每个元素分配一个 1 MB 的片,然后调用keepFirstTwoElementsOnly和一个 GC。最后,我们使用runtime .KeepAlive在垃圾收集之后保留一个对two变量的引用,这样它就不会被收集。

我们可能期望 GC 收集剩余的 998 个Foo元素和为片分配的数据,因为这些元素不再能被访问。然而,事实并非如此。例如,代码可以输出以下内容:

83 KB
1024072 KB
1024072 KB     // ❶

切片手术后的// ❶

第一个输出分配了大约 83 KB 的数据。的确,我们分配了 1000 个Foo的零值。第二个结果为每个片分配 1 MB,这增加了内存。但是,请注意,在最后一步之后,GC 没有收集剩余的 998 个元素。原因是什么?

使用切片时,一定要记住这条规则:如果元素是指针或带有指针字段的结构,那么元素不会被 GC 回收。在我们的例子中,因为Foo包含一个切片(切片是后备数组顶部的指针),所以剩余的 998 个Foo元素和它们的切片没有被回收。因此,即使这 998 个元素不能被访问,只要被引用了由keepFirstTwoElementsOnly返回的变量,它们就留在内存中。

有哪些选项可以确保我们不会泄露剩余的Foo元素?同样,第一个选项是创建切片的副本:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    res := make([]Foo, 2)
    copy(res, foos)
    return res
}

因为我们复制了切片的前两个元素,GC 知道这 998 个元素将不再被引用,现在可以被收集了。

如果我们想要保持 1000 个元素的底层容量,还有第二个选择,就是将剩余元素的切片显式标记为nil:

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
    for i := 2; i < len(foos); i++ {
        foos[i].v = nil
    }
    return foos[:2]
}

这里,我们返回一个 2 长度、1000 容量的切片,但是我们将剩余元素的切片设置为nil。因此,GC 可以收集 998 个后备数组。

哪个选项是最好的?如果我们不想将容量保持在 1000 个元素,第一个选项可能是最好的。然而,决定也可以取决于元素的比例。图 3.14 提供了一个我们可以选择的选项的可视化例子,假设一个切片包含了我们想要保存i元素的n元素。

图 3.14 选项 1 迭代到i,而选项 2 从i开始迭代。

第一个选项创建一个i元素的副本。因此,它必须从元素 0 迭代到i。第二个选项将剩余的片设置为零,因此它必须从元素i迭代到n。如果性能很重要,并且i比 0 更接近于n,我们可以考虑第二个选项。这需要迭代更少的元素(至少,可能值得对这两个选项进行基准测试)。

在本节中,我们看到了两个潜在的内存泄漏问题。第一个是对现有存储片或数组进行切片以保留容量。如果我们处理大的切片并重新切片以只保留一小部分,那么大量的内存将仍然被分配但未被使用。第二个问题是,当我们使用带有指针的切片操作或带有指针字段的结构时,我们需要知道 GC 不会回收这些元素。在这种情况下,有两种选择,要么执行复制,要么显式地将剩余的元素或它们的字段标记为nil

现在,让我们在初始化的上下文中讨论映射。

3.11 #27:低效的映射初始化

本节讨论了一个类似于切片初始化的问题,但是使用了贴图。但是首先,我们需要知道关于如何在 Go 中实现映射的基础知识,以理解为什么调整映射初始化是重要的。

3.11.1 概念

一个映射提供了一个无序的键值对集合,其中所有的键都是不同的。在 Go 中,映射是基于哈希表数据结构的。在内部,哈希表是一个桶的数组,每个桶是一个指向键值对数组的指针,如图 3.15 所示。

图 3.15 中的哈希表后面是一个由四个元素组成的数组。如果我们检查数组索引,我们会注意到一个由单个键值对(元素)组成的桶:"two" / 2。每个桶有八个元素的固定大小。

图 3.15 关注桶 0 的散列表示例

每个操作(读取、更新、插入、删除)都是通过将一个键与一个数组索引相关联来完成的。这个步骤依赖于散列函数。这个函数是稳定的,因为我们希望它返回相同的桶,给定相同的键,保持一致。在前面的例子中,hash("two")返回 0;因此,该元素存储在数组索引 0 引用的桶中。

如果我们插入另一个元素,并且散列键返回相同的索引,Go 将另一个元素添加到相同的桶中。图 3.16 显示了这个结果。

图 3.16 hash("six")返回 0;因此,元素存储在同一个桶中。

在插入一个已经满了的桶(桶溢出)的情况下,Go 创建另一个包含八个元素的桶,并将前一个桶链接到它。图 3.17 给出了这个结果。

图 3.17 在桶溢出的情况下,Go 分配一个新的桶,并将前一个桶链接到它。

关于读取、更新和删除,Go 必须计算相应的数组索引。然后 Go 依次遍历所有的键,直到找到提供的键。因此,这三个操作的最坏情况时间复杂度是O(p),其中p是桶中元素的总数(默认为一个桶,溢出时为多个桶)。

现在让我们讨论一下为什么有效地初始化映射很重要。

3.12.2 初始化

为了理解与低效的映射初始化相关的问题,让我们创建一个包含三个元素的map[string]int类型:

m := map[string]int{
    "1": 1,
    "2": 2,
    "3": 3,
}

在内部,这个映射由一个包含单个条目的数组支持:因此,只有一个桶。如果我们增加 100 万个元素会发生什么?在这种情况下,单个条目是不够的,因为在最坏的情况下,找到一个键意味着要遍历数千个桶。这就是为什么映射应该能够自动增长以应对元素的数量。

当一个映射增长时,它的桶的数量会翻倍。映射成长的条件是什么?

  • 桶中物品的平均数量(称为装载系数)大于一个恒定值。这个常数等于 6.5(但是在未来的版本中可能会改变,因为它是内部的)。

  • 过多的桶溢出(包含八个以上的元素)。

当一个映射增长时,所有的键被再次分配给所有的桶。这就是为什么在最坏的情况下,插入一个键可以是一个O(n)操作,其中n是图中元素的总数。

我们看到,当使用切片时,如果我们预先知道要添加到切片中的元素数量,我们可以用给定的大小或容量初始化它。这避免了必须不断重复代价高昂的切片增长操作。这个想法对于映射来说是类似的。事实上,我们可以使用make内置函数来在创建映射时提供初始大小。例如,如果我们想要初始化一个包含一百万个元素的映射,可以这样做:

m := make(map[string]int, 1_000_000)

有了映射,我们可以只给内置函数make一个初始大小,而不是容量,就像切片一样:因此,只有一个参数。

通过指定大小,我们提供了一个关于预期进入映射的元素数量的提示。在内部,使用适当数量的存储桶来创建映射,以存储一百万个元素。这节省了大量的计算时间,因为映射不必动态创建存储桶和处理重新平衡存储桶。

此外,指定一个尺寸n并不意味着用最大数量的n元素来制作映射。如果需要,我们仍然可以添加多于 n 个元素。相反,这意味着要求 Go 运行时为至少n个元素分配一个映射空间,如果我们事先已经知道元素的大小,这是很有帮助的。

为了理解为什么指定大小很重要,让我们运行两个基准测试。第一个示例在没有设置初始大小的情况下在一个映射中插入一百万个元素,而我们用一个大小来初始化第二个映射:

BenchmarkMapWithoutSize-4     6    227413490 ns/op
BenchmarkMapWithSize-4       13     91174193 ns/op

第二个版本,初始大小,大约快 60%。通过提供一个大小,我们可以防止映射增长以适应插入的元素。

因此,就像切片一样,如果我们预先知道映射将包含的元素数量,我们应该通过提供初始大小来创建它。这样做避免了潜在的映射增长,这在计算上是相当繁重的,因为它需要重新分配足够的空间和重新平衡所有的元素。

让我们继续关于映射的讨论,看看一个导致内存泄漏的常见错误。

3.12 #28:映射和内存泄漏

在 Go 中使用映射时,我们需要了解映射如何增长和收缩的一些重要特征。让我们深入研究这个问题,以防止可能导致内存泄漏的问题。

首先,为了查看此问题的具体示例,让我们设计一个场景,其中我们将使用以下映射:

m := make(map[int][128]byte)

m的每个值都是一个 128 字节的数组。我们将执行以下操作:

  1. 分配一个空映射。

  2. 添加 100 万个元素。

  3. 移除所有元素,并运行 GC。

在每一步之后,我们想要打印堆的大小(这次使用 MB)。这向我们展示了这个例子在内存方面的表现:

n := 1_000_000
m := make(map[int][128]byte)
printAlloc()

for i := 0; i < n; i++ {      // ❶
    m[i] = randBytes()
}
printAlloc()

for i := 0; i < n; i++ {      // ❷
    delete(m, i)
}

runtime.GC()                  // ❸
printAlloc()
runtime.KeepAlive(m)          // ❹

❶ 添加 100 万个元素

❷ 删除一百万个元素

❸ 触发了手动 GC

❹ 保留了一个对m的引用,这样映射就不会被收集

我们分配一个空的映射,添加一百万个元素,删除一百万个元素,然后运行一个 GC。我们还确保使用runtime .KeepAlive保存对映射的引用,这样映射就不会被收集。让我们运行这个例子:

0 MB       // ❶
461 MB     // ❷
293 MB     // ❸

❶ 在m被分配后

❷ 我们添加 100 万个元素后

❸ 在我们移除一百万个元素后

我们能观察到什么?起初,堆的大小是最小的。然后,在映射上添加了一百万个元素后,它会显著增长。但是,如果我们期望在移除所有元素后堆的大小会减小,那么这不是映射在 Go 中的工作方式。最后,即使 GC 已经收集了所有的元素,堆的大小仍然是 293 MB。所以内存缩小了,但并不像我们预期的那样。有什么道理?

我们在上一节中讨论了一个映射由八个元素的桶组成。在幕后,Go映射是一个指向runtime.hmap结构的指针。该结构包含多个字段,包括一个B字段,给出了映射中的桶数:

type hmap struct {
    B uint8 // log_2 of # of buckets
            // (can hold up to loadFactor * 2^B items)
    // ...
}

添加 100 万个元素后,B的值等于 18,这意味着2^18 = 262144个桶。当我们去掉 100 万个元素,B的值是多少?还是 18。因此,映射仍然包含相同数量的桶。

原因是映射中的存储桶数量不能减少。因此,从映射中删除元素不会影响现有存储桶的数量;它只是将桶中的槽归零。一张映射只能成长,只能有更多的桶;它从不缩水。

在前面的例子中,我们从 461 MB 增加到 293 MB,因为收集了元素,但是运行 GC 并不影响映射本身。甚至额外桶(由于溢出而创建的桶)的数量也保持不变。

让我们后退一步,讨论一下映射不能缩小的事实何时会成为问题。想象使用map[int][128]byte构建一个缓存。这个映射包含每个客户 ID,一个 128 字节的序列。现在,假设我们想要保留最后 1000 名客户。映射大小将保持不变,所以我们不应该担心映射不能缩小的事实。

然而,假设我们想要存储一个小时的数据。与此同时,我们公司决定在黑色星期五进行一次大促销:一小时后,我们可能会有数百万客户连接到我们的系统。但是在黑色星期五之后的几天,我们的映射将包含与高峰时间相同数量的桶。这解释了为什么我们会经历高内存消耗,而在这种情况下不会显著减少。

如果我们不想手动重启服务来清理映射消耗的内存量,有什么解决方案?一种解决方案可以是定期重新创建当前映射的副本。比如每个小时,我们可以建立一个新的映射,复制所有的元素,释放上一个。这种方法的主要缺点是,在复制之后直到下一次垃圾收集之前,我们可能会在短时间内消耗两倍于当前的内存。

另一个解决方案是改变映射类型来存储数组指针:map[int]*[128]byte。它没有解决我们将会有相当数量的桶的事实;然而,每个桶条目将为该值保留指针的大小,而不是 128 字节(在 64 位系统上是 8 字节,在 32 位系统上是 4 字节)。

回到最初的场景,让我们按照每个步骤比较每个映射类型的内存消耗。下表显示了这种比较。

步骤map[int][128]bytemap[int]*[128]byte
分配一个空映射。0 MB0 MB
添加 100 万个元素。461 MB182 MB
移除所有元素并运行 GC。293 MB38 MB

正如我们所看到的,删除所有元素后,使用map[int]*[128]byte类型所需的内存量明显减少。此外,在这种情况下,由于一些减少内存消耗的优化,高峰时间所需的内存量不太重要。

注意如果一个键或者一个值超过 128 个字节,Go 不会把它直接存储在映射桶中。相反,Go 存储一个指针来引用键或值。

正如我们已经看到的,向一个映射添加n个元素,然后删除所有元素意味着在内存中保持相同数量的存储桶。所以,我们必须记住,因为 Go 映射的大小只会增加,所以它的内存消耗也会增加。没有自动化的策略来缩小它。如果这导致高内存消耗,我们可以尝试不同的选项,如强制 Go 重新创建映射或使用指针检查是否可以优化。

在本章的最后一节,我们来讨论在 Go 中比较数值。

3.13 #29:不正确地比较值

比较数值是软件开发中常见的操作。我们经常实现比较:编写一个函数来比较两个对象,测试来比较一个值和一个期望值,等等。我们的第一反应可能是在任何地方都使用==操作符。但是正如我们将在本节中看到的,情况不应该总是这样。那么什么时候使用==比较合适,有哪些替代方案呢?

要回答这些问题,我们先来看一个具体的例子。我们创建一个基本的customer结构并使用==来比较两个实例。在您看来,这段代码的输出应该是什么?

type customer struct {
    id string
}

func main() {
    cust1 := customer{id: "x"}
    cust2 := customer{id: "x"}
    fmt.Println(cust1 == cust2)
}

比较这两个customer结构是 Go 中的有效操作,它会打印true。现在,如果我们稍微修改一下customer结构,添加一个切片字段,会发生什么呢?

type customer struct {
    id         string
    operations []float64      // ❶
}

func main() {
    cust1 := customer{id: "x", operations: []float64{1.}}
    cust2 := customer{id: "x", operations: []float64{1.}}
    fmt.Println(cust1 == cust2)
}

❶ 新字段

我们可能希望这段代码也能打印出true。然而,它甚至不能编译:

invalid operation:
    cust1 == cust2 (struct containing []float64 cannot be compared)

该问题与==!=操作器的工作方式有关。这些运算符不适用于切片或贴图。因此,因为customer结构包含一个片,所以它不能编译。

了解如何使用==!=进行有效的比较是非常重要的。我们可以在可比的操作数上使用这些操作符:

  • 布尔型——比较两个布尔型是否相等。

  • 数值 (整数、浮点和复数类型)——比较两个数值是否相等。

  • 字符串——比较两个字符串是否相等。

  • 通道——比较两个通道是否由同一个对make的调用创建,或者是否都是nil

  • 接口——比较两个接口是否具有相同的动态类型和相等的动态值,或者是否都是nil

  • 指针——比较两个指针是否指向内存中的同一个值或者是否都是nil

  • 结构和数组——比较它们是否由相似的类型组成。

注意我们也可以使用>=<>操作符,对数字类型使用这些操作符来比较值,对字符串使用这些操作符来比较它们的词汇顺序。

在最后一个例子中,我们的代码编译失败,因为结构是在不可比较的类型(片)上构成的。

我们还需要知道将==!=any类型一起使用可能出现的问题。例如,允许比较分配给any类型的两个整数:

var a any = 3
var b any = 3
fmt.Println(a == b)

该代码打印:

true

但是如果我们初始化两个customer类型(最新版本包含一个切片字段)并将值赋给any类型会怎么样呢?这里有一个例子:

var cust1 any = customer{id: "x", operations: []float64{1.}}
var cust2 any = customer{id: "x", operations: []float64{1.}}
fmt.Println(cust1 == cust2)

这段代码可以编译。但是由于两种类型不能比较,因为customer结构包含一个切片字段,这导致了运行时的错误:

panic: runtime error: comparing uncomparable type main.customer

考虑到这些行为,如果我们必须比较两个切片、两个映射或者两个包含不可比较类型的结构,有什么选择呢?如果我们坚持使用标准库,一个选择是对reflect包使用运行时反射。

反射是元编程的一种形式,它指的是应用自省和修改其结构和行为的能力。比如GO,我们可以用reflect.DeepEqual。该函数通过递归遍历两个值来报告两个元素是否完全相等。它接受的元素是基本类型加上数组、结构、切片、映射、指针、接口和函数。

注意reflect.DeepEqual根据我们提供的类型有特定的行为。使用之前,请仔细阅读文档。

让我们重新运行第一个例子,添加reflect.DeepEqual:

cust1 := customer{id: "x", operations: []float64{1.}}
cust2 := customer{id: "x", operations: []float64{1.}}
fmt.Println(reflect.DeepEqual(cust1, cust2))

尽管customer结构包含不可比较的类型(slice ),但它会像预期的那样运行,打印true

但是,在使用reflect.DeepEqual的时候,有两点需要记住。首先,它区分了空集合和nil集合,正如错误#22 中所讨论的,“混淆nil和空切片。”这是个问题吗?不一定;这取决于我们的用例。例如,如果我们想要比较两个解组操作(比如从 JSON 到 Go 结构)的结果,我们可能想要提高这个差异。但是为了有效地使用reflect.DeepEqual,记住这种行为是值得的。

另一个问题是在大多数语言中相当标准的东西。因为这个函数使用反射,即在运行时自省值以发现它们是如何形成的,所以它有一个性能损失。用不同大小的结构在本地做几个基准测试,平均来说,reflect.DeepEqual==慢 100 倍左右。这可能是支持在测试环境中而不是在运行时使用它的原因。

如果性能是一个关键因素,另一个选择可能是实现我们自己的比较方法。下面是一个比较两个customer结构并返回布尔值的例子:

func (a customer) equal(b customer) bool {
    if a.id != b.id {                             // ❶
        return false
    }
    if len(a.operations) != len(b.operations) {   // ❷
        return false
    }
    for i := 0; i < len(a.operations); i++ {      // ❸
        if a.operations[i] != b.operations[i] {
            return false
        }
    }
    return true
}

❶ 比较id字段

❷ 检查两个切片的长度

❸ 比较了两个切片的每个元素

在这段代码中,我们用对customer结构的不同字段的自定义检查来构建我们的比较方法。在由 100 个元素组成的切片上运行本地基准测试表明,我们的定制equal方法比reflect.DeepEqual快大约 96 倍。

一般来说,我们应该记住==操作符是非常有限的。例如,它不适用于切片和贴图。在大多数情况下,使用reflect.DeepEqual是一种解决方案,但是主要的问题是性能损失。在单元测试的上下文中,一些其他的选项是可能的,比如使用带有go-cmpgithub.com/google/go-cmp)或者testifygithub.com/stretchr/testify)的外部库。然而,如果性能在运行时至关重要,实现我们的定制方法可能是最好的解决方案。

一个额外的注意:我们必须记住标准库有一些现有的比较方法。例如,我们可以使用优化的bytes.Compare函数来比较两个字节切片。在实现一个定制方法之前,我们需要确保我们不会重复发明轮子。

总结

  • 阅读现有代码时,请记住以 0 开头的整数是八进制数。此外,为了提高可读性,通过在八进制整数前面加上前缀0o,使它们显式。

  • 因为在 Go 中整数溢出和下溢是静默处理的,所以你可以实现自己的函数来捕捉它们。

  • 在给定的增量内进行浮点比较可以确保你的代码是可移植的。

  • 执行加法或减法时,将具有相似数量级的运算分组,以提高精确度。还有,先做乘除,再做加减。

  • 理解切片长度和容量之间的区别应该是 Go 开发人员核心知识的一部分。切片长度是切片中可用元素的数量,而切片容量是后备数组中元素的数量。

  • 创建切片时,如果长度已知,用给定的长度或容量初始化切片。这减少了分配的数量并提高了性能。同样的逻辑也适用于映射,您需要初始化它们的大小。

  • 如果两个不同的函数使用由同一数组支持的片,使用复制或完整片表达式是防止append产生冲突的一种方式。但是,如果您想要收缩一个大的切片,只有切片复制可以防止内存泄漏。

  • 使用copy内置函数将一个切片复制到另一个切片,记住复制元素的数量对应于两个切片长度之间的最小值。

  • 使用指针切片或带有指针字段的结构,可以通过将切片操作排除的元素标记为nil来避免内存泄漏。

  • 为了防止常见的混淆,例如在使用encoding/jsonreflect包时,您需要理解nil切片和空切片之间的区别。两者都是零长度、零容量的片,但是只有零片不需要分配。

  • 要检查切片是否不包含任何元素,请检查其长度。无论切片是nil还是空的,该检查都有效。映射也是如此。

  • 为了设计明确的 API,你不应该区分nil和空切片。

  • 一张映射在内存中可以一直增长,但永远不会缩小。因此,如果它导致一些内存问题,您可以尝试不同的选项,例如强制 Go 重新创建映射或使用指针。

  • 要比较 Go 中的类型,如果两个类型是可比较的,可以使用==!=操作符:布尔值、数字、字符串、指针、通道和结构完全由可比较的类型组成。否则,您可以使用reflect.DeepEqual并付出反射的代价,或者使用定制的实现和库。

司、控制结构

本章涵盖

  • 一个range循环如何分配元素值并求值所提供的表达式
  • 处理range循环和指针
  • 防止常见的映射迭代和破环错误
  • 在循环内部使用defer

Go 中的控制结构类似于 C 或 Java 中的控制结构,但在很多方面有很大的不同。比如GO中没有dowhile循环,只有一个广义的for。本章深入探讨与控制结构相关的最常见错误,重点关注循环range,这是一个常见的误解来源。

4.1 #30:忽略元素在范围循环中被复制的事实

range循环是迭代各种数据结构的便捷方式。我们不必处理索引和终止状态。Go 开发人员可能会忘记或者没有意识到range循环是如何赋值的,从而导致常见的错误。首先,让我们提醒自己如何使用一个range循环;然后我们来看看值是如何赋值的。

4.1.1 概念

一个range循环允许迭代不同的数据结构:

  • 字符串

  • 数组

  • 指向数组的指针

  • 切片

  • 映射

  • 接收通道

与经典的for循环相比,range循环是迭代这些数据结构中所有元素的一种便捷方式,这要归功于它简洁的语法。它也更不容易出错,因为我们不必手动处理条件表达式和迭代变量,这可以避免诸如一个接一个的错误之类的错误。下面是一个对字符串片段进行迭代的示例:

s := []string{"a", "b", "c"}
for i, v := range s {
    fmt.Printf("index=%d, value=%s\n", i, v)
}

这段代码循环遍历切片的每个元素。在每次迭代中,当我们迭代一个片时,range产生一对值:一个索引和一个元素值,分别分配给iv。一般来说,range为每个数据结构生成两个值,除了接收通道,它为接收通道生成一个元素(值)。

在某些情况下,我们可能只对元素值感兴趣,而对索引不感兴趣。因为不使用局部变量会导致编译错误,所以我们可以使用空白标识符来替换索引变量,如下所示:

s := []string{"a", "b", "c"}
for _, v := range s {
    fmt.Printf("value=%s\n", v)
}

多亏了空白标识符,我们通过忽略索引并只将元素值赋给v来迭代每个元素。

如果我们对值不感兴趣,我们可以省略第二个元素:

for i := range s {}

既然我们已经用一个range循环刷新了我们的思维,让我们看看在一次迭代中返回什么样的值。

4.1.2 值的复制

理解在每次迭代中如何处理值对于有效使用range循环至关重要。让我们用一个具体的例子来看看它是如何工作的。

我们创建一个包含单个balance字段的account结构:

type account struct {
    balance float32
}

接下来,我们创建一片account结构,并使用一个range循环遍历每个元素。在每次迭代中,我们递增每个accountbalance:

accounts := []account{
    {balance: 100.},
    {balance: 200.},
    {balance: 300.},
}
for _, a := range accounts {
    a.balance += 1000
}

根据这段代码,您认为以下两个选项中的哪一个显示了切片的内容?

  • [{100} {200} {300}]

  • [{1100} {1200} {1300}]

答案是[{100} {200} {300}]。在本例中,range循环不影响切片的内容。我们来看看为什么。

在 Go 中,我们分配的所有内容都是副本:

  • 如果我们赋值一个函数的结果,返回一个结构,它执行该结构的一个拷贝。

  • 如果我们赋值一个函数的结果,返回一个指针,它执行内存地址的复制(在 64 位架构上一个地址是 64 位长)。

牢记这一点以避免常见错误是至关重要的,包括那些与range循环相关的错误。事实上,当一个range循环遍历一个数据结构时,它会将每个元素复制到值变量(第二项)。

回到我们的例子,迭代每个account元素导致一个结构体副本被赋给值变量a。因此,用a.balance += 1000增加余额只会改变值变量(a),而不会改变切片中的元素。

那么,如果我们想要更新切片元素呢?有两个主要选项。第一种选择是使用片索引访问元素。这可以通过使用索引而不是值变量的经典for循环或range循环来实现:

for i := range accounts {                // ❶
    accounts[i].balance += 1000
}

for i := 0; i < len(accounts); i++ {     // ❷
    accounts[i].balance += 1000
}

❶ 使用索引变量来访问切片的元素

❷ 使用传统的for循环

两次迭代具有相同的效果:更新accounts切片中的元素。

我们应该支持哪一个?这要看上下文。如果我们想检查每个元素,第一个循环读写起来会更短。但是如果我们需要控制想要更新哪个元素(比如两个中的一个),我们应该使用第二个循环。

更新切片元素:第三个选项

另一种选择是继续使用range循环并访问值,但是将切片类型修改为一个account指针切片:

accounts := []*account{       // ❶
    {balance: 100.},
    {balance: 200.},
    {balance: 300.},
}
for _, a := range accounts {
    a.balance += 1000         // ❷
}

❶ 将切片类型更新为[]*account

❷ 直接更新切片元素

在这种情况下,正如我们提到的,a变量是存储在切片中的account指针的副本。但是由于两个指针引用同一个结构,a.balance += 1000语句更新切片元素。

然而,这种选择有两个主要缺点。首先,它需要更新切片类型,这并不总是可能的。第二,如果性能很重要,我们应该注意到,由于缺乏可预测性,迭代指针片对 CPU 来说可能效率较低(我们将在错误#91“不理解 CPU 缓存”中讨论这一点)。

一般来说,我们应该记住range循环中的值元素是一个副本。因此,如果值是我们需要改变的结构,我们将只更新副本,而不是元素本身,除非我们修改的值或字段是指针。更好的选择是使用一个range循环或者一个经典的for循环通过索引访问元素。

在下一节中,我们继续使用range循环,看看如何计算提供的表达式。

4.2 #31:忽略参数在范围循环中的求值方式

range循环语法需要一个表达式。比如在for i, v := range expexp就是表达式。正如我们所见,它可以是一个字符串、一个数组、一个指向数组的指针、一个切片、一个映射或一个通道。现在,我们来讨论下面这个问题:这个表达式是如何评价的?使用range循环时,这是避免常见错误的要点。

让我们看看下面的例子,它将一个元素附加到我们迭代的切片上。你相信循环会终止吗?

s := []int{0, 1, 2}
for range s {
    s = append(s, 10)
}

为了理解这个问题,我们应该知道当使用一个range循环时,所提供的表达式只计算一次,在循环开始之前。在这个上下文中,“求值”意味着提供的表达式被复制到一个临时变量,然后range迭代这个变量。在本例中,当对s表达式求值时,结果是一个切片副本,如图 4.1 所示。

图 4.1 s被复制到range使用的临时变量中。

range循环使用这个临时变量。原始切片s也在每次迭代期间更新。因此,在三次迭代之后,状态如图 4.2 所示。

图 4.2 临时变量仍然是一个三长度的切片;因此,迭代完成。

每一步都会追加一个新元素。然而,在三个步骤之后,我们已经检查了所有的元素。实际上,range使用的临时切片仍然是三长度切片。因此,循环在三次迭代后完成。

这种行为与传统的for循环有所不同:

s := []int{0, 1, 2}
for i := 0; i < len(s); i++ {
    s = append(s, 10)
}

在这个例子中,循环永远不会结束。在每次迭代中,len(s)表达式被求值,因为我们不断添加元素,所以我们永远不会到达终止状态。为了准确地使用 Go 循环,记住这一点是很重要的。

回到range操作符,我们应该知道我们描述的行为(表达式只计算一次)也适用于所有提供的数据类型。作为一个例子,让我们用另外两种类型来看看这种行为的含义:通道和数组。

4.2.1 通道

让我们看一个基于使用range循环迭代一个通道的具体例子。我们创建了两个 goroutines,都将元素发送到两个不同的通道。然后,在父 goroutine 中,我们使用一个range循环在一个通道上实现一个消费者,该循环试图在迭代期间切换到另一个通道:

ch1 := make(chan int, 3)     // ❶
go func() {
    ch1 <- 0
    ch1 <- 1
    ch1 <- 2
    close(ch1)
}()

ch2 := make(chan int, 3)     // ❷
go func() {
    ch2 <- 10
    ch2 <- 11
    ch2 <- 12
    close(ch2)
}()

ch := ch1                    // ❸
for v := range ch {          // ❹
    fmt.Println(v)
    ch = ch2                 // ❺
}

❶ 创建包含元素 0、1 和 2 的第一个通道

❷ 创建了包含元素 10、11 和 12 的第二个通道

❸ 将第一个通道分配给ch

❹ 通过遍历ch创建了一个通道消费者

❺ 将第二通道分配给ch

在这个例子中,同样的逻辑适用于如何求值range表达式。提供给range的表达式是一个指向ch1ch通道。因此,rangech求值,执行对临时变量的复制,并迭代这个通道中的元素。尽管有ch = ch2语句,但是range一直在ch1上迭代,而不是ch2:

0
1
2

然而,ch = ch2声明并不是没有效果。因为我们将ch赋给了第二个变量,如果我们在这段代码后调用close(ch),它将关闭第二个通道,而不是第一个。

现在让我们来看看range操作符在使用数组时只对每个表达式求值一次的影响。

4.2.2 数组

对数组使用range循环有什么影响?因为range表达式是在循环开始之前计算的,所以分配给临时循环变量的是数组的副本。让我们通过下面的例子来看看这个原则的实际应用,这个例子在迭代过程中更新了一个特定的数组索引:

a := [3]int{0, 1, 2}      // ❶
for i, v := range a {     // ❷
    a[2] = 10             // ❸
    if i == 2 {           // ❹
        fmt.Println(v)
    }
}

❶ 创建了一个由三个元素组成的数组

❷ 迭代数组

❸ 更新了最后一个元素

❹ 打印最后一个元素的内容

这段代码将最后一个索引更新为 10。但是,如果我们运行这段代码,它不会打印10;相反,它打印出2,如图 4.3 所示。

图 4.3 range迭代数组副本(左),同时循环修改a(右)。

正如我们提到的,range操作符创建了数组的副本。同时,循环不更新副本;它更新原始数组:a。所以最后一次迭代时v的值是2,而不是10

如果我们想打印最后一个元素的实际值,我们可以用两种方法:

  • 通过从索引中访问元素:

    a := [3]int{0, 1, 2}
    for i := range a {
        a[2] = 10
        if i == 2 {
            fmt.Println(a[2])     // ❶ 
        }
    }
    

    ❶ 访问[2]而不是范围值变量

    因为我们访问的是原始数组,这段代码打印的是2而不是10

  • 使用数组指针:

    a := [3]int{0, 1, 2}
    for i, v := range &a {     // ❶ 
        a[2] = 10
        if i == 2 {
            fmt.Println(v)
        }
    }
    

    ❶ 的范围超过 1000 英镑,而不是 1000 英镑

    我们将数组指针的副本分配给range使用的临时变量。但是因为两个指针引用同一个数组,所以访问v也会返回10

两个选项都有效。然而,第二个选项不会导致复制整个数组,这可能是在数组非常大的情况下需要记住的事情。

总之,range循环只对提供的表达式求值一次,在循环开始之前,通过复制(不考虑类型)。我们应该记住这种行为,以避免常见的错误,例如,可能导致我们访问错误的元素。

在下一节中,我们将看到如何使用带有指针的range循环来避免常见错误。

4.3 #32:忽略在范围循环中使用指针元素的影响

本节着眼于使用带有指针元素的range循环时的一个具体错误。如果我们不够谨慎,可能会导致我们引用错误的元素。让我们检查一下这个问题以及如何修复它。

在开始之前,让我们澄清一下使用指针元素切片或映射的基本原理。主要有三种情况:

  • 就语义而言,使用指针语义存储数据意味着共享元素。例如,以下方法包含将元素插入缓存的逻辑:

    type Store struct {
        m map[string]*Foo
    }
    
    func (s Store) Put(id string, foo *Foo) {
        s.m[id] = foo
        // ...
    }
    

    这里,使用指针语义意味着Foo元素由Put的调用者和Store结构共享。

  • 有时我们已经在操作指针了。因此,在集合中直接存储指针而不是值会很方便。

  • 如果我们存储大型结构,并且这些结构经常发生改变,我们可以使用指针来避免每次改变的复制和插入:

    func updateMapValue(mapValue map[string]LargeStruct, id string) {
        value := mapValue[id]              // ❶
        value.foo = "bar"
        mapValue[id] = value               // ❷
    }
    
    func updateMapPointer(mapPointer map[string]*LargeStruct, id string) {
        mapPointer[id].foo = "bar"         // ❸
    }
    

    ❶拷贝

    ❷插页

    ❸直接改变了映射元素

    因为updateMapPointer接受指针映射,所以foo字段的改变可以在一个步骤中完成。

现在是时候讨论一下range循环中指针元素的常见错误了。我们将考虑以下两种结构:

  • 一个代表客户的Customer结构

  • 一个Store,它保存了一个Customer指针的映射

type Customer struct {
    ID      string
    Balance float64
}

type Store struct {
    m map[string]*Customer
}

下面的方法迭代一片Customer元素,并将它们存储在m映射中:

func (s *Store) storeCustomers(customers []Customer) {
    for _, customer := range customers {
        s.m[customer.ID] = &customer         // ❶
    }
}

❶ 将customer指针存储在映射中

在这个例子中,我们使用操作符range对输入切片进行迭代,并将Customer指针存储在映射中。但是这种方法能达到我们预期的效果吗?

让我们用三个不同的Customer结构来调用它,试一试:

s.storeCustomers([]Customer{
    {ID: "1", Balance: 10},
    {ID: "2", Balance: -10},
    {ID: "3", Balance: 0},
})

如果我们打印映射,下面是这段代码的结果:

key=1, value=&main.Customer{ID:"3", Balance:0}
key=2, value=&main.Customer{ID:"3", Balance:0}
key=3, value=&main.Customer{ID:"3", Balance:0}

正如我们所看到的,不是存储三个不同的Customer结构,而是存储在映射中的所有元素都引用同一个Customer结构:3。我们做错了什么?

使用range循环迭代customers片,不管元素的数量,创建一个具有固定地址的单个customer变量。我们可以通过在每次迭代中打印指针地址来验证这一点:

func (s *Store) storeCustomers(customers []Customer) {
    for _, customer := range customers {
        fmt.Printf("%p\n", &customer)      // ❶
        s.m[customer.ID] = &customer
    }
}
0xc000096020
0xc000096020
0xc000096020

❶ 打印customer地址

为什么这很重要?让我们检查一下每个迭代:

  • 在第一次迭代中,customer引用第一个元素:Customer 1。我们存储了一个指向customer结构的指针。

  • 在第二次迭代中,customer现在引用了另一个元素:Customer 2。我们还存储了一个指向customer结构的指针。

  • 最后,在最后一次迭代中,customer引用最后一个元素:Customer 3。同样,相同的指针存储在映射中。

在迭代结束时,我们已经在映射中存储了同一个指针三次(见图 4.4)。这个指针的最后一个赋值是对切片的最后一个元素的引用:Customer 3。这就是为什么所有映射元素都引用同一个Customer

图 4.4customer变量有一个常量地址,所以我们在映射中存储了相同的指针。

那么,我们如何解决这个问题呢?有两种主要的解决方案。第一个类似于我们在错误 1 中看到的,“非预期的变量隐藏”它需要创建一个局部变量:

func (s *Store) storeCustomers(customers []Customer) {
    for _, customer := range customers {
        current := customer                 // ❶
        s.m[current.ID] = &current          // ❷
    }
}

❶ 创建一个本地current变量

❷ 将这个指针存储在映射中

在这个例子中,我们不存储引用customer的指针;相反,我们存储一个引用current的指针。current是在每次迭代中引用唯一Customer的变量。因此,在循环之后,我们在映射中存储了引用不同Customer结构的不同指针。另一种解决方案是使用片索引存储引用每个元素的指针:

func (s *Store) storeCustomers(customers []Customer) {
    for i := range customers {
        customer := &customers[i]        // ❶
        s.m[customer.ID] = customer      // ❷
    }
}

❶ 给customer分配一个i元素的指针

❷ 存储customer指针

在这个解决方案中,customer现在是一个指针。因为它是在每次迭代中初始化的,所以它有一个唯一的地址。因此,我们在映射中存储不同的指针。

当使用一个range循环迭代一个数据结构时,我们必须记住所有的值都被分配给一个具有唯一地址的唯一变量。因此,如果我们在每次迭代中存储一个引用这个变量的指针,我们将会在这样一种情况下结束:我们存储了引用同一个元素的同一个指针:最新的元素。我们可以通过在循环范围内强制创建一个局部变量或者创建一个指针通过它的索引引用一个切片元素来解决这个问题。两种解决方案都可以。还要注意,我们将切片数据结构作为输入,但是问题与映射类似。

在下一节中,我们将看到与映射迭代相关的常见错误。

4.4 #33:在映射迭代过程中做出错误的假设

对映射进行迭代是误解和错误的常见来源,主要是因为开发人员做出了错误的假设。在本节中,
我们讨论两种不同的情况:

  • 排序

  • 迭代期间的映射更新

我们将看到两个基于错误假设的常见错误。

4.4.1 排序

关于排序,我们需要了解映射数据结构的一些基本行为:

  • 它不保持数据按键排序(映射不是基于二叉树)。

  • 它不保留数据添加的顺序。例如,如果我们在对 B 之前插入对 A,我们不应该根据这个插入顺序做出任何假设。

此外,当迭代一个映射时,我们根本不应该做任何排序假设。让我们来看看这句话的含义。

我们将考虑图 4.5 所示的映射,由四个桶组成(元素代表键)。后备数组的每个索引引用一个给定的桶。

图 4.5 有四个桶的映射

现在,让我们使用一个range循环来迭代这个映射,并打印所有的键:

for k := range m {
    fmt.Print(k)
}

我们提到过数据不是按键排序的。因此,我们不能期望这段代码打印出acdeyz。与此同时,我们说过映射不保留插入顺序。因此,我们也不能期望代码打印出ayzcde

但是我们至少可以期望代码按照键当前存储在映射中的顺序打印键吧?不,这个也不行。在 Go 中,映射上的迭代顺序不是指定的。也不能保证从一次迭代到下一次迭代的顺序是相同的。我们应该记住这些映射行为,这样我们就不会把代码建立在错误的假设上。

我们可以通过运行前面的循环两次来确认所有这些语句:

zdyaec
czyade

正如我们所看到的,每次迭代的顺序都是不同的。

注意尽管迭代顺序没有保证,但迭代分布并不均匀。这就是为什么官方的 Go 规范声明迭代是未指定的,而不是随机的。

那么为什么 Go 有如此惊人的方法来迭代映射呢?这是语言设计者有意识的选择。他们想添加一些随机性,以确保开发人员在使用映射时不会依赖任何排序假设(见 mng.bz/M2JW )。

因此,作为 Go 开发者,我们不应该在迭代一个映射时对排序做任何假设。然而,让我们注意使用来自标准库或外部库的包会导致不同的行为。例如,当encoding/json包将一个映射整理到 JSON 中时,它按照键的字母顺序对数据进行重新排序,而不考虑插入顺序。但这并不是 Go映射本身的属性。如果需要排序,我们应该依赖其他数据结构,比如二进制堆(GoDS 库在 github.com/emirpasic/gods 包含有用的数据结构实现)。

现在让我们看看第二个错误,它与迭代映射时更新映射有关。

4.4.2 迭代期间的映射插入

在 Go 中,允许在迭代过程中更新映射(插入或删除元素);它不会导致编译错误或运行时错误。然而,在迭代过程中向映射中添加条目时,我们应该考虑另一个方面,以避免不确定的结果。

让我们来看看下面这个迭代一个map[int]bool的例子。如果偶对值为真,我们添加另一个元素。你能猜到这段代码的输出是什么吗?

m := map[int]bool{
    0: true,
    1: false,
    2: true,
}

for k, v := range m {
    if v {
        m[10+k] = true
    }
}

fmt.Println(m)

这段代码的结果是不可预测的。如果我们多次运行此代码,下面是一些结果示例:

map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true]
map[0:true 1:false 2:true 10:true 12:true 20:true]

为了理解其中的原因,我们必须阅读 Go 规范对迭代过程中的新映射条目的描述:

如果映射条目是在迭代过程中创建的,它可能是在迭代过程中生成的,也可能被跳过。对于创建的每个条目,以及从一次迭代到下一次迭代,选择可能会有所不同。

因此,当一个元素在迭代过程中被添加到一个映射中时,它可能会在后续的迭代过程中产生,也可能不会产生。作为 Go 开发者,我们没有任何方法来强制执行这种行为。它也可能因迭代而异,这就是为什么我们三次得到不同的结果。

记住这种行为以确保我们的代码不会产生不可预测的输出是很重要的。如果我们想在迭代时更新映射,并确保添加的条目不是迭代的一部分,一种解决方案是处理映射的副本,如下所示:

m := map[int]bool{
    0: true,
    1: false,
    2: true,
}
m2 := copyMap(m)            // ❶

for k, v := range m {
    m2[k] = v
    if v {
        m2[10+k] = true     // ❷
    }
}

fmt.Println(m2)

❶ 创建了初始映射的副本

❷ 更新m2而不是m

在本例中,我们将正在读取的映射与正在更新的映射分离开来。事实上,我们一直在迭代m,但是更新是在m2完成的。这个新版本创建了可预测和可重复的输出:

map[0:true 1:false 2:true 10:true 12:true]

总而言之,当我们使用映射时,我们不应该依赖以下内容:

  • 数据按键排序

  • 插入顺序的保留

  • 确定性迭代顺序

  • 在添加元素的同一次迭代中产生的元素

记住这些行为应该有助于我们避免基于错误假设的常见错误。

在下一节中,我们将看到一个在中断循环时经常犯的错误。

4.5 #34:忽略break语句的工作方式

一个break语句是常用来终止一个循环的执行。当循环与switchselect一起使用时,开发人员经常会犯破坏错误语句的错误。

让我们看看下面的例子。我们在和for循环中实现了一个switch。如果循环索引的值为2,我们想要中断循环:

for i := 0; i < 5; i++ {
    fmt.Printf("%d ", i)

    switch i {
    default:
    case 2:
        break      // ❶
    }
}

❶ 如果i等于 2,就break

这段代码乍一看可能没错;然而,它并没有做我们所期望的。break语句没有终止循环:相反,它终止了语句switch。因此,这段代码不是从 0 迭代到 2,而是从 0 迭代到 4: 0 1 2 3 4

要记住的一个基本规则是,break语句终止最里面的forswitchselect语句的执行。在前面的例子中,它终止了switch语句。

那么我们如何编写代码来打破循环而不是switch语句的?最惯用的方法是使用标签:

loop:                           // ❶
    for i := 0; i < 5; i++ {
        fmt.Printf("%d ", i)

        switch i {
        default:
        case 2:
            break loop          // ❷
        }
    }

❶ 定义了一个loop标签

❷ 终止的是附在loop标签上的循环,而不是switch

这里,我们将标签与for循环联系起来。然后,因为我们向break语句提供了loop标签,所以它中断了循环,而不是切换。因此,这个新版本将打印0 1 2,正如我们所料。

带标签的break是不是跟goto一样?

一些开发人员可能会质疑带有标签的break是否是惯用的,并将它视为一个花哨的goto语句。然而,事实并非如此,标准库中使用了这样的代码。例如,我们在从缓冲区读取行时,在net/http包中看到这个:

readlines:
    for {
        line, err := rw.Body.ReadString('\n')
        switch {
        case err == io.EOF:
            break readlines
        case err != nil:
            t.Fatalf("unexpected error reading from CGI: %v", err)
        }
        // ...
    }

这个例子使用了一个带有readlines的表达性标签来强调循环的目标。因此,我们应该考虑使用标签来中断语句,这是 Go 中惯用的方法。

循环内的select也可能会中断错误的语句。在下面的代码中,我们想在两种情况下使用select,如果上下文取消,则中断循环:

for {
    select {
    case <-ch:
        // Do something
    case <-ctx.Done():
        break             // ❶
    }
}

如果上下文取消,❶会中断

这里最里面的forswitchselect语句是的select语句,而不是for循环。因此,循环重复。同样,为了打破循环本身,我们可以使用一个标签:

loop:                          // ❶
    for {
        select {
        case <-ch:
            // Do something
        case <-ctx.Done():
            break loop         // ❷
        }
    }

❶ 定义了一个loop标签

❷ 终止附加到loop标签的循环,而不是select

现在,正如预期的那样,break语句中断了循环,而不是select

注意,我们也可以使用带标签的continue来进入带标签循环的下一次迭代。

在循环中使用语句switchselect时,我们应该保持谨慎。当使用break时,我们应该始终确保我们知道它将影响哪个语句。正如我们所见,使用标签是强制中断特定语句的惯用解决方案。

在本章的最后一节,我们继续讨论循环,但这次是结合关键字defer来讨论。

4.6 #35:在循环中使用defer

defer语句延迟一个调用的执行,直到周围的函数返回。它主要用于减少样板代码。例如,如果一个资源最终必须关闭,我们可以使用defer来避免在每个return之前重复关闭调用。然而,一个常见的错误是没有意识到在循环中使用defer的后果。让我们来研究一下这个问题。

我们将实现一个打开一组文件的函数,其中的文件路径是通过一个通道接收的。因此,我们必须遍历这个通道,打开文件,并处理闭包。这是我们的第一个版本:

func readFiles(ch <-chan string) error {
    for path := range ch {                    // ❶
        file, err := os.Open(path)            // ❷
        if err != nil {
            return err
        }

        defer file.Close()                    // ❸

        // Do something with file
    }
    return nil
}

❶ 迭代通道

❷ 打开文件

❸ 延迟调用file.Close

注意我们将讨论如何处理错误#54“不处理延迟错误”中的延迟错误

这种实现有一个很大的问题。我们必须回忆一下,当包围函数返回时,defer调度一个函数调用。在这种情况下,延迟调用不是在每次循环迭代中执行,而是在readFiles函数返回时执行。如果readFiles没有返回,文件描述符将永远保持打开,导致泄漏。

有什么办法可以解决这个问题?一种可能是去掉defer,手动处理文件关闭。但是如果我们那样做,我们将不得不放弃 Go 工具集的一个方便的特性,仅仅因为我们在一个循环中。那么,如果我们想继续使用defer,有哪些选择呢?我们必须围绕defer创建另一个在每次迭代中调用的周围函数。

例如,我们可以实现一个readFile函数来保存接收到的每个新文件路径的逻辑:

func readFiles(ch <-chan string) error {
    for path := range ch {
        if err := readFile(path); err != nil {    // ❶
            return err
        }
    }
    return nil
}

func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }

    defer file.Close()                            // ❷

    // Do something with file
    return nil
}

❶ 调用包含主逻辑的readFile函数

❷ 延迟调用file.Close

在这个实现中,当readFile返回时,调用defer函数,这意味着在每次迭代结束时。因此,在父readFiles函数返回之前,我们不会打开文件描述符。

另一种方法是让readFile函数成为一个闭包:

func readFiles(ch <-chan string) error {
    for path := range ch {
        err := func() error {
            // ...
            defer file.Close()
            // ...
        }()                  // ❶
        if err != nil {
            return err
        }
    }
    return nil
}

❶ 运行提供的闭包

但本质上,这仍然是相同的解决方案:在每次迭代中添加另一个周围函数来执行defer调用。普通的旧函数的优点是可能更清晰一点,我们也可以为它编写一个特定的单元测试。

当使用defer时,我们必须记住,当周围的函数返回时,它调度一个函数调用。因此,在一个循环中调用defer将会堆叠所有的调用:它们不会在每次迭代中执行,例如,如果循环没有终止,这可能会导致内存泄漏。解决这个问题最方便的方法是在每次迭代中引入另一个要调用的函数。但是如果性能是至关重要的,一个缺点是函数调用增加了开销。如果我们有这样的情况,并且我们想要防止这种开销,我们应该去掉defer并且在循环之前手动处理延迟调用。

总结

  • 循环range中的值元素是一个副本。因此,例如,要改变一个结构,可以通过它的索引或者通过一个经典的for循环来访问它(除非你想要修改的元素或者字段是一个指针)。

  • 了解传递给range操作符的表达式在循环开始前只计算一次,可以帮助您避免常见的错误,如通道或片迭代中的低效赋值。

  • 使用局部变量或使用索引访问元素,可以防止在循环内复制指针时出错。

  • 为了在使用映射时确保可预测的输出,请记住映射数据结构

    • 不按键排序数据
    • 不保留插入顺序
    • 没有确定的迭代顺序
    • 不保证在一次迭代中添加的元素会在这次迭代中产生
  • 使用带标签的breakcontinue强制中断特定语句。这对循环中的switchselect语句很有帮助。

  • 提取函数内部的循环逻辑会导致在每次迭代结束时执行一个defer语句。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
文件为doc版,可自行转成txt,在手机上看挺好的。 本资源来自网络,如有纰漏还请告知,如觉得还不错,请留言告知后来人,谢谢!!!!! 入门学习Linux常用必会60个命令实例详解 Linux必学的60个命令 Linux提供了大量的命令,利用它可以有效地完成大量的工作,如磁盘操作、文件存取、目录操作、进程管理、文件权限设定等。所以,在Linux系统上工作离不开使用系统提供的命令。要想真正理解Linux系统,就必须从Linux命令学起,通过基础的命令学习可以进一步理解Linux系统。 不同Linux发行版的命令数量不一样,但Linux发行版本最少的命令也有200多个。这里笔者把比较重要和使用频率最多的命令,按照它们在系统中的作用分成下面六个部分一一介绍。 ◆ 安装和登录命令:login、shutdown、halt、reboot、install、mount、umount、chsh、exit、last; ◆ 文件处理命令:file、mkdir、grep、dd、find、mv、ls、diff、cat、ln; ◆ 系统管理相关命令:df、top、free、quota、at、lp、adduser、groupadd、kill、crontab; ◆ 网络操作命令:ifconfig、ip、ping、netstat、telnet、ftp、route、rlogin、rcp、finger、mail、 nslookup; ◆ 系统安全相关命令:passwd、su、umask、chgrp、chmod、chown、chattr、sudo ps、who; ◆ 其它命令:tar、unzip、gunzip、unarj、mtools、man、unendcode、uudecode。 本文以Mandrake Linux 9.1(Kenrel 2.4.21)为例,介绍Linux下的安装和登录命令。 immortality按:请用ctrl+f在本页中查找某一部分的内容或某一命令的用法。 -------------------------------------------------------------------------------- Linux必学的60个命令(1)-安装与登陆命令 login 1.作用 login的作用是登录系统,它的使用权限是所有用户。 2.格式 login [name][-p ][-h 主机名称] 3.主要参数 -p:通知login保持现在的环境参数。 -h:用来向远程登录的之间传输用户名。 如果选择用命令行模式登录Linux的话,那么看到的第一个Linux命令就是login:。 一般界面是这样的: Manddrake Linux release 9.1(Bamboo) for i586 renrel 2.4.21-0.13mdk on i686 / tty1 localhost login:root password: 上面代码中,第一行是Linux发行版本号,第二行是内核版本号和登录的虚拟控制台,我们在第三行输入登录名,按“Enter”键在Password后输入账户密码,即可登录系统。出于安全考虑,输入账户密码时字符不会在屏幕上回显,光标也不移动。 登录后会看到下面这个界面(以超级用户为例): [root@localhost root]# last login:Tue ,Nov 18 10:00:55 on vc/1 上面显示的是登录星期、月、日、时间和使用的虚拟控制台。 4.应用技巧 Linux 是一个真正的多用户操作系统,可以同时接受多个用户登录,还允许一个用户进行多次登录。这是因为Linux和许多版本的Unix一样,提供了虚拟控制台的访问方式,允许用户在同一时间从控制台(系统的控制台是与系统直接相连的监视器和键盘)进行多次登录。每个虚拟控制台可以看作是一个独立的工作站,工作台之间可以切换。虚拟控制台的切换可以通过按下Alt键和一个功能键来实现,通常使用F1-F6 。 例如,用户登录后,按一下“Alt+ F2”键,用户就可以看到上面出现的“login:”提示符,说明用户看到了第二个虚拟控制台。然后只需按“Alt+ F1”键,就可以回到第一个虚拟控制台。一个新安装的Linux系统允许用户使用“Alt+F1”到“Alt+F6”键来访问前六个虚拟控制台。虚拟控制台最有用的是,当一个程序出错造成系统死锁时,可以切换到其它虚拟控制台工作,关闭这个程序。 shutdown 1.作用 shutdown命令的作用是关闭计算机,它的使用权限是超级用户。 2.格式 shutdown [-h][-i][-k][-m][-t] 3.重要参数 -t:在改变到其它运行级别之前,告诉init程序多久以后关机。 -k:并不真正关机,只是送警告信号给每位登录者。 -h:关机后关闭电源。 -c:cancel current process取消目前正在执行的关机程序。所以这个选项当然没有时间参数,但是可以输入一个用来解释的讯息,而这信息将会送到每位使用者。 -F:在重启计算机时强迫fsck。 -time:设定关机前的时间。 -m: 将系统改为单用户模式。 -i:关机时显示系统信息。 4.命令说明 shutdown 命令可以安全地将系统关机。有些用户会使用直接断掉电源的方式来关闭Linux系统,这是十分危险的。因为Linux与Windows不同,其后台运行着许多进程,所以强制关机可能会导致进程的数据丢失,使系统处于不稳定的状态,甚至在有的系统中会损坏硬件设备(硬盘)。在系统关机前使用 shutdown命令,系统管理员会通知所有登录的用户系统将要关闭,并且login指令会被冻结,即新的用户不能再登录。 halt 1.作用 halt命令的作用是关闭系统,它的使用权限是超级用户。 2.格式 halt [-n] [-w] [-d] [-f] [-i] [-p] 3.主要参数说明 -n:防止sync系统调用,它用在用fsck修补根分区之后,以阻止内核用老版本的超级块覆盖修补过的超级块。 -w:并不是真正的重启或关机,只是写wtmp(/var/log/wtmp)纪录。 -f:没有调用shutdown,而强制关机或重启。 -i:关机(或重启)前,关掉所有的网络接口。 -f:强迫关机,不呼叫shutdown这个指令。 -p: 当关机的时候顺便做关闭电源的动作。 -d:关闭系统,但不留下纪录。  4.命令说明 halt 就是调用shutdown -h。halt执行时,杀死应用进程,执行sync(将存于buffer中的资料强制写入硬盘中)系统调用,文件系统写操作完成后就会停止内核。若系统的运行级别为0或6,则关闭系统;否则以shutdown指令(加上-h参数)来取代。  reboot 1.作用 reboot命令的作用是重新启动计算机,它的使用权限是系统管理者。 2.格式 reboot [-n] [-w] [-d] [-f] [-i] 3.主要参数 -n: 在重开机前不做将记忆体资料写回硬盘的动作。 -w: 并不会真的重开机,只是把记录写到/var/log/wtmp文件里。 -d: 不把记录写到/var/log/wtmp文件里(-n这个参数包含了-d)。 -i: 在重开机之前先把所有与网络相关的装置停止。 install 1.作用 install命令的作用是安装或升级软件或备份数据,它的使用权限是所有用户。 2.格式 (1)install [选项]... 来源 目的地 (2)install [选项]... 来源... 目录 (3)install -d [选项]... 目录... 在前两种格式中,会将复制至或将多个文件复制至已存在的,同时设定权限模式及所有者/所属组。在第三种格式中,会创建所有指定的目录及它们的主目录。长选项必须用的参数在使用短选项时也是必须的。 3.主要参数 --backup[=CONTROL]:为每个已存在的目的地文件进行备份。 -b:类似 --backup,但不接受任何参数。 -c:(此选项不作处理)。 -d,--directory:所有参数都作为目录处理,而且会创建指定目录的所有主目录。 -D:创建前的所有主目录,然后将复制至 ;在第一种使用格式中有用。 -g,--group=组:自行设定所属组,而不是进程目前的所属组。 -m,--mode=模式:自行设定权限模式 (像chmod),而不是rwxr-xr-x。 -o,--owner=所有者:自行设定所有者 (只适用于超级用户)。 -p,--preserve-timestamps:以文件的访问/修改时间作为相应的目的地文件的时间属性。 -s,--strip:用strip命令删除symbol table,只适用于第一及第二种使用格式。 -S,--suffix=后缀:自行指定备份文件的。 -v,--verbose:处理每个文件/目录时印出名称。 --help:显示此帮助信息并离开。 --version:显示版本信息并离开。 mount 1.作用 mount命令的作用是加载文件系统,它的用权限是超级用户或/etc/fstab中允许的使用者。 2.格式 mount -a [-fv] [-t vfstype] [-n] [-rw] [-F] device dir 3.主要参数 -h:显示辅助信息。 -v:显示信息,通常和-f用来除错。 -a:将/etc/fstab中定义的所有文件系统挂上。 -F:这个命令通常和-a一起使用,它会为每一个mount的动作产生一个行程负责执行。在系统需要挂上大量NFS文件系统时可以加快加载的速度。 -f:通常用于除错。它会使mount不执行实际挂上的动作,而是模拟整个挂上的过程,通常会和-v一起使用。 -t vfstype:显示被加载文件系统的类型。 -n:一般而言,mount挂上后会在/etc/mtab中写入一笔资料,在系统中没有可写入文件系统的情况下,可以用这个选项取消这个动作。 4.应用技巧 在Linux 和Unix系统上,所有文件都是作为一个大型树(以/为根)的一部分访问的。要访问CD-ROM上的文件,需要将CD-ROM设备挂装在文件树中的某个挂装点。如果发行版安装了自动挂装包,那么这个步骤可自动进行。在Linux中,如果要使用硬盘、光驱等储存设备,就得先将它加载,当储存设备挂上了之后,就可以把它当成一个目录来访问。挂上一个设备使用mount命令。在使用mount这个指令时,至少要先知道下列三种信息:要加载对象的文件系统类型、要加载对象的设备名称及要将设备加载到哪个目录下。 (1)Linux可以识别的文件系统 ◆ Windows 95/98常用的FAT 32文件系统:vfat ; ◆ Win NT/2000 的文件系统:ntfs ; ◆ OS/2用的文件系统:hpfs; ◆ Linux用的文件系统:ext2、ext3; ◆ CD-ROM光盘用的文件系统:iso9660。 虽然vfat是指FAT 32系统,但事实上它也兼容FAT 16的文件系统类型。 (2)确定设备的名称 在Linux 中,设备名称通常都存在/dev里。这些设备名称的命名都是有规则的,可以用“推理”的方式把设备名称找出来。例如,/dev/hda1这个 IDE设备,hd是Hard Disk(硬盘)的,sd是SCSI Device,fd是Floppy Device(或是Floppy Disk?)。a代表第一个设备,通常IDE接口可以接上4个IDE设备(比如4块硬盘)。所以要识别IDE硬盘的方法分别就是hda、hdb、hdc、 hdd。hda1中的“1”代表hda的第一个硬盘分区 (partition),hda2代表hda的第二主分区,第一个逻辑分区从hda5开始,依此类推。此外,可以直接检查 /var/log/messages文件,在该文件中可以找到计算机开机后系统已辨认出来的设备代号。 (3)查找挂接点 在决定将设备挂接之前,先要查看一下计算机是不是有个/mnt的空目录,该目录就是专门用来当作挂载点(Mount Point)的目录。建议在/mnt里建几个/mnt/cdrom、/mnt/floppy、/mnt/mo等目录,当作目录的专用挂载点。举例而言,如要挂载下列5个设备,其执行指令可能如下 (假设都是Linux的ext2系统,如果是Windows XX请将ext2改成vfat): 软盘 ===>mount -t ext2 /dev/fd0 /mnt/floppy cdrom ===>mount -t iso9660 /dev/hdc /mnt/cdrom SCSI cdrom ===>mount -t iso9660 /dev/sdb /mnt/scdrom SCSI cdr ===>mount -t iso9660 /dev/sdc /mnt/scdr 不过目前大多数较新的Linux发行版本(包括红旗 Linux、中软Linux、Mandrake Linux等)都可以自动挂装文件系统,但Red Hat Linux除外。 umount 1.作用 umount命令的作用是卸载一个文件系统,它的使用权限是超级用户或/etc/fstab中允许的使用者。 2.格式 unmount -a [-fFnrsvw] [-t vfstype] [-n] [-rw] [-F] device dir 3.使用说明 umount 命令是mount命令的逆操作,它的参数和使用方法和mount命令是一样的。Linux挂装CD-ROM后,会锁定CD—ROM,这样就不能用CD- ROM面板上的Eject按钮弹出它。但是,当不再需要光盘时,如果已将/cdrom作为符号链接,请使用umount/cdrom来卸装它。仅当无用户正在使用光盘时,该命令才会成功。该命令包括了将带有当前工作目录当作该光盘中的目录的终端窗口。 chsh 1.作用 chsh命令的作用是更改使用者shell设定,它的使用权限是所有使用者。 2.格式 chsh [ -s ] [ -list] [ --help ] [ -v ] [ username ] 3.主要参数 -l:显示系统所有Shell类型。 -v:显示Shell版本号。 4.应用技巧 前面介绍了Linux下有多种Shell,一般缺省的是Bash,如果想更换Shell类型可以使用chsh命令。先输入账户密码,然后输入新Shell类型,如果操作正确系统会显示“Shell change”。其界面一般如下: Changing fihanging shell for cao Password: New shell [/bin/bash]: /bin/tcsh 上面代码中,[ ]内是目前使用的Shell。普通用户只能修改自己的Shell,超级用户可以修改全体用户的Shell。要想查询系统提供哪些Shell,可以使用chsh -l 命令,见图1所示。 图1 系统可以使用的Shell类型 从图1中可以看到,笔者系统中可以使用的Shell有bash(缺省)、csh、sh、tcsh四种。 exit 1.作用 exit命令的作用是退出系统,它的使用权限是所有用户。 2.格式 exit 3.参数 exit命令没有参数,运行后退出系统进入登录界面。 last 1.作用 last命令的作用是显示近期用户或终端的登录情况,它的使用权限是所有用户。通过last命令查看该程序的log,管理员可以获知谁曾经或企图连接系统。 2.格式 1ast[—n][-f file][-t tty] [—h 节点][-I —IP][—1][-y][1D] 3.主要参数 -n:指定输出记录的条数。 -f file:指定用文件file作为查询用的log文件。 -t tty:只显示指定的虚拟控制台上登录情况。 -h 节点:只显示指定的节点上的登录情况。 -i IP:只显示指定的IP上登录的情况。 -1:用IP来显示远端地址。 -y:显示记录的年、月、日。 -ID:知道查询的用户名。 -x:显示系统关闭、用户登录和退出的历史。 动手练习 上面介绍了Linux安装和登录命令,下面介绍几个实例,动手练习一下刚才讲过的命令。 1.一次运行多个命令 在一个命令行中可以执行多个命令,用分号将各个命令隔开即可,例如: #last -x;halt 上面代码表示在显示系统关闭、用户登录和退出的历史后关闭计算机。 2.利用mount挂装文件系统访问Windows系统 许多Linux发行版本现在都可以自动加载Vfat分区来访问Windows系统,而Red Hat各个版本都没有自动加载Vfat分区,因此还需要进行手工操作。 mount 可以将Windows分区作为Linux的一个“文件”挂接到Linux的一个空文件夹下,从而将Windows的分区和/mnt这个目录联系起来。因此,只要访问这个文件夹就相当于访问该分区了。首先要在/mnt下建立winc文件夹,在命令提示符下输入下面命令: #mount -t vfat /dev/hda1 /mnt/winc 即表示将Windows的C分区挂到Liunx的/mnt/winc目录下。这时,在/mnt/winc目录下就可以看到Windows中C盘的内容了。使用类似的方法可以访问Windows系统的D、E盘。在Linux系统显示Windows的分区一般顺序这样的:hda1为C盘、hda5为D盘、 hda6为E盘……以此类推。上述方法可以查看Windows系统有一个很大的问题,就是Windows中的所有中文文件名或文件夹名全部显示为问号 “?”,而英文却可以正常显示。我们可以通过加入一些参数让它显示中文。还以上面的操作为例,此时输入命令: #mount -t vfat -o iocharset=cp936 /dev/hda1 /mnt/winc 现在它就可以正常显示中文了。 3.使用mount加挂闪盘上的文件系统 在Linux下使用闪盘非常简单。Linux对USB设备有很好的支持,当插入闪盘后,闪盘被识别为一个SCSI盘,通常输入以下命令: # mount /dev/sda1 /usb 就能够加挂闪盘上的文件系统。 小知识 Linux命令与Shell 所谓Shell,就是命令解释程序,它提供了程序设计接口,可以使用程序来编程。学习Shell对于Linux初学者理解Linux系统是非常重要的。 Linux系统的Shell作为操作系统的外壳,为用户提供了使用操作系统的接口。Shell是命令语言、命令解释程序及程序设计语言的统称,是用户和 Linux内核之间的接口程序。如果把Linux内核想象成一个球体的中心,Shell就是围绕内核的外层。当从Shell或其它程序向Linux传递命令时,内核会做出相应的反应。Shell在Linux系统的作用和MS DOS下的COMMAND.COM和Windows 95/98 的 explorer.exe相似。Shell虽然不是系统核心的一部分,只是系统核心的一个外延,但它能够调用系统内核的大部分功能。因此,可以说 Shell是Unux/Linux最重要的实用程序。 Linux中的Shell有多种类型,其中最常用的是Bourne Shell(sh)、C Shell(csh)和Korn Shell(ksh)。大多数Linux发行版本缺省的Shell是Bourne Again Shell,它是Bourne Shell的扩展,简称bash,与Bourne Shell完全向后兼容,并且在Bourne Shell的基础上增加了很多特性。bash放在/bin/bash中,可以提供如命令补全、命令编辑和命令历史表等功能。它还包含了很多C Shell和Korn Shell中的优点,有灵活和强大的编程接口,同时又有很友好的用户界面。Linux系统中200多个命令中有40个是bash的内部命令,主要包括 exit、less、lp、kill、 cd、pwd、fc、fg等。 -------------------------------------------------------------------------------- Linux必学的60个命令(2)-文件处理命令 Linux 系统信息存放在文件里,文件与普通的公务文件类似。每个文件都有自己的名字、内容、存放地址及其它一些管理信息,如文件的用户、文件的大小等。文件可以是一封信、一个通讯录,或者是程序的源语句、程序的数据,甚至可以包括可执行的程序和其它非正文内容。 Linux文件系统具有良好的结构,系统提供了很多文件处理程序。这里主要介绍常用的文件处理命令。 file 1.作用 件内容判断文件类型,使用权限是所有用户。 2.格式 file通过探测文 file [options] 文件名 3.[options]主要参数 -v:在标准输出后显示版本信息,并且退出。 -z:探测压缩过的文件类型。 -L:允许符合连接。 -f name:从文件namefile中读取要分析的文件名列表。 4.简单说明 使用file命令可以知道某个文件究竟是二进制(ELF格式)的可执行文件, 还是Shell Script文件,或者是其它的什么格式。file能识别的文件类型有目录、Shell脚本、英文文本、二进制可执行文件、C语言源文件、文本文件、DOS的可执行文件。 5.应用实例 如果我们看到一个没有后缀的文件grap,可以使用下面命令: $ file grap grap: English text 此时系统显示这是一个英文文本文件。需要说明的是,file命令不能探测包括图形、音频、视频等多媒体文件类型。 mkdir 1.作用 mkdir命令的作用是建立名称为dirname的子目录,与MS DOS下的md命令类似,它的使用权限是所有用户。 2.格式 mkdir [options] 目录名 3.[options]主要参数 -m, --mode=模式:设定权限,与chmod类似。 -p, --parents:需要时创建上层目录;如果目录早已存在,则不当作错误。 -v, --verbose:每次创建新目录都显示信息。 --version:显示版本信息后离开。 4.应用实例 在进行目录创建时可以设置目录的权限,此时使用的参数是“-m”。假设要创建的目录名是“tsk”,让所有用户都有rwx(即读、写、执行的权限),那么可以使用以下命令: $ mkdir -m 777 tsk grep 1.作用 grep命令可以指定文件中搜索特定的内容,并将含有这些内容的行标准输出。grep全称是Global Regular Expression Print,表示全局正则表达式版本,它的使用权限是所有用户。 2.格式 grep [options] 3.主要参数 [options]主要参数: -c:只输出匹配行的计数。 -I:不区分大小写(只适用于单字符)。 -h:查询多文件时不显示文件名。 -l:查询多文件时只输出包含匹配字符的文件名。 -n:显示匹配行及行号。 -s:不显示不存在或无匹配文本的错误信息。 -v:显示不包含匹配文本的所有行。 pattern正则表达式主要参数: \:忽略正则表达式中特殊字符的原有含义。 ^:匹配正则表达式的开始行。 $: 匹配正则表达式的结束行。 \:到匹配正则表达式的行结束。 [ ]:单个字符,如[A]即A符合要求 。 [ - ]:范围,如[A-Z],即A、B、C一直到Z都符合要求 。 。:所有的单个字符。 * :有字符,长度可以为0。 正则表达式是Linux/Unix系统中非常重要的概念。正则表达式(也称为“regex”或“regexp”)是一个可以描述一类字符串的模式(Pattern)。如果一个字符串可以用某个正则表达式来描述,我们就说这个字符和该正则表达式匹配(Match)。这和DOS中用户可以使用通配符 “*”代表任意字符类似。在Linux系统上,正则表达式通常被用来查找文本的模式,以及对文本执行“搜索-替换”操作和其它功能。 4.应用实例 查询DNS服务是日常工作之一,这意味着要维护覆盖不同网络的大量IP地址。有时IP地址会超过2000个。如果要查看nnn.nnn网络地址,但是却忘了第二部分中的其余部分,只知到有两个句点,例如nnn nn..。要抽取其中所有nnn.nnn IP地址,使用[0-9 ]\{3 \}\.[0-0\{3\}\。含义是任意数字出现3次,后跟句点,接着是任意数字出现3次,后跟句点。 $grep '[0-9 ]\{3 \}\.[0-0\{3\}\' ipfile 补充说明,grep家族还包括fgrep和egrep。fgrep是fix grep,允许查找字符串而不是一个模式;egrep是扩展grep,支持基本及扩展的正则表达式,但不支持\q模式范围的应用及与之相对应的一些更加规范的模式。 dd 1.作用 dd命令用来复制文件,并根据参数将数据转换和格式化。 2.格式 dd [options] 3.[opitions]主要参数 bs=字节:强迫 ibs=及obs=。 cbs=字节:每次转换指定的。 conv=关键字:根据以逗号分隔的关键字表示的方式来转换文件。 count=块数目:只复制指定的输入数据。 ibs=字节:每次读取指定的。 if=文件:读取内容,而非标准输入的数据。 obs=字节:每次写入指定的。 of=文件:将数据写入,而不在标准输出显示。 seek=块数目:先略过以obs为单位的指定的输出数据。 skip=块数目:先略过以ibs为单位的指定的输入数据。 4.应用实例 dd命令常常用来制作Linux启动盘。先找一个可引导内核,令它的根设备指向正确的根分区,然后使用dd命令将其写入软盘: $ rdev vmlinuz /dev/hda $dd if=vmlinuz of=/dev/fd0 上面代码说明,使用rdev命令将可引导内核vmlinuz中的根设备指向/dev/hda,请把“hda”换成自己的根分区,接下来用dd命令将该内核写入软盘。 find 1.作用 find命令的作用是在目录中搜索文件,它的使用权限是所有用户。 2.格式 find [path][options][expression] path指定目录路径,系统从这里开始沿着目录树向下查找文件。它是一个路径列表,相互用空格分离,如果不写path,那么默认为当前目录。 3.主要参数 [options]参数: -depth:使用深度级别的查找过程方式,在某层指定目录中优先查找文件内容。 -maxdepth levels:表示至多查找到开始目录的第level层子目录。level是一个非负数,如果level是0的话表示仅在当前目录中查找。 -mindepth levels:表示至少查找到开始目录的第level层子目录。 -mount:不在其它文件系统(如Msdos、Vfat等)的目录和文件中查找。 -version:打印版本。 [expression]是匹配表达式,是find命令接受的表达式,find命令的所有操作都是针对表达式的。它的参数非常多,这里只介绍一些常用的参数。 —name:支持统配符*和?。 -atime n:搜索在过去n天读取过的文件。 -ctime n:搜索在过去n天修改过的文件。 -group grpoupname:搜索所有组为grpoupname的文件。 -user 用户名:搜索所有文件属主为用户名(ID或名称)的文件。 -size n:搜索文件大小是n个block的文件。 -print:输出搜索结果,并且打印。 4.应用技巧 find命令查找文件的几种方法: (1)根据文件名查找 例如,我们想要查找一个文件名是lilo.conf的文件,可以使用如下命令: find / -name lilo.conf find命令后的“/”表示搜索整个硬盘。 (2)快速查找文件 根据文件名查找文件会遇到一个实际问题,就是要花费相当长的一段时间,特别是大型Linux文件系统和大容量硬盘文件放在很深的子目录中时。如果我们知道了这个文件存放在某个目录中,那么只要在这个目录中往下寻找就能节省很多时间。比如smb.conf文件,从它的文件后缀“.conf”可以判断这是一个配置文件,那么它应该在/etc目录内,此时可以使用下面命令: find /etc -name smb.conf 这样,使用“快速查找文件”方式可以缩短时间。 (3)根据部分文件名查找方法 有时我们知道只某个文件包含有abvd这4个字,那么要查找系统中所有包含有这4个字符的文件可以输入下面命令: find / -name '*abvd*' 输入这个命令以后,Linux系统会将在/目录中查找所有的包含有abvd这4个字符的文件(其中*是通配符),比如abvdrmyz等符合条件的文件都能显示出来。 (4) 使用混合查找方式查找文件 find命令可以使用混合查找的方法,例如,我们想在/etc目录中查找大于500000字节,并且在24小时内修改的某个文件,则可以使用-and (与)把两个查找参数链接起来组合成一个混合的查找方式。 find /etc -size +500000c -and -mtime +1 mv 1.作用 mv命令用来为文件或目录改名,或者将文件由一个目录移入另一个目录中,它的使用权限是所有用户。该命令如同DOS命令中的ren和move的组合。 2.格式 mv[options] 源文件或目录 目标文件或目录 3.[options]主要参数 -i:交互方式操作。如果mv操作将导致对已存在的目标文件的覆盖,此时系统询问是否重写,要求用户回答“y”或“n”,这样可以避免误覆盖文件。 -f:禁止交互操作。mv操作要覆盖某个已有的目标文件时不给任何指示,指定此参数后i参数将不再起作用。 4.应用实例 (1)将/usr/cbu中的所有文件移到当前目录(用“.”表示)中: $ mv /usr/cbu/ * . (2)将文件cjh.txt重命名为wjz.txt: $ mv cjh.txt wjz.txt  ls 1.作用 ls命令用于显示目录内容,类似DOS下的dir命令,它的使用权限是所有用户。 2.格式 ls [options][filename] 3.options主要参数 -a, --all:不隐藏任何以“.” 字符开始的项目。 -A, --almost-all:列出除了“ . ”及 “.. ”以外的任何项目。 --author:印出每个文件著作者。 -b, --escape:以八进制溢出序列表示不可打印的字符。 --block-size=大小:块以指定的字节为单位。 -B, --ignore-backups:不列出任何以 ~ 字符结束的项目。 -f:不进行排序,-aU参数生效,-lst参数失效。 -F, --classify:加上文件类型的指示符号 (*/=@| 其中一个)。 -g:like -l, but do not list owner。 -G, --no-group:inhibit display of group information。 -i, --inode:列出每个文件的inode号。 -I, --ignore=样式:不印出任何符合Shell万用字符的项目。 -k:即--block-size=1K。 -l:使用较长格式列出信息。 -L, --dereference:当显示符号链接的文件信息时,显示符号链接所指示的对象,而并非符号链接本身的信息。 -m:所有项目以逗号分隔,并填满整行行宽。 -n, --numeric-uid-gid:类似-l,但列出UID及GID号。 -N, --literal:列出未经处理的项目名称,例如不特别处理控制字符。 -p, --file-type:加上文件类型的指示符号 (/=@| 其中一个)。 -Q, --quote-name:将项目名称括上双引号。 -r, --reverse:依相反次序排列。 -R, --recursive:同时列出所有子目录层。 -s, --size:以块大小为序。 4.应用举例 ls 命令是Linux系统使用频率最多的命令,它的参数也是Linux命令中最多的。使用ls命令时会有几种不同的颜色,其中蓝色表示是目录,绿色表示是可执行文件,红色表示是压缩文件,浅蓝色表示是链接文件,加粗的黑色表示符号链接,灰色表示是其它格式文件。ls最常使用的是ls- l,见图1所示。 图1 使用ls-l命令 文件类型开头是由10个字符构成的字符串。其中第一个字符表示文件类型,它可以是下述类型之一:-(普通文件)、d(目录)、l(符号链接)、b(块设备文件)、c(字符设备文件)。后面的9个字符表示文件的访问权限,分为3组,每组3位。第一组表示文件属主的权限,第二组表示同组用户的权限,第三组表示其他用户的权限。每一组的三个字符分别表示对文件的读(r)、写(w)和执行权限(x)。对于目录,表示进入权限。s表示当文件被执行时,把该文件的UID 或GID赋予执行进程的UID(用户ID)或GID(组 ID)。t表示设置标志位(留在内存,不被换出)。如果该文件是目录,那么在该目录中的文件只能被超级用户、目录拥有者或文件属主删除。如果它是可执行文件,那么在该文件执行后,指向其正文段的指针仍留在内存。这样再次执行它时,系统就能更快地装入该文件。接着显示的是文件大小、生成时间、文件或命令名称。 diff 1.作用 diff命令用于两个文件之间的比较,并指出两者的不同,它的使用权限是所有用户。 2.格式 diff [options] 源文件 目标文件 3.[options]主要参数 -a:将所有文件当作文本文件来处理。 -b:忽略空格造成的不同。 -B:忽略空行造成的不同。 -c:使用纲要输出格式。 -H:利用试探法加速对大文件的搜索。 -I:忽略大小写的变化。 -n --rcs:输出RCS格式。 cmp 1.作用 cmp(“compare”的缩写)命令用来简要指出两个文件是否存在差异,它的使用权限是所有用户。 2.格式 cmp[options] 文件名 3.[options]主要参数 -l: 将字节以十进制的方式输出,并方便将两个文件中不同的以八进制的方式输出。 cat 1.作用 cat(“concatenate”的缩写)命令用于连接并显示指定的一个和多个文件的有关信息,它的使用权限是所有用户。 2.格式 cat [options] 文件1 文件2…… 3.[options]主要参数 -n:由第一行开始对所有输出的行数编号。 -b:和-n相似,只不过对于空白行不编号。 -s:当遇到有连续两行以上的空白行时,就代换为一行的空白行。 4.应用举例 (1)cat命令一个最简单的用处是显示文本文件的内容。例如,我们想在命令行看一下README文件的内容,可以使用命令: $ cat README  (2)有时需要将几个文件处理成一个文件,并将这种处理的结果保存到一个单独的输出文件。cat命令在其输入上接受一个或多个文件,并将它们作为一个单独的文件打印到它的输出。例如,把README和INSTALL的文件内容加上行号(空白行不加)之后,将内容附加到一个新文本文件File1 中: $ cat README INSTALL File1 (3)cat还有一个重要的功能就是可以对行进行编号,见图2所示。这种功能对于程序文档的编制,以及法律和科学文档的编制很方便,打印在左边的行号使得参考文档的某一部分变得容易,这些在编程、科学研究、业务报告甚至是立法工作中都是非常重要的。 图2 使用cat命令/etc/named.conf文件进行编号 对行进行编号功能有-b(只能对非空白行进行编号)和-n(可以对所有行进行编号)两个参数: $ cat -b /etc/named.conf ln 1.作用 ln命令用来在文件之间创建链接,它的使用权限是所有用户。 2.格式 ln [options] 源文件 [链接名] 3.参数 -f:链结时先将源文件删除。 -d:允许系统管理者硬链结自己的目录。 -s:进行软链结(Symbolic Link)。 -b:将在链结时会被覆盖或删除的文件进行备份。 链接有两种,一种被称为硬链接(Hard Link),另一种被称为符号链接(Symbolic Link)。默认情况下,ln命令产生硬链接。 硬连接指通过索引节点来进行的连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。一般这种连接就是硬连接。硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。其原因如上所述,因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件才会被真正删除。 与硬连接相对应,Lnux系统中还存在另一种连接,称为符号连接(Symbilc Link),也叫软连接。软链接文件有点类似于Windows的快捷方式。它实际上是特殊文件的一种。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。 动手练习 上面我们介绍了Linux文件处理命令,下面介绍几个实例,大家可以动手练习一下刚才讲过的命令。 1.利用符号链接快速访问关键目录 符号链接是一个非常实用的功能。假设有一些目录或文件需要频繁使用,但由于Linux的文件和目录结构等原因,这个文件或目录在很深的子目录中。比如, Apache Web服务器文档位于系统的/usr/local/httpd/htdocs中,并且不想每次都要从主目录进入这样一个长的路径之中(实际上,这个路径也非常不容易记忆)。 为了解决这个问题,可以在主目录中创建一个符号链接,这样在需要进入该目录时,只需进入这个链接即可。 为了能方便地进入Web服务器(/usr/local/httpd/htdocs)文档所在的目录,在主目录下可以使用以下命令: $ ln -s /usr/local/httpd/htdocs gg 这样每次进入gg目录就可访问Web服务器的文档,以后如果不再访问Web服务器的文档时,删除gg即可,而真正的Web服务器的文档并没有删除。 2.使用dd命令将init.rd格式的root.ram内容导入内存 dd if=/dev/fd0 of=floppy.fd dd if=root.ram of=/dev/ram0 # 3.grep命令系统调用 grep是Linux/Unix中使用最广泛的命令之一,许多Linux系统内部都可以调用它。 (1)如果要查询目录列表中的目录,方法如下: $ ls -l | grep '∧d' (2)如果在一个目录中查询不包含目录的所有文件,方法如下: $ ls -l | grep '∧[∧d]' (3)用find命令调用grep,如所有C源代码中的“Chinput”,方法如下: $find /ZhXwin -name *.c -exec grep -q -s Chinput {} \;-print -------------------------------------------------------------------------------- Linux必学的60个命令(3)-系统管理命令 Linux必学的系统管理命令 对于Linux系统来说,无论是中央处理器、内存、磁盘驱动器、键盘、鼠标,还是用户等都是文件,Linux系统管理的命令是它正常运行的核心。熟悉了Linux常用的文件处理命令以后,这一讲介绍对系统和用户进行管理的命令。 df 1.作用 df命令用来检查文件系统的磁盘空间占用情况,使用权限是所有用户。 2.格式 df [options] 3.主要参数 -s:对每个Names参数只给出占用的数据块总数。 -a:递归地显示指定目录中各文件及子目录中各文件占用的数据块数。若既不指定-s,也不指定-a,则只显示Names中的每一个目录及其中的各子目录所占的磁盘块数。 -k:以1024字节为单位列出磁盘空间使用情况。 -x:跳过在不同文件系统上的目录不予统计。 -l:计算所有的文件大小,对硬链接文件则计算多次。 -i:显示inode信息而非块使用量。 -h:以容易理解的格式印出文件系统大小,例如136KB、254MB、21GB。 -P:使用POSIX输出格式。 -T:显示文件系统类型。 4.说明 df命令被广泛地用来生成文件系统的使用统计数据,它能显示系统中所有的文件系统的信息,包括总容量、可用的空闲空间、目前的安装点等。 超级权限用户使用df命令时会发现这样的情况:某个分区的容量超过了100%。这是因为Linux系统为超级用户保留了10%的空间,由其单独支配。也就是说,对于超级用户而言,他所见到的硬盘容量将是110%。这样的安排对于系统管理而言是有好处的,当硬盘被使用的容量接近100%时系统管理员还可以正常工作。 5.应用实例 Linux支持的文件系统非常多,包括JFS、ReiserFS、ext、ext2、ext3、ISO9660、XFS、Minx、vfat、MSDOS等。使用df -T命令查看磁盘空间时还可以得到文件系统的信息: #df -T 文件系统 类型 容量 已用 可用 已用% 挂载点 /dev/hda7 reiserfs 5.2G 1.6G 3.7G 30% / /dev/hda1 vfat 2.4G 1.6G 827M 66% /windows/C /dev/hda5 vfat 3.0G 1.7G 1.3G 57% /windows/D /dev/hda9 vfat 3.0G 2.4G 566M 82% /windows/E /dev/hda10 NTFS 3.2G 573M 2.6G 18% /windows/F /dev/hda11 vfat 1.6G 1.5G 23M 99% /windows/G 从上面除了可以看到磁盘空间的容量、使用情况外,分区的文件系统类型、挂载点等信息也一览无遗。 top 1.作用 top命令用来显示执行中的程序进程,使用权限是所有用户。 2.格式 top [-] [d delay] [q] [c] [S] [s] [i] [n] 3.主要参数 d:指定更新的间隔,以秒计算。 q:没有任何延迟的更新。如果使用者有超级用户,则top命令将会以最高的优先序执行。 c:显示进程完整的路径与名称。 S:累积模式,会将己完成或消失的子行程的CPU时间累积起来。 s:安全模式。 i:不显示任何闲置(Idle)或无用(Zombie)的行程。 n:显示更新的次数,完成后将会退出top。 4.说明 top命令是Linux系统管理的一个主要命令,通过它可以获得许多信息。这里我们结合图1来说明它给出的信息。 图1 top命令的显示 在图1中,第一行表示的项目依次为当前时间、系统启动时间、当前系统登录用户数目、平均负载。第二行显示的是所有启动的进程、目前运行的、挂起 (Sleeping)的和无用(Zombie)的进程。第三行显示的是目前CPU的使用情况,包括系统占用的比例、用户使用比例、闲置(Idle)比例。第四行显示物理内存的使用情况,包括总的可以使用的内存、已用内存、空闲内存、缓冲区占用的内存。第五行显示交换分区使用情况,包括总的交换分区、使用的、空闲的和用于高速缓存的大小。第六行显示的项目最多,下面列出了详细解释。 PID(Process ID):进程标示号。 USER:进程所有者的用户名。 PR:进程的优先级别。 NI:进程的优先级别数值。 VIRT:进程占用的虚拟内存值。 RES:进程占用的物理内存值。 SHR:进程使用的共享内存值。 S:进程的状态,其中S表示休眠,R表示正在运行,Z表示僵死状态,N表示该进程优先值是负数。 %CPU:该进程占用的CPU使用率。 %MEM:该进程占用的物理内存和总内存的百分比。 TIME+:该进程启动后占用的总的CPU时间。 Command:进程启动的启动命令名称,如果这一行显示不下,进程会有一个完整的命令行。 top命令使用过程中,还可以使用一些交互的命令来完成其它参数的功能。这些命令是通过快捷键启动的。 :立刻刷新。 P:根据CPU使用大小进行排序。 T:根据时间、累计时间排序。 q:退出top命令。 m:切换显示内存信息。 t:切换显示进程和CPU状态信息。 c:切换显示命令名称和完整命令行。 M:根据使用内存大小进行排序。 W:将当前设置写入~/.toprc文件中。这是写top配置文件的推荐方法。 可以看到,top命令是一个功能十分强大的监控系统的工具,对于系统管理员而言尤其重要。但是,它的缺点是会消耗很多系统资源。 5.应用实例 使用top命令可以监视指定用户,缺省情况是监视所有用户的进程。如果想查看指定用户的情况,在终端中按“U”键,然后输入用户名,系统就会切换为指定用户的进程运行界面,见图2所示。 图2 使用top命令监视指定用户 free 1.作用 free命令用来显示内存的使用情况,使用权限是所有用户。 2.格式 free [-b|-k|-m] [-o] [-s delay] [-t] [-V] 3.主要参数 -b -k -m:分别以字节(KB、MB)为单位显示内存使用情况。 -s delay:显示每隔多少秒数来显示一次内存使用情况。 -t:显示内存总和列。 -o:不显示缓冲区调节列。 4.应用实例 free命令是用来查看内存使用情况的主要命令。和top命令相比,它的优点是使用简单,并且只占用很少的系统资源。通过-S参数可以使用free命令不间断地监视有多少内存在使用,这样可以把它当作一个方便实时监控器。 #free -b -s5 使用这个命令后终端会连续不断地报告内存使用情况(以字节为单位),每5秒更新一次。 quota 1.作用 quota命令用来显示磁盘使用情况和限制情况,使用权限超级用户。 2.格式 quota [-g][-u][-v][-p] 用户名 组名 3.参数 -g:显示用户所在组的磁盘使用限制。 -u:显示用户的磁盘使用限制。 -v:显示没有分配空间的文件系统的分配情况。 -p:显示简化信息。 4.应用实例 在企业应用中磁盘配额非常重要,普通用户要学会看懂自己的磁盘使用情况。要查询自己的磁盘配额可以使用下面命令(下例中用户账号是caojh): #quota caojh Disk quotas for user caojh(uid 502): Filesystem blocks quota limit grace files quota limit grace /dev/hda3 58 200000 400000 41 500 1000 以上显示ID号为502的caojh账号,文件个数设置为500~1000个,硬盘空间限制设置为200MB~400MB。一旦磁盘配额要用完时,就需要删除一些垃圾文件或向系统管理员请求追加配额。 at 1.作用 at命令用来在指定时刻执行指定的命令序列。 2.格式 at [-V] [-q x] [-f file] [-m] time 3.主要参数 -V:显示标准错误输出。 -q:许多队列输出。 -f:从文件中读取作业。 -m:执行完作业后发送电子邮件到用户。 time:设定作业执行的时间。time格式有严格的要求,由小时、分钟、日期和时间的偏移量组成,其中日期的格式为MM.DD.YY,MM是分钟,DD是日期,YY是指年份。偏移量的格式为时间+偏移量,单位是minutes、hours和days。 4.应用实例 #at -f data 15:30 +2 days 上面命令表示让系统在两天后的17:30执行文件data中指明的作业。 lp 1.作用 lp是打印文件的命令,使用权限是所有用户。 2.格式 lp [-c][-d][-m][-number][-title][-p] 3.主要参数 -c:先拷贝文件再打印。 -d:打印队列文件。 -m:打印结束后发送电子邮件到用户。 -number:打印份数。 -title:打印标题。 -p:设定打印的优先级别,最高为100。 4.应用实例 (1)使用lp命令打印多个文件 #lp 2 3 4 request id is 11 (3 file(s)) 其中2、3、4分别是文件名;“request id is 11 (3 file(s)) ”表示这是第11个打印命令,依次打印这三个文件。 (2)设定打印优先级别 #lp lp -d LaserJet -p 90 /etc/aliases 通过添加“-p 90”,规定了打印作业的优先级为90。它将在优先级低于90的打印作业之前打印,包括没有设置优先级的作业,缺省优先级是50 useradd 1.作用 useradd命令用来建立用户帐号和创建用户的起始目录,使用权限是超级用户。 2.格式 useradd [-d home] [-s shell] [-c comment] [-m [-k template]] [-f inactive] [-e expire ] [-p passwd] [-r] name 3.主要参数 -c:加上备注文字,备注文字保存在passwd的备注栏中。  -d:指定用户登入时的启始目录。 -D:变更预设值。 -e:指定账号的有效期限,缺省表示永久有效。 -f:指定在密码过期后多少天即关闭该账号。 -g:指定用户所属的群组。 -G:指定用户所属的附加群组。 -m:自动建立用户的登入目录。 -M:不要自动建立用户的登入目录。 -n:取消建立以用户名称为名的群组。 -r:建立系统账号。 -s:指定用户登入后所使用的shell。 -u:指定用户ID号。 4.说明 useradd可用来建立用户账号,它和adduser命令是相同的。账号建好之后,再用passwd设定账号的密码。使用useradd命令所建立的账号,实际上是保存在/etc/passwd文本文件中。 5.应用实例 建立一个新用户账户,并设置ID: #useradd caojh -u 544 需要说明的是,设定ID值时尽量要大于500,以免冲突。因为Linux安装后会建立一些特殊用户,一般0到499之间的值留给bin、mail这样的系统账号。 groupadd 1.作用 groupadd命令用于将新组加入系统。 2.格式 groupadd [-g gid] [-o]] [-r] [-f] groupname 3.主要参数 -g gid:指定组ID号。 -o:允许组ID号,不必惟一。 -r:加入组ID号,低于499系统账号。 -f:加入已经有的组时,发展程序退出。 4.应用实例 建立一个新组,并设置组ID加入系统: #groupadd -g 344 cjh 此时在/etc/passwd文件中产生一个组ID(GID)是344的项目。 kill 1.作用 kill命令用来中止一个进程。 2.格式 kill [ -s signal | -p ] [ -a ] pid ... kill -l [ signal ] 3.参数 -s:指定发送的信号。 -p:模拟发送信号。 -l:指定信号的名称列表。 pid:要中止进程的ID号。 Signal:表示信号。 4.说明 进程是Linux系统中一个非常重要的概念。Linux是一个多任务的操作系统,系统上经常同时运行着多个进程。我们不关心这些进程究竟是如何分配的,或者是内核如何管理分配时间片的,所关心的是如何去控制这些进程,让它们能够很好地为用户服务。 Linux 操作系统包括三种不同类型的进程,每种进程都有自己的特点和属性。交互进程是由一个Shell启动的进程。交互进程既可以在前台运行,也可以在后台运行。批处理进程和终端没有联系,是一个进程序列。监控进程(也称系统守护进程)时Linux系统启动时启动的进程,并在后台运行。例如,httpd是著名的 Apache服务器的监控进程。 kill命令的工作原理是,向Linux系统的内核发送一个系统操作信号和某个程序的进程标识号,然后系统内核就可以对进程标识号指定的进程进行操作。比如在top命令中,我们看到系统运行许多进程,有时就需要使用kill中止某些进程来提高系统资源。在讲解安装和登陆命令时,曾提到系统多个虚拟控制台的作用是当一个程序出错造成系统死锁时,可以切换到其它虚拟控制台工作关闭这个程序。此时使用的命令就是kill,因为kill是大多数Shell内部命令可以直接调用的。 5.应用实例 (1)强行中止(经常使用杀掉)一个进程标识号为324的进程: #kill -9 324 (2)解除Linux系统的死锁 在Linux 中有时会发生这样一种情况:一个程序崩溃,并且处于死锁的状态。此时一般不用重新启动计算机,只需要中止(或者说是关闭)这个有问题的程序即可。当 kill处于X-Window界面时,主要的程序(除了崩溃的程序之外)一般都已经正常启动了。此时打开一个终端,在那里中止有问题的程序。比如,如果 Mozilla浏览器程序出现了锁死的情况,可以使用kill命令来中止所有包含有Mozolla浏览器的程序。首先用top命令查处该程序的PID,然后使用kill命令停止这个程序: #kill -SIGKILL XXX 其中,XXX是包含有Mozolla浏览器的程序的进程标识号。 (3)使用命令回收内存 我们知道内存对于系统是非常重要的,回收内存可以提高系统资源。kill命令可以及时地中止一些“越轨”的程序或很长时间没有相应的程序。例如,使用top命令发现一个无用 (Zombie) 的进程,此时可以使用下面命令: #kill -9 XXX 其中,XXX是无用的进程标识号。 然后使用下面命令: #free 此时会发现可用内存容量增加了。 (4)killall命令 Linux下还提供了一个killall命令,可以直接使用进程的名字而不是进程标识号,例如: # killall -HUP inetd crontab 1.作用 使用crontab命令可以修改crontab配置文件,然后该配置由cron公用程序在适当的时间执行,该命令使用权限是所有用户。 2.格式 crontab [ -u user ] 文件 crontab [ -u user ] { -l | -r | -e } 3.主要参数 -e:执行文字编辑器来设定时程表,内定的文字编辑器是vi。 -r:删除目前的时程表。 -l:列出目前的时程表。 crontab 文件的格式为“M H D m d cmd”。其中,M代表分钟(0~59),H代表小时(0~23),D代表天(1~31),m代表月(1~12),d代表一星期内的天(0~6,0为星期天)。cmd表示要运行的程序,它被送入sh执行,这个Shell只有USER、HOME、SHELL三个环境变量。 4.说明 和at命令相比,crontab命令适合完成固定周期的任务。 5.应用实例 设置一个定时、定期的系统提示: [cao @www cao]#crontab -e 此时系统会打开一个vi编辑器。 如果输入以下内容:35 17 * * 5 wall "Tomorrow is Saturday I will go CS",然后存盘退出。这时在/var/spool/cron/目录下会生产一个cao的文件,内容如下: # DO NOT EDIT THIS FILE - edit the master and reinstall. # (/tmp/crontab.2707 installed on Thu Jan 1 22:01:51 2004) # (Cron version -- $Id: crontab.c,v 2.13 1994/01/17 03:20:37 vixie Exp $) 35 17 * * 5 wall "Tomorrow is Saturday I will play CS " 这样每个星期五17:35系统就会弹出一个终端,提醒星期六可以打打CS了!显示结果见图3所示。 图3 一个定时、定期的系统提示 动手练习 1.联合使用kill和top命令观察系统性能的变化 首先启动一个终端运行top命令,然后再启动一个终端使用kill命令,见图4所示。 图4 观察kill命令对top终端的影响 这时利用上面介绍的kill命令来中止一些程序: #kill SIGKILL XXX 然后再看top命令终端的变化,包括内存容量、CPU使用率、系统负载等。注意,有些进程是不能中止的,不过学习Linux命令时可以试试,看看系统有什么反应。 2.使用at和halt命令定时关机 首先设定关机时间是17:35,输入下面代码: #at 17:35 warning: commands will be executed using (in order) a) $SHELL b) login shell c) /bin/sh at>halt `-i -p at> job 6 at 2004-01-01 17:35 此时实际上就已经进入Linux系统的Shell,并且编写一个最简单程序:halt -i -p。上面Shell中的文本结束符号表示按“Ctrl+D”组合键关闭命令,提交任务退出Shell。“Job 6 at 2004-01-01 17:35”表示系统接受第6个at命令,在“2004-01-01 17:35”时执行命令:先把所有网络相关的装置停止,关闭系统后关闭电源。 3.用crontab命令实现每天定时的病毒扫描 前面已经介绍了一个简单的crontab命令操作,这里看一些更重要的操作。 (1)建立一个文件,文件名称自己设定,假设为caoproject: #crontab -e (2)文件内容如下: 05 09 * * * antivir 用vi编辑后存盘退出。antivir是一个查杀Linux病毒的软件,当然需要时先安装在系统中。 (3)使用crontab命令添加到任务列表中: #crontab caoproject 这样系统内所有用户在每天的9点05分会自动进行病毒扫描。 4.用kill使修改的配置文件马上生效 Windows用户一般都知道,重要配置文件修改后往往都要重新启动计算机才能使修改生效。而Linux由于采用了模块化设计,可以自己根据需要实时设定服务。这里以网络服务inetd为例介绍一些操作技巧。 inetd 是一个监听守护进程,监听与提供互联网服务进程(如rlogin、telnet、ftp、rsh)进行连接的要求,并扩展所需的服务进程。默认情况下, inetd监听的这些daemon均列于/etc /inetd.conf文件中。编辑/etc/inetd.conf文件,可以改变inetd启动服务器守护进程的选项,然后驱使inetd以 SIGHUP(signal 1)向当前的inetd进程发送信号,使inetd重读该文件。这一过程由kill命令来实现。 用vi或其它编辑器修改inetd.conf后,首先使用下面命令: #ps -ef |grep inetd 上面代码表明查询inetd.conf的进程号(PID),这里假设是1426,然后使用下面命令: # kill -1426 inetd 这样配置文件就生效了。 这一讲介绍的系统管理命令都是比较重要的,特别是crontab命令和quota命令使用起来会有一定难度,需要多做一些练习。另外,使用kill命令要注意“-9“这个参数,练习时最好不要运行一些重要的程序。 -------------------------------------------------------------------------------- Linux必学的60个命令(4)-网络操作命令 Linux必学的60个命令:网络操作命令 因为Linux系统是在Internet上起源和发展的,它与生俱来拥有强大的网络功能和丰富的网络应用软件,尤其是TCP/IP网络协议的实现尤为成熟。 Linux的网络命令比较多,其中一些命令像ping、 ftp、telnet、route、netstat等在其它操作系统上也能看到,但也有一些Unix/Linux系统独有的命令,如ifconfig、 finger、mail等。Linux网络操作命令的一个特点是,命令参数选项和功能很多,一个命令往往还可以实现其它命令的功能。 ifconfig 1.作用 ifconfig用于查看和更改网络接口的地址和参数,包括IP地址、网络掩码、广播地址,使用权限是超级用户。 2.格式 ifconfig -interface [options] address 3.主要参数 -interface:指定的网络接口名,如eth0和eth1。 up:激活指定的网络接口卡。 down:关闭指定的网络接口。 broadcast address:设置接口的广播地址。 pointopoint:启用点对点方式。 address:设置指定接口设备的IP地址。 netmask address:设置接口的子网掩码。 4.应用说明 ifconfig是用来设置和配置网卡的命令行工具。为了手工配置网络,这是一个必须掌握的命令。使用该命令的好处是无须重新启动机器。要赋给eth0接口IP地址207.164.186.2,并且马上激活它,使用下面命令: #fconfig eth0 210.34.6.89 netmask 255.255.255.128 broadcast 210.34.6.127 该命令的作用是设置网卡eth0的IP地址、网络掩码和网络的本地广播地址。若运行不带任何参数的ifconfig命令,这个命令将显示机器所有激活接口的信息。带有“-a”参数的命令则显示所有接口的信息,包括没有激活的接口。注意,用ifconfig命令配置的网络设备参数,机器重新启动以后将会丢失。 如果要暂停某个网络接口的工作,可以使用down参数: #ifconfig eth0 down ip 1.作用 ip是iproute2软件包里面的一个强大的网络配置工具,它能够替代一些传统的网络管理工具,例如ifconfig、route等,使用权限为超级用户。几乎所有的Linux发行版本都支持该命令。 2.格式 ip [OPTIONS] OBJECT [COMMAND [ARGUMENTS]] 3.主要参数 OPTIONS是修改ip行为或改变其输出的选项。所有的选项都是以-字符开头,分为长、短两种形式。目前,ip支持如表1所示选项。 OBJECT是要管理者获取信息的对象。目前ip认识的对象见表2所示。 表1 ip支持的选项 -V,-Version 打印ip的版本并退出。 -s,-stats,-statistics 输出更为详尽的信息。如果这个选项出现两次或多次,则输出的信息将更为详尽。 -f,-family 这个选项后面接协议种类,包括inet、inet6或link,强调使用的协议种类。如果没有足够的信息告诉ip使用的协议种类,ip就会使用默认值inet或any。link比较特殊,它表示不涉及任何网络协议。 -4 是-family inet的简写。 -6 是-family inet6的简写。 -0 是-family link的简写。 -o,-oneline 对每行记录都使用单行输出,回行用字符代替。如果需要使用wc、grep等工具处理ip的输出,则会用到这个选项。 -r,-resolve 查询域名解析系统,用获得的主机名代替主机IP地址 COMMAND 设置针对指定对象执行的操作,它和对象的类型有关。一般情况下,ip支持对象的增加(add)、删除(delete)和展示(show或list)。有些对象不支持这些操作,或者有其它的一些命令。对于所有的对象,用户可以使用help命令获得帮助。这个命令会列出这个对象支持的命令和参数的语法。如果没有指定对象的操作命令,ip会使用默认的命令。一般情况下,默认命令是list,如果对象不能列出,就会执行help命令。 ARGUMENTS 是命令的一些参数,它们倚赖于对象和命令。ip支持两种类型的参数:flag和parameter。flag由一个关键词组成;parameter由一个关键词加一个数值组成。为了方便,每个命令都有一个可以忽略的默认参数。例如,参数dev是ip link命令的默认参数,因此ip link ls eth0等于ip link ls dev eth0。我们将在后面的详细介绍每个命令的使用,命令的默认参数将使用default标出。 4.应用实例 添加IP地址192.168.2.2/24到eth0网卡上: #ip addr add 192.168.1.1/24 dev eth0 丢弃源地址属于192.168.2.0/24网络的所有数据报: #ip rule add from 192.168.2.0/24 prio 32777 reject ping 1.作用 ping检测主机网络接口状态,使用权限是所有用户。 2.格式 ping [-dfnqrRv][-c][-i][-I][-l][-p][-s][-t] IP地址 3.主要参数 -d:使用Socket的SO_DEBUG功能。 -c:设置完成要求回应的次数。 -f:极限检测。 -i:指定收发信息的间隔秒数。 -I:网络界面使用指定的网络界面送出数据包。 -l:前置载入,设置在送出要求信息之前,先行发出的数据包。 -n:只输出数值。 -p:设置填满数据包的范本样式。 -q:不显示指令执行过程,开头和结尾的相关信息除外。 -r:忽略普通的Routing Table,直接将数据包送到远端主机上。 -R:记录路由过程。 -s:设置数据包的大小。 -t:设置存活数值TTL的大小。 -v:详细显示指令的执行过程。 ping 命令是使用最多的网络指令,通常我们使用它检测网络是否连通,它使用ICMP协议。但是有时会有这样的情况,我们可以浏览器查看一个网页,但是却无法 ping通,这是因为一些网站处于安全考虑安装了防火墙。另外,也可以在自己计算机上试一试,通过下面的方法使系统对ping没有反应: # echo 1 > /proc/sys/net/ipv4/icmp_echo_ignore_all netstat 1.作用 检查整个Linux网络状态。 2.格式 netstat [-acCeFghilMnNoprstuvVwx][-A][--ip] 3.主要参数 -a--all:显示所有连线中的Socket。 -A:列出该网络类型连线中的IP相关地址和网络类型。 -c--continuous:持续列出网络状态。 -C--cache:显示路由器配置的快取信息。 -e--extend:显示网络其它相关信息。 -F--fib:显示FIB。 -g--groups:显示多重广播功能群组组员名单。 -h--help:在线帮助。 -i--interfaces:显示网络界面信息表单。 -l--listening:显示监控中的服务器的Socket。 -M--masquerade:显示伪装的网络连线。 -n--numeric:直接使用IP地址,而不通过域名服务器。 -N--netlink--symbolic:显示网络硬件外围设备的符号连接名称。 -o--timers:显示计时器。 -p--programs:显示正在使用Socket的程序识别码和程序名称。 -r--route:显示Routing Table。 -s--statistice:显示网络工作信息统计表。 -t--tcp:显示TCP传输协议的连线状况。 -u--udp:显示UDP传输协议的连线状况。 -v--verbose:显示指令执行过程。 -V--version:显示版本信息。 -w--raw:显示RAW传输协议的连线状况。 -x--unix:和指定“-A unix”参数相同。 --ip--inet:和指定“-A inet”参数相同。 4.应用实例 netstat 主要用于Linux察看自身的网络状况,如开启的端口、在为哪些用户服务,以及服务的状态等。此外,它还显示系统路由表、网络接口状态等。可以说,它是一个综合性的网络状态的察看工具。在默认情况下,netstat只显示已建立连接的端口。如果要显示处于监听状态的所有端口,使用-a参数即可: #netstat -a Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 *:32768 *:* LISTEN tcp 0 0 *:32769 *:* LISTEN tcp 0 0 *:nfs *:* LISTEN tcp 0 0 *:32770 *:* LISTEN tcp 0 0 *:868 *:* LISTEN tcp 0 0 *:617 *:* LISTEN tcp 0 0 *:mysql *:* LISTEN tcp 0 0 *:netbios-ssn *:* LISTEN tcp 0 0 *:sunrpc *:* LISTEN tcp 0 0 *:10000 *:* LISTEN tcp 0 0 *:http *:* LISTEN ...... 上面显示出,这台主机同时提供HTTP、FTP、NFS、MySQL等服务。 telnet 1.作用 telnet表示开启终端机阶段作业,并登入远端主机。telnet是一个Linux命令,同时也是一个协议(远程登陆协议)。 2.格式 telnet [-8acdEfFKLrx][-b][-e][-k][-l][-n][-S][-X][主机名称IP地址] 3.主要参数 -8:允许使用8位字符资料,包括输入与输出。 -a:尝试自动登入远端系统。 -b:使用别名指定远端主机名称。 -c:不读取用户专属目录里的.telnetrc文件。 -d:启动排错模式。 -e:设置脱离字符。 -E:滤除脱离字符。 -f:此参数的效果和指定“-F”参数相同。 -F:使用Kerberos V5认证时,加上此参数可把本地主机的认证数据上传到远端主机。 -k:使用Kerberos认证时,加上此参数让远端主机采用指定的领域名,而非该主机的域名。 -K:不自动登入远端主机。 -l:指定要登入远端主机的用户名称。 -L:允许输出8位字符资料。 -n:指定文件记录相关信息。 -r:使用类似rlogin指令的用户界面。 -S:服务类型,设置telnet连线所需的IP TOS信息。 -x:假设主机有支持数据加密的功能,就使用它。 -X:关闭指定的认证形态。 4.应用说明 用户使用telnet命令可以进行远程登录,并在远程计算机之间进行通信。用户通过网络在远程计算机上登录,就像登录到本地机上执行命令一样。为了通过 telnet登录到远程计算机上,必须知道远程机上的合法用户名和口令。虽然有些系统确实为远程用户提供登录功能,但出于对安全的考虑,要限制来宾的操作权限,因此,这种情况下能使用的功能是很少的。 t
1. 概要 这是嵌入式PowerPC, ARM和MIPS系统中使用DENX U-Boot和Linux的指导手册。文档中描述了如何在嵌入式PowerPC, ARM和MIPS系统上配置、编译、使用Das U-Boot(常常缩写为“U-Boot”)和Linux操作系统。文档中涵盖了所有你可能需要的用于配置、编译、运行U-Boot和Linux的工具。 2. 绪论 首先,我们介绍如何安装交叉编译开发工具Embedded Linux Development Kit(ELDK),这个开发套件你很有可能会用到——至少当你在标准的x86 PC上使用Linux或者Sun Solaris系统作为开发环境的时候,你会需要它的。 然后,我们会阐述通过串口与你的目标板连接:你需要配置一个终端控制程序,如cu或者kermit。 你常常需要通过网线把映像文件下载到你的目标板上。为了实现这个目的,你需要TFTP和DHCP/BOOTP服务器。文档中提供了简要的相关配置说明。 接下来则是描述如何配置和编译U-Boot使之适用于某个特定的平台,以及如何安装和在该硬件平台上运行。 下一步的工作是配置、建立和安装Linux。我们使用SELF(Simple Embedded Linux Framework)来展示如何建立一个开发环境(包括通过NFS挂载的根文件系统)和一个嵌入式目标板配置(从基于busybox的ramdisk映像文件中运行)。 本文档不会给出如何把U-Boot或者Linux移植到一个新的硬件平台,而是默认你的开发板已经被U-Boot和Linux所支持。 本手册各种文档格式的最新版本可以从以下网址获取: · HTML http://www.denx.de/wiki/publish/DULG/DULG-canyonlands.html · plain ASCII text http://www.denx.de/wiki/publish/DULG/DULG-canyonlands.txt · PostScript European A4 format http://www.denx.de/wiki/publish/DULG/DULG-canyonlands.ps · PDF European A4 format http://www.denx.de/wiki/publish/DULG/DULG-canyonlands.pdf 3. 嵌入式Linux开发工具套件 嵌入式Linux开发工具套件(ELDK)包括GNU交叉开发工具,如编译器、binutils、gdb等工具,和一些已经编译好的目标工具以及负责提供在目标平台上函数调用的库文件。还免费提供了所有的源代码,包括全部补丁、扩展文件、以及用于编译开发工具使用的程序和脚本。安装包都是基于RPM包管理器。 3.1 获取ELDK 可以通过以下方式获得ELDK。 ·DENX计算机系统光盘 ·从以下服务器中下载 FTP方式 ftp://mirror.switch.ch/mirror/eldk/eldk/ ftp://sunsite.utk.edu/pub/linux/eldk/ ftp://ftp.sunet.se/pub/Linux/distributions/eldk/ ftp://ftp.leo.org/pub/eldk/ HTTP方式 http://mirror.switch.ch/ftp/mirror/eldk/eldk/ http://ftp.sunet.se/pub/Linux/distributions/eldk/ http://archiv.leo.org/pub/comp/os/unix/linux/eldk/ 3.2 初始安装 初始安装可以使用放在ELDK目录树根目录下的安装工具。安装工具使用语法如下; $ ./install [-d ] [] [] … -d 确定ELDK安装在哪个目录。如果省略ELDK会安装在当前目录。 确定目标平台的CPU。如果此项设置了一项以上的参数,则会将这些CPU的支持都安装。如果省略将会安装所有CPU的支持。你也可以把ELDK安装到任何空目录下,这么做的唯一条件是你有那个目录的写和执行权限。安装过程并不需要超级用户的特权。由安装时的参数决定安装几个目标组件集合。ELDT包是肯定会安装的。 $ export CROSS_COMPILE=ppc_4xx- //加入环境变量 $ PATH=$PATH:/opt/eldk/usr/bin:/opt/eldk/bin //加入PATH 这样加入的话,每次重启系统后必须重新加入,一劳永逸的办法是编辑/root/.bashrc 加上 export CROSS_COMPILE=ppc_4xx- export PATH=$PATH:/opt/eldk/usr/bin:/opt/eldk/bin 重启系统后即可使用ELDK。 4. 系统设置 在目标平台上安装和配置U-Boot和Linux需要一些工具。特别是在开发过程中,你需要和目标平台保持联系。这一节将告诉你如何配置你的主机以达到上述目的。 4.1 设置串口 为了更好地使用U-Boot和Linux,你需要通过串口将目标板和你的主机连接。U-Boot和Linux可以配置成自动执行而不需要任何用户的干涉。 通过串口有很多种方法来控制你的目标板,比如说使用终端服务器。不过最常见的做法是使用你本机的串口,这时,你主机需要安装一个终端程序,如cu或者kermit。 4.2 配置“kermit” kermit这个名字就代表了它是连接串口和网络的通信软件。事实上在很多计算机和操作系统上使用它,能够很好地满足我们的目的。 kermit在执行其它命令之前,会执行你的用户目录下的初始文件.kermrc,所以可以非常简单的通过初始化命令来定制kermit。下面是使用U-Boot和Linux时推荐配置: ~/.kermrc: set line /dev/ttyS0 set speed 115200 set carrier-watch off set handshake none set flow-control none robust set file type bin set file name lit set rec pack 1000 set send pack 1000 set window 5 这个设置假定你使用的是主机第一个串口(/dev/ttyS0),以115200这个波特率与目标板的串口连接。 然后你可以连接目标板了: $ kermit -c Connecting to /dev/ttyS0, speed 115200. The escape character is Ctrl-\ (ASCII 28, FS) Type the escape character followed by C to get back, or followed by ? to see other options. —————————————————- 下载kermit这个软件时,你会发现有两个kermit包。你只需要安装ckermit。其中gkermit仅仅是实现kermit传输协议的一个命令行工具。如果你主机上的Linux系统没有安装kermit,你可以到kerimt的官方网站 http://www.columbia.edu/kermit/ 下载。 4.3 使用minicom minicom是另外一种非常流行的串口通信终端。很遗憾的是,很多用户发现在使用U-Boot和Linux时,minicom有很多问题,尤其是试图使用它来下载image的时候。因此,不推荐大家使用minicom。 4.4 配置TFTP服务器 使用U-Boot下载Linux内核或者应用程序的最快捷的方法是通过网络传输。为了这一目的,U-Boot实现了TFTP协议(参见U-Boot中的tftpboot命令)。为了使主机支持TFTP,你必须确保TFTP后台程序/usr/sbin/in.tftpd已经安装。在RedHat系统中,你可以运行下面的命令来确认: $ rpm -q tftp-server 如果没有安装,请从你的Linux安装盘或者其它媒介安装。 大多数的Linux发行版都默认关闭TFTP服务。以RedHat系统为例,如果要使能TFTP服务,编辑文件/etc/xinetd.d/tftp,移除这一行: disable = yes 或者注释掉它,或者修改disable = no 此外,确保/tftpboot目录存在,而且有访问权限(至少应该"dr-xr-xr-x")。 5. Das U-Boot 5.1 当前版本 Das U-Boot(或者简称“U-Boot”)是针对嵌入式PowerPC, ARM, MIPS和x86处理器的开放源代码软件。U-Boot项目已经在Sourceforge设立,你可以访问这个官方网站:http://www.denx.de/wiki/UBoot U-Boot最新版的源代码可以在Sourcefoge通过匿名CVS得到。当要求输入匿名用户anonymous的密码时只需要直接按下回车键。 $ cvs -d:pserver:[email protected]:/cvsroot login $ cvs -z6 -d:pserver:[email protected]:/cvsroot co -P u-boot 官方发布的U-Boot也可以通过FTP方式获取。你可以到ftp://ftp.denx.de/pub/u-boot/下载tar形式的压缩包。 或者通过git的方式获取: git clone git://www.denx.de/git/u-boot.git u-boot/ git clone http://www.denx.de/git/u-boot.git u-boot/ git clone rsync://www.denx.de/git/u-boot.git u-boot/ 5.2 源代码包的解压 如果你是通过CVS得到的U-Boot源代码,你可以跳过这一步,因为你得到的已经是解压后的目录树了。如果你是从FTP服务器上下载的tar压缩包,那么你需要按照以下步骤解压: $ cd /opt/eldk/usr/src $ wget ftp://ftp.denx.de/pub/u-boot/u-boot-1.3.2.tar.bz2 $ rm -f u-boot $ bunzip2 < u-boot-0.4.5.tar.bz2 | tar xf - $ ln -s u-boot-0.4.5 u-boot $ cd u-boot 5.3 配置 $ export BUILD_DIR=/opt/eldk/build //指定编译的输出目录 进入U-Boot源代码根目录后,可以先使用如下命令确保已经清除以前编译的结果: $ make distclean 下一步是为Makalu板配置U-Boot: $ make makalu_config (译者注:应该根据你自己的具体开发板配置,如$ make _config,如果没有相应的开发板,应该自己照着建立相应的目录和配置文件。) 最后我们可以开始编译U-Boot了: $ make all 5.4 安装 5.4.1 动手之前 5.4.1.1 安装所需 以下的章节假定你的开发板使用flash作为存储设备。如果不是,则以下的指令不会工作。如果你想使用U-Boot,需要换掉存储设备。 5.4.1.2 开发板识别数据 所有的Makalu开发板使用一个序列号加以识别。而且开发板需要分配一个以太网MAC地址。如果这些数据丢失,你可能会失去授权。在安装U-Boot或者改变开发板的配置之前,你需要搜集足够的信息。 5.4.2 使用BDM/JTAG调试器安装U-Boot.bin 把数据烧入flash中的一个简单而又快速的办法是通过BDM或者JTAG接口的调试器或者flash烧写器。当flash中没有任何数据(比如说一块新的开发板),这种方法是唯一的选择。 我们(强烈推荐)使用Abatron公司的BDI2000(见http://www.abatron.ch/BDI/bdiGDB.html )。 其它的BDM/JTAG调试器也可以使用,但是如何操作它们不是本文档要讨论的范围。如果你想使用别的工具请参照它们的说明文档。(我没有使用BDI2000,故略去操作BDI2000的方法。我烧写u-boot.bin就是简单地通过JTAG口。) 5.4.3 使用U-Boot安装U-Boot.bin 如果U-Boot已经在你的板子上安装运行,你可以使用这些命令来下载新的U-Boot映像来代替当前的。 警告:在你安装新的映像之前,你必须擦除当前的u-boot.bin。如果出现什么差错,你的开发板将不能运行。因此强烈建议: 做一个能工作的U-Boot映像文件的备份; 你清楚如何在一个新的开发板上安装u-boot.bin。 过程如下: => tftp 100000 /tftpboot/uboot.bin ARP broadcast 1 TFTP from server 10.0.0.2; our IP address is 10.0.0.100 Filename ””/tftpboot/uboot.bin””. Load address: 0×100000 Loading: ############################### done Bytes transferred = 155376 (25ef0 hex) => protect off 40000000 4003FFFF Un-Protected 5 sectors => era 40000000 4003FFFF Erase Flash from 0×40000000 to 0x4003ffff ……… done Erased 5 sectors => cp.b 100000 40000000 $(filesize) Copy to Flash… done => setenv filesize => saveenv Saving Enviroment to Flash… Un-Protected 1 sectors Erasing Flash… .. done Erased 1 sectors Writing to Flash… done Protected 1 sectors => reset 5.5 工具的安装 U-Boot加载Linux内核、Ramdisk或者其它映像时使用一种特殊的映像格式。这种格式包含了一些信息,如创建时间、操作系统、压缩格式、映像类型、映像名和CRC32校验和。 mkimage用来创建这种格式的映像文件或者显示它包含的信息。如果使用ELDK,那么mkimage这个命令已经包含在ELDK中。 如果你不想使用ELDK,你应该把mkimage安装在某个能够直接执行的目录里,比如: $ cp tools/mkimage /usr/local/bin/ 5.6 初始化 初始化你的Makalu板上的U-Boot,你需要通过终端连接板子的串口。 Makalu板的串口默认配置是波特率为115200/8N1(115200bps,每个字符8bit,无奇偶校验,1bit停止位,无握手)。 如果你的主机是Linux操作系统,我们建议你用kermit或者cu作为终端控制程序。确定硬件和软件控制流都已经关闭。 5.7 开始的步骤 在默认配置中,U-Boot运行在一种互动模式,它通过串口“UART1”提供命令行形式的用户接口。 这意味着U-Boot显示一个提示符(默认是:=>),等待着接受用户的输入。然后你输入一个命令,按下回车键。U-Boot将运行这个命令,然后又出现提示符等待下一条命令。 你可以使用help(或者简单地一个?)来查看所有的U-Boot命令。它将会列出在你当前配置下所有支持的命令。[请注意到尽管U-Boot提供了很多配置选项,并不是所有选项都支持各种处理器和开发板,有些选项可能在你的配置中并没有被选上。] => help ? – alias for ‘help’ askenv – get environment variables from stdin autoscr – run script from memory base – print or set address offset bdinfo – print Board Info structure boot – boot default, i.e., run ‘bootcmd’ bootd – boot default, i.e., run ‘bootcmd’ bootelf – Boot from an ELF image in memory bootm – boot application image from memory bootp – boot image via network using BootP/TFTP protocol bootstrap – program the I2C bootstrap EEPROM bootvx – Boot vxWorks from an ELF image cmp – memory compare coninfo – print console devices and information cp – memory copy crc32 – checksum calculation date – get/set/reset date & time dhcp – invoke DHCP client to obtain IP/boot params dtt – Digital Thermometer and Thermostat echo – echo args to console eeprom – EEPROM sub-system erase – erase FLASH memory exit – exit script ext2load- load binary file from a Ext2 filesystem ext2ls – list files in a directory (default /) fatinfo – print information about filesystem fatload – load binary file from a dos filesystem fatls – list files in a directory (default /) fdt – flattened device tree utility commands flinfo – print FLASH memory information getdcr – Get an AMCC PPC 4xx DCR’s value getidcr – Get a register value via indirect DCR addressing go – start application at address ‘addr’ help – print online help icrc32 – checksum calculation iloop – infinite loop on address range imd – i2c memory display iminfo – print header information for application image imls – list all images found in flash imm – i2c memory modify (auto-incrementing) imw – memory write (fill) imxtract- extract a part of a multi-image inm – memory modify (constant address) iprobe – probe to discover valid I2C chip addresses irqinfo – print information about IRQs isdram – print SDRAM configuration information itest – return true/false on integer compare loadb – load binary file over serial line (kermit mode) loads – load S-Record file over serial line loady – load binary file over serial line (ymodem mode) loop – infinite loop on address range loopw – infinite write loop on address range md – memory display mdc – memory display cyclic mii – MII utility commands mm – memory modify (auto-incrementing) mtest – simple RAM test mw – memory write (fill) mwc – memory write cyclic nand – NAND sub-system nboot – boot from NAND device nfs – boot image via network using NFS protocol nm – memory modify (constant address) pci – list and access PCI Configuration Space ping – send ICMP ECHO_REQUEST to network host printenv- print environment variables protect – enable or disable FLASH write protection rarpboot- boot image via network using RARP/TFTP protocol reginfo – print register information reset – Perform RESET of the CPU run – run commands in an environment variable saveenv – save environment variables to persistent storage setdcr – Set an AMCC PPC 4xx DCR’s value setenv – set environment variables setidcr – Set a register value via indirect DCR addressing sleep – delay execution for some time test – minimal test like /bin/sh tftpboot- boot image via network using TFTP protocol usb – USB sub-system usbboot – boot from USB device version – print monitor version =>使用help 你可以得到更多的命令信息: => help tftpboot tftpboot [loadAddress] [[hostIPaddr:]bootfilename] => => help setenv printenv setenv name value … - set environment variable ‘name’ to ‘value …’ setenv name - delete environment variable ‘name’ printenv - print values of all environment variables printenv name … - print value of environment variable ‘name’ => 大多数命令可以缩写,只要字符串的内容仍然可以被确定: => help fli tftp flinfo - print information for all FLASH memory banks flinfo N - print information for FLASH memory bank # N tftpboot [loadAddress] [[hostIPaddr:]bootfilename] => 5.8 首次上电 把主机指定的串口和在Makalu板上标有UART1的端口连接,运行终端程序,给Makalu板接上电源。你可以看到如下信息: => reset U-Boot 1.3.3-rc2-01466-g4f27098 (May 1 2008 – 13:57:57) CPU: AMCC PowerPC 460EX Rev. A at 600 MHz (PLB=200, OPB=100, EBC=100 MHz) Security/Kasumi support Bootstrap Option H – Boot ROM Location I2C (Addr 0×52) Internal PCI arbiter disabled 32 kB I-Cache 32 kB D-Cache Board: Canyonlands – AMCC PPC460EX Evaluation Board, 2*PCIe, Rev. 13 I2C: ready DTT: 1 is 48 C DRAM: 256 MB (ECC not enabled, 400 MHz, CL3) FLASH: 64 MB NAND: 32 MiB PCI: Bus Dev VenId DevId Class Int PCIE0: link is not up. PCIE0: initialization as root-complex failed PCIE1: link is not up. PCIE1: initialization as root-complex failed Net: ppc_4xx_eth0, ppc_4xx_eth1 Type run flash_nfs to mount root filesystem over NFS Hit any key to stop autoboot: 0 => => 你可以按下任意键来中止倒计数。如果你不那么做,你可能会看到一些(无关紧要的)错误,因为系统没有初始化。 有时你可能会看到一种信息: *** Warning – bad CRC, using default environment 这条信息没有害处,只要你初始化和保存环境变量之后,它就不会出现了。 首先,你必须输入你的开发板的序列号和网卡地址。需要特别注意的是,这些参数是写保护的,一旦保存了就无法改变(通常制造商已经设置好了)。使用U-Boot的setenv命令可以输入数据,命令后面跟上变量名和值,参数之间用空格(或者Tab符)隔开。例如,使用变量名serial#设置开发板的ID或者说序列号,变量名ethaddr用于设置以太网地址: => => setenv ethaddr !!!!!!FILL_THIS!!!!!! => setenv serial# CF56-216F-400A 使用printenv确认你已经输入正确的值: => printenv serial# ethaddr ## Error: "serial#" not defined ethaddr=5e:ed:18:38:81:85 => 请仔细核查显示值是否正确!等保存之后你将不能更正任何错误。如果发现错误,请重新启动开发板。如果检查无误,你可以使用saveenv命令永久保存这些参数。 => saveenv Saving Environment to Flash… Un-Protected 1 sectors Un-Protected 1 sectors Erasing Flash… . done Erased 1 sectors Writing to Flash… done Protected 1 sectors Protected 1 sectors => 5.9 U-Boot命令介绍 这一节将介绍U-Boot中最重要的指令。U-Boot可配置性非常强,所以并不是所有的命令都已经在你的硬件平台上安装,此外可能也有这儿没提到的命令。你可以使用help命令来显示根据你的配置所有可用的命令列表。 对于大多数命令,你不必打全这些命令,只需输入一些字符足以。比如,help可以简写为h。 一些命令的执行依赖于U-Boot的配置以及U-Boot中一些环境变量的定义。 所有的U-Boot命令都把输入的数字当作十六进制的格式。 不要使用除了退格键之外的其它编辑键,因为在诸如环境变量中隐藏的字符是很难被发现的。 具体命令略 6. Linux内核的编译 6.1 下载Linux内核 可以通过git下载到最新的内核,命令如下: $ cd /opt/eldk/usr/src $ git clone git://www.denx.de/git/linux-2.6-denx.git linux-2.6-denx $ cd linux-2.6-denx 6.2 内核的配置及编译 下面的步骤需要powerpc的交叉开发工具,所以您必须确认您的PATH变量中有ELDK的编译工具的地址。 首先使用下面的命令清除以前的编译信息: $ make mrproper 使用下面的命令导入适合Makalu开发板的默认配置,这些配置是经过官方多次测试的: $ make ARCH=powerpc CROSS_COMPILE=ppc_4xx- makalu_defconfig //导入默认配置 注:这些默认的配置文件位于arch/powerpc/configs/XXX_defconfig ,根据您的开发板的型号选择。如果找不到相应的配置文件,可以去arch/ppc/configs/中寻找相应的配置文件,那里还有一些。makalu_defconfig这个就是位于arch/ppc/configs/下面,而arch/powerpc/configs/中没有。 当然您还可以自己去修改内核的配置,使用如下命令: $ make ARCH=powerpc CROSS_COMPILE=ppc_4xx- config //手动改动配置 或者 $ make ARCH=powerpc CROSS_COMPILE=ppc_4xx- menuconfig //手动改动配置 注:因为一些问题(尤其是老版本的内核),"make xconfig"这个命令不被推荐 使用下面的命令编译内核: $ make ARCH=powerpc CROSS_COMPILE=ppc_4xx- uImage //编译 编译生成的目标文件uImage是通过mkimage(位于U-Boot包中) 创建的,位于/opt/eldk/usr/src/linux-2.6-denx/arch/powerpc/boot/uImage,它可以通过U-Boot来下载和引导 配置和安装模块使用如下命令: $ make ARCH=powerpc CROSS_COMPILE=ppc_4xx- modules $ make ARCH=powerpc CROSS_COMPILE=ppc_4xx- INSTALL_MOD_PATH=/opt/eldk/ppc_4xx modules_install 6.3 安装 将文件复制到tftpboot目录下面,然后通过tftp烧写: $ cp arch/powerpc/boot/uImage /tftpboot/uImage 7. 根文件系统的设计与编译 7.1 根文件系统的设计 嵌入式开发中根文件系统的设计并不是很容易的事,主要的问题是: (1)里面要包括哪些内容 (2)使用哪种文件系统类型 (3)怎样存储和引导 现在假设根文件系统中的内容我们已经知道,那么我们主要关注后面两点。 我们使用ELDK安装时生成的镜像 SELF (Simple Embedded Linux Framework),它位于 /opt/eldk//images/ 文件夹下,ramdisk_image.gz这个文件便是。 (1)解压ramdisk: $ gzip -d -c -v /opt/eldk/ppc_4xx/images/ramdisk_image.gz >/tmp/ramdisk_image //解压 (2)挂载ramdisk $ mount -o loop /tmp/ramdisk_image /mnt/tmp //挂载 (3)创建压缩文件,为了避免下面步骤需要root权限,不包括设备文件 $ cd /mnt/tmp $ tar -zc –exclude=’dev/*’ -f /tmp/rootfs.tar.gz * //创建tarball,为了避免下面步骤需要root权限,不包括设备文件 (4)将设备文件创建成单独的压缩文件(使用cramfs) $ tar -zcf /tmp/devices.tar.gz dev/ //将设备文件创建成单独的tarball $ cd /tmp (5)取消挂载 $ umount /mnt/tmp //取消挂载 7.2 根文件系统的编译 我们使用ramdisk的形式来生成根文件系统的镜像文件,一般情况下,它使用ext2文件系统。 (1)创建目录,解压压缩文件 $ cd /opt/eldk/ $ mkdir rootfs $ cd rootfs $ tar zxf /tmp/rootfs.tar.gz (2)我们使用genext2fs来创建ramdisk镜像文件,因为它使用一个简单的文本文件来描述设备,因而不需要root权限。使用设备表rootfs_devices.tab: # /dev d 755 0 0 – - – - - /dev/console c 640 0 0 5 1 – - - /dev/fb0 c 640 0 0 29 0 – - - /dev/full c 640 0 0 1 7 – - - /dev/hda b 640 0 0 3 0 – - - /dev/hda b 640 0 0 3 1 1 1 16 /dev/kmem c 640 0 0 1 2 – - - /dev/mem c 640 0 0 1 1 – - - /dev/mtd c 640 0 0 90 0 0 2 16 /dev/mtdblock b 640 0 0 31 0 0 1 16 /dev/mtdr c 640 0 0 90 1 0 2 16 /dev/nftla b 640 0 0 93 0 – - - /dev/nftla b 640 0 0 93 1 1 1 8 /dev/nftlb b 640 0 0 93 16 – - - /dev/nftlb b 640 0 0 93 17 1 1 8 /dev/null c 640 0 0 1 3 – - - /dev/ptyp c 640 0 0 2 0 0 1 10 /dev/ptypa c 640 0 0 2 10 – - - /dev/ptypb c 640 0 0 2 11 – - - /dev/ptypc c 640 0 0 2 12 – - - /dev/ptypd c 640 0 0 2 13 – - - /dev/ptype c 640 0 0 2 14 – - - /dev/ptypf c 640 0 0 2 15 – - - /dev/ram b 640 0 0 1 0 0 1 2 /dev/ram b 640 0 0 1 1 – - - /dev/rtc c 640 0 0 10 135 – - - /dev/tty c 640 0 0 4 0 0 1 4 /dev/tty c 640 0 0 5 0 – - - /dev/ttyS c 640 0 0 4 64 0 1 8 /dev/ttyp c 640 0 0 3 0 0 1 10 /dev/ttypa c 640 0 0 3 10 – - - /dev/ttypb c 640 0 0 3 11 – - - /dev/ttypc c 640 0 0 3 12 – - - /dev/ttypd c 640 0 0 3 13 – - - /dev/ttype c 640 0 0 3 14 – - - /dev/ttypf c 640 0 0 3 15 – - - /dev/zero c 640 0 0 1 5 – - - 具体的格式描述请参见genext2fs的帮助文档。 (3)使用genext2fs来创建一个ext2文件系统的镜像: $ ROOTFS_DIR=/opt/eldk/rootfs # 路径 $ ROOTFS_SIZE=8192 # 文件系统镜像的大小,如果此值太小,当生成的文件超过此值时,会报错 $ ROOTFS_FREE=100 # free space wanted $ ROOTFS_INODES=380 # number of inodes $ ROOTFS_DEVICES=rootfs_devices.tab # device description file $ ROOTFS_IMAGE=ramdisk.img # generated file system image $ genext2fs -U \ -d ${ROOTFS_DIR} \ -D ${ROOTFS_DEVICES} \ -b ${ROOTFS_SIZE} \ -r ${ROOTFS_FREE} \ -i ${ROOTFS_INODES} \ ${ROOTFS_IMAGE} (4)压缩文件 $ gzip -v9 ramdisk.img (5)创建适合U-Boot的images: $ mkimage -T ramdisk -C gzip -n ‘Test Ramdisk Image’ \> -d ramdisk.img.gz uRamdisk 至此,ELDK的开发环境便基本搭建完成。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值