清晰代码:如何编写易于阅读的代码 - Ryan

22-12-21 banq

我们将讨论为什么编写更多可读的代码,而不是简明(短)的代码。之后,以下是关于如何做到这一点的策略:
  • 变量、类和函数的命名
  • 辅助函数
  • 代码注释
  • 枚举/字典/密封类/等等。
  • 包的组织和命名


效率来自于更少的代码吗?
我记得作为一个初级开发人员,我认为简短或缩写名称更有效率。

我的逻辑很简单。如果我花更少的时间来写它,那么我就能更快地完成工作。

如果以下情况属实,这种逻辑就有意义:

  • 我或其他人永远不必阅读或修改我过去写的东西
  • 我在阅读一个函数时,不会经常忘记一个或几个变量是什么。
  • 我不会偶尔不得不写一些真正复杂和晦涩的代码
  • 我可以把可笑的或晦涩的外部库函数、类或属性重新命名为更合理的东西。

重点是,对我来说,我发现很少有简明的情况下能真正节省时间。此外,现代集成开发环境有这种有用的功能,叫做代码补全,无论如何都能节省大部分的键盘敲击。

现在我将分享我为使自己和他人更容易阅读我的代码所做的事情。我使用的代码例子将是Kotlin,但我提出的观点应该适用于大多数平台和语言。

如何命名类、变量和函数
在学习如何为软件实体命名时,我看了两件重要的事情要知道。这个术语,软件实体,指的是以下任何一种。

  • 类、结构、对象
  • 变量、值、引用、指针
  • 函数、方法、算法、命令
  • 接口、协议、抽象

基本上,任何由程序员决定如何命名的东西。

名字应该有多大的描述性?
我为软件实体命名的目标是这样的。命名应该减少对软件实体所做的事情或者是什么的任何混淆。

关于它如何做某事的细节,通常没有必要。

一个软件实体的上下文(周围的一切),特别是在函数和变量(等)的层面上,是很重要的。某些东西可能需要更多或更少的细节,这取决于它的上下文。

让我们考虑三个例子:
  1. getFormattedDate(date: String) : String
  2. getYYYYMMDDFormattedDate(date: String) : String
  3. getYYYYMMDDFormattedDateFromIso8601Format(date: String) : String

我会使用类似第三选项,这是因为选项1对于我们的项目要求来说是非常模糊的。

另一个选择可能是将选项2中的参数名称改为类似iso8601Date。

关键是要尽可能多地添加必要的信息,以消除任何歧义。

如果我在写一个一次性的程序,只将一种格式转换为另一种格式,那么选项1就可以了。
我在这里提倡的不是添加更多必要的信息。

做得越多,就越难命名
如果你发现自己在命名某物时遇到困难,这往往是因为它做了太多在概念上不相关的事情(虽然不一定)。

软件实体在概念上的关联程度被称为内聚力。

软件实体相关或不相关的程度,应该让你知道它们应该如何分组或分离。

这个过程可以从不同的角度进行,我将尝试用例子来解释。

假设有四个软件实体。

  1. StoreUserInCloud
  2. StoreUserOnDisk
  3. StoreMessage
  4. EditUserUI


我们可以考虑的第一个角度是这些实体所关注的现实世界的信息。从这个角度来看,我们可以看到StoreUserInCloud、StoreUserOnDisk和EditUserUI使用相同的信息模型:一个User。

然而,还有一个角度我们必须牢记,特别是在设计图形用户界面(GUI)程序时。每一个GUI程序都可以分解成三个主要层(MVC)。
  • 用户界面(通常称为 "View视图")。
  • 逻辑(通常指的是控制器和演示器等东西)
  • 模型(数据存储和访问,或状态本身,取决于你的定义)


如何使用辅助Helper函数
帮助Helper函数,特别是与良好的函数命名方法相结合,可以大大改善代码的可读性。辅助函数也是一个应用软件架构核心原则的机会。分离关注点。

如何创建数独谜题
我们现在将看一个实际的例子来证明辅助函数的广泛使用。请试着想象一下,如果所有的东西都在一个巨大的函数中,那么这段代码将是多么的难懂啊!

我们可以将生成一个可玩的数独谜题拼图的过程分成五个步骤。

  • 创建拼图的节点(代表格子tiles)
  • 创建拼图的边(边在这里是指格子tiles之间的关系。要么是行,要么是列,要么是子网格)
  • 在结构中加入一些数值,以使解题速度加快
  • 解开谜题拼图
  • 解除一定数量的格子tiles,以便用户能够真正玩这个游戏


source code here.

我使用了类似于Builder模式的东西,在我调用的函数中表示这些步骤,以创建拼图:

internal fun buildNewSudoku(
    boundary: Int,
    difficulty: Difficulty
): SudokuPuzzle = buildNodes(boundary, difficulty)
        .buildEdges()
        .seedColors()
        .solve()
        .unsolve()



尽管 "节点Nodes "和 "边Edges "的概念是图论中的技术定义,但这段代码清楚地反映了我所决定的五个步骤。

我们将不看整个代码库,但我想强调辅助helper函数是如何继续分解逻辑和促进可读性的。

internal fun SudokuPuzzle.buildEdges(): SudokuPuzzle {
    this.graph.forEach {
        val x = it.value.first.x
        val y = it.value.first.y

        it.value.mergeWithoutRepeats(
                getNodesByColumn(this.graph, x)
        )

        it.value.mergeWithoutRepeats(
                getNodesByRow(this.graph, y)
        )

        it.value.mergeWithoutRepeats(
                getNodesBySubgrid(this.graph, x, y, boundary)
        )

    }
    return this
}

internal fun LinkedList<SudokuNode>.mergeWithoutRepeats(new: List<SudokuNode>) {
    val hashes: MutableList<Int> = this.map { it.hashCode() }.toMutableList()
    new.forEach {
        if (!hashes.contains(it.hashCode())) {
            this.add(it)
            hashes.add(it.hashCode())
        }
    }
}

internal fun getNodesByColumn(graph: LinkedHashMap<Int,
        LinkedList<SudokuNode>>, x: Int): List<SudokuNode> {
    val edgeList = mutableListOf<SudokuNode>()
    graph.values.filter {
        it.first.x == x
    }.forEach {
        edgeList.add(it.first)
    }
    return edgeList
}
//...


总结一下这个过程,辅助helper函数提供了两个好处。
  • 它们是一组代码的替身,可以做一些事情
  • 可以给这部分代码起一个描述性的名字

要决定某件事情是应该保留在一个函数中还是委托给一个辅助函数,有必要进行一定量的试验和错误。

如何使用代码注释
我个人对代码注释的偏好是,它们有两个主要用途。首先是当我要写一个复杂的函数时,我就会这样做。

第二种用法更简单:解决对某一行或某一代码块的任何困惑。

如何使用注释来设计新函数
当我遇到一些我认为难以编写的函数时,我会用普通语言或伪代码来描述该函数的作用。

多年来,我的做法已经发生了变化,我鼓励你找到适合你的方法。

在上一节的例子中,我实际上省略了代码注释。

/**
 * 1. Generate a Map which contains n*n nodes.
 * 2. for each adjacent node (as per rules of sudoku), add an Edge to the hashset
 *  - By column
 *  - By row
 *  - By n sized subgrid
 *
 *  LinkedHashMap: I chose to use a LinkedHashMap because it preserves the ordering of
 *  the elements placed within the Map, but also allows lookups by hash code, which are
 *  generated by x and y values.
 *
 *  As for the LinkedList in each bucket (element) of the map, assume that the first element
 *  is the node at hashCode(x, y), and subsequent elements are edges of that element.
 *  Apart from the ordering the first element as the Head of the LinkedList, the rest of
 *  the elements need not be ordering in any particular fashion.
 *
 *
 *  */
internal fun buildNodes(n: Int, difficulty: Difficulty): SudokuPuzzle {
    val newMap = LinkedHashMap<Int, LinkedList<SudokuNode>>()

    (1..n).forEach { xIndex ->
        (1..n).forEach { yIndex ->
            val newNode = SudokuNode(
                    xIndex,
                    yIndex,
                    0
            )

            val newList = LinkedList<SudokuNode>()
            newList.add(newNode)
            newMap.put(
                    newNode.hashCode(),
                    newList
            )
        }
    }
    return SudokuPuzzle(n, difficulty, newMap)
}


我在这些评论中添加了多少细节,取决于上下文。如果我在一个团队中工作,我会尽量保持比你在上面看到的短得多;只有我觉得必要的内容。

上面的例子是一个个人学习项目,我期望与他人分享;这就是为什么我甚至包括了我对用来表示数独拼图的类型的决策过程。

对于测试驱动开发的爱好者来说,你可以尝试在写测试之前写出算法的伪代码步骤。

/**
     * On bind process, called by view in onCreate. Check current user state, write that result to
     * vModel, show loading graphic, perform some initialization
     *
     * a. User is Anonymous
     * b. User is Registered
     *
     * a:
     * 1. Display Loading View
     * 2. Check for a logged in user from auth: null
     * 3. write null to vModel user state
     * 4. call On start process
     */
    @Test
    fun `On bind User anonymous`() = runBlocking {

        //...
    } 


这允许你在编写实现之前在更高的抽象层次上设计单元。从长远来看,你花在更高层次的抽象设计上的时间可以为你节省时间。

如何有效地使用内联代码注释
在两种主要情况下,我会写一个内联代码注释。

当我觉得某一行或某一个代码块的目的对我自己或其他读它的人来说不明确时
当我不得不调用一些我无法控制的随机的、名字不好的库函数时。
到目前为止,我程序中最复杂的数独算法是解算器算法。事实上,它是如此之长,以至于我在这里只贴出它的一个片段。

internal fun SudokuPuzzle.solve()
        : SudokuPuzzle {
    //已经被分配的节点(不包括从seedColors()播种的节点
    val assignments = LinkedList<SudokuNode>()

    //跟踪失败的赋值尝试,以观察无限循环的情况
    var assignmentAttempts = 0
    //两个阶段的回溯,部分是一半的数据集,完全是一个完整的重启
    var partialBacktrack = false

    var fullbacktrackCounter = 0

    //从0-边界,代表算法对分配新值的 "挑剔 "程度
    var niceValue:Int = (boundary / 2)

    //为了避免过早地变得太好
    var niceCounter = 0

    //用一个副本来工作
    var newGraph = LinkedHashMap(this.graph)
    //所有数值为0的节点(未着色的)。
    val uncoloredNodes = LinkedList<SudokuNode>()
    newGraph.values.filter { it.first.color == 0 }.forEach { uncoloredNodes.add(it.first) }

    while (uncoloredNodes.size > 0) {
    //...
    }
//...
}


这是必要的,因为我在阅读这个巨大的算法时,经常会忘记这些变量是什么。

另一种情况是,当我需要解释或提醒自己我无法控制的代码时,我会添加一个内联评论。

例如,臭名昭著的Java日历API对月份使用了基于0的索引。这可以说是非常愚蠢的,因为我不知道有什么标准(不,我不关心是否存在)可以用0来表示一月。

如何使用枚举和字典
这类代码结构还有其他名称,但这是我所熟悉的两种。假设你有一组受限制的(有限的)值,你用它来表示某些东西。

例如,我需要一种方法来限制一个新的数独游戏中包含的拼图数量,基于。

拼图的大小(每列/行/子网格4,9,或16块格子)。
拼图的难度(易、中、难)。
通过广泛的测试,我得出了以下数值作为修饰语。

enum class Difficulty(val modifier:Double) {
    EASY(0.50),
    MEDIUM(0.44),
    HARD(0.38)
}

data class SudokuPuzzle(
        val boundary: Int,
        val difficulty: Difficulty,
        val graph: LinkedHashMap<Int, LinkedList<SudokuNode>>
        = buildNewSudoku(boundary, difficulty).graph,
        var elapsedTime: Long = 0L
)//...


这些值被用于各种必须根据难度改变逻辑的地方。

有时,你甚至不需要将人类可读的名字与数值联系起来。我使用了一个不同的枚举来代表不同的解题策略,以确保一个谜题相对于所选择的难度是可玩的。

enum class SolvingStrategy {
    BASIC,
    ADVANCED,
    UNSOLVABLE
}

internal fun determineDifficulty(
    puzzle: SudokuPuzzle
): SolvingStrategy {
    val basicSolve = isBasic(
        puzzle
    )
    val advancedSolve = isAdvanced(
        puzzle
    )

    //if puzzle is no longer solvable, we return the current strategy
    if (basicSolve) return SolvingStrategy.BASIC
    else if (advancedSolve) return SolvingStrategy.ADVANCED
    else {
        puzzle.print()
        return SolvingStrategy.UNSOLVABLE
    }
}


设计任何系统的一个好原则是这样的。活动部件越少,可能出错的地方就越少。

对值和类型进行限制,并给它们起一个好的名字,不仅使你的代码更容易阅读,而且还能保护它不出错。

如何组织和命名包、文件夹和目录
没有关于包的讨论,任何关于代码可读性的指南都是不完整的。如果你所选择的平台和语言没有使用这个术语,请假设我是指文件夹或目录。

这一点我已经多次改变了我的观点,这在我的老项目中也有所反映。

两种常见的包组织方式是。

  • 按架构层打包
  • 按功能打包


如何进行分层打包
按层打包是我使用过的第一个也是最糟糕的系统。这个想法通常是为了遵循一些架构模式,如MVC、MVP、MVVM等等。

以MVC为例,你的顶层包结构会是这样的。

  • 模型
  • 视图
  • 控制器

这种方法的第一个问题是,它假设每一个类或功能都适合于其中的一个层次。在实践中,这种情况很少发生。

我还发现这种方法的可读性最差,因为顶层只告诉你关于每个包内的最一般的细节。

这种方法通常可以通过增加更多的 "层 "来加以改进,使之更加具体。
  • ui
  • model
  • api
  • repository
  • domain
  • common


这在较小的代码库中效果相当好,所有的开发人员都熟悉所使用的一般模式和风格。

如何做到按功能打包
按功能打包有其自身的缺陷,但通常更容易阅读和浏览。这又是假设你给这些包起了好名字。

功能这个词很难描述,但我一般会把它定义为。一个屏幕/页面,或一组屏幕/页面,为用户或客户定义一个主要功能。

对于一个社交媒体应用程序,我们可能会看到这样一个结构。

  • timeline
  • friends
  • userprofile
  • messages
  • messagedetail


按功能打包的核心问题与按层打包相反。几乎总是会有一些软件实体在多个功能中使用。

这个问题有两个解决方案。第一个是在每个功能中都有重复的代码。

信不信由你,在企业环境中,在特定的情况下,重复软件实体可能是非常有用的。

然而,我并不推荐将其作为一般规则。

如何做一个混合包结构
我通常向开发者推荐的解决方案是我喜欢称之为混合方法。它非常简单、灵活,而且应该涵盖你的大部分要求。

timeline
friends
messages
- allmessages
- conversation
- messagedetail
api
- timeline
- user
- message
uicomponents



总结
我对代码可读性和风格的大部分偏好来自于对不同方法的大量尝试。有时,这些方法是我看到别人使用的,而有些则是自然产生的。

如果能把自己放在一个不太熟悉代码的位置上,会更容易使代码读起来像读一本书明白无误。

1