职场新人兼新手程序员斗胆开了新坑「如何写出更好的程序」,所见所得都是来自实际写代码时自己的思考,且已脱敏。这一系列不包含任何复杂的技术,也不包含任何难懂的代码。只是将核心问题暴露出来,针对这些场景,如何写出可维护性更高、更简洁优雅的代码。
以 python
为例,本文的主要内容包括:如何使用配置文件,以及如何减少代码中的硬编码,引申到了代码的组织架构和可维护性上。
如何使用好配置文件
针对一个代码文件使用配置文件的情况
假设只有在 main.py
中需要读取配置文件,将配置文件的部分变量以传参的形式交给其他函数使用,这是最简单的场景。举个简单的例子,如果是生产环境,那么 env=debug
;如果是开发环境,那么 env=release
,当然这是从配置文件里读取得到的。考虑复杂一些的情况,如果是用户 DIY 使用,可能需要的变量并不在配置文件中。
对于这一场景,建议将配置文件写到 config.py
中,并且用一个类进行封装,变量就是类的成员。当需要根据生产或开发环境执行不同的代码时,只需要在类内进行判断即可。当用户需要增加其他变量时,由用户继承这一个类并添加自己的变量和方法就好。
1 | class Data: |
针对多个文件使用配置文件的情况
如果此时有几十个代码文件都需要读取配置文件,获取其中的变量并执行对应的代码,总不能每个文件都创建一个类对象并初始化吧。你说参数传递?如果函数的传参很困难又该怎么办呢?具体而言,当开发后端的时候,main.py
读取配置文件并得到了 env=debug
,此时打开了网页,点击一些按钮完成一些交互,则 web
端会通过 js
发起了一个 post
请求,告诉你需要执行某些代码,这个请求被 handler.py
拦截到。
此时存在一个问题:handler.py
中的 get
方法拦截到 web
端请求,并不是 main.py
直接将请求发送到 handler.py
。所以此时不能直接传递参数,handler.py
也并不知道 env=debug
,所以可能不知道执行哪些代码。再去重新实例化一个类?几十个代码文件都去实例化同一个类,未免浪费空间。
简单的参数可以加到 post
请求的 url
里,但是当参数高达十几个时,传参和接收参数这会很麻烦。何况配置文件就在那里,handler.py
直接获取会方便很多。这个时候建议将配置文件写到 config.py
中,但不是以类的形式,而是直接写入变量并赋值,如 ENV="DEBUG"
。当任何文件需要读取这一变量时,直接 import config; config.ENV
便可获取。有点类似 C
语言中的 #define
。
yaml 或者 json?
还有一些通过读取 yaml
,json
等配置文件来生成变量的,但是这会不可避免的增加代码中的硬编码,而且只能获取变量。根据变量去判断执行哪些方法需要单独实现,所以没有考虑使用。具体而言:
对于情景一中的代码,用类实现配置文件的话可以直接调用类内的
__setup()
方法。如果是yaml
文件,从文件加载到env1, env2
后,需要单独去写情景一例子中的__setup()
方法,不如封装到类内方便。对于情景二,如果几十个代码文件都去执行
import yaml; yaml.load()
来获取配置文件中的变量,这又会造成大量的文件IO
,没有意义。这也是我不考虑使用yaml,json
作为配置文件的原因。
减少代码的硬编码
在有了配置文件后,可以有效减少代码中的硬编码,增强代码的可维护性。比如创建了一个字典:
1 | data["name"] = ... |
但是此时后台的接口忽然发生了变化,children
这个名字忽然改成了 subfunc
,后台解析只认 data["subfunc"]
这个字段,上面的写法需要去所有代码文件里一个个的搜索 "children"
并替换为 "subfunc"
,显然是很累又不得不干的活。这个时候可以使用配置文件:
1 | config.py |
如果再遇到 children
名字改成了 subfunc
,只需要在 config.py
里修改 CHILDREN
的取值就可以了,只需要修改一次,比上面的实现优雅一些。
重灾区:函数返回值
另一个硬编码重灾区是函数的返回值,众所周知 python
函数是可以有多个返回值的,对于暂时不需要的返回值可以用下划线忽略掉。
1 | def func(): |
其实上面获取函数返回值的形式更像列表的切片:
1 | def func(): |
可以看到,如果要调用 func
函数,就必须牢记返回值的顺序,当代码文件很多时并不友好,也不优雅。当需要增加或减少返回值的数量时,切片访问函数返回值的形式也很难处理。比如当不需要返回 name
字段时,或者需要增加一个 param
参数,下标都需要修改。增加返回值时, 别说把这个返回值放到所有函数返回值的最后,这只是为了代码能运行起来做的妥协,没意思。以上情况对于调用 func
的函数而言都需要一个个手动修改,简直是一场灾难。
这个时候建议使用类对象或者字典,道理是一样的:
1 | def func(): |
这样,就在也不需要记住返回值的顺序,也不必担心函数增加或减少返回值,甚至不用关注返回值的顺序。都可以直接通过字典的 key
访问。你说 "name", "info"
这样的硬编码不好?可以用前面讲述的配置文件避免掉它呀。
C
这种语言并不支持函数返回多个变量,需要返回多个变量时都是使用结构体来完成,这种想法值得借鉴。对于 python
语言,字典也好,类对象也罢(对象的话就是通过成员访问),取决于具体的适用场景,但是都可以避免通过切片这样的硬编码方式去获取函数的返回值。
使用类规范函数返回值
对于一个函数,接受原生的数据 raw_data
完成解析,并返回各种信息数据:
1 | def func(raw_data): |
但是其他函数使用返回值时,info1到info6这些信息并不是全部都需要使用。有时候仅仅需要使用 info1
和 info4
,很烂的写法有两种:
1 | 1. |
上述写法,当 func
函数发生变化,如:增加其他返回值、删除无用的返回值时,对于代码维护而言都是一场灾难。千万不要假设需求不会变化,也不要假设针对接口编程时接口始终不变,永远不知道会面临什么新的鬼需求和变动。就算是针对接口编程,每个函数的返回值是什么,返回值的顺序都需要记住,是一种很累的事情。
除了上文讲述的使用字典或者类之外,还有一种其他方法:
1 | class Info: |
只需要创建一个对象,在 get_item
这个函数的参数中指定自己想要获取的参数和顺序即可。即使函数 func
的返回值发生了顺序、数量等方面的变化,也只需要修改一下 __idx
成员即可。
仿佛不如字典简单?确切来说,这种方法有自己的适用场景:当 A
函数获取 info.get_item
信息后需要进行 postA
的后处理,当 B
函数获取 info.get_item
信息后需要进行 postB
的后处理。这样,就可以把 postA
和 postB
放入到 class Info
中,将分散到各地的相同逻辑的代码整合到一起。至于 "info1"
和 "info2"
这种硬编码,也可以用前面讲的东西规避掉。
需要注意的是,这种实现是比较耗时的。如果这个方法到处被调用,会增加程序的执行时间。耗时这一点是通过 py-spy + speedscope
这两个工具发现的,推荐一下这两个工具,用来观察 python
代码中的性能瓶颈。
关于代码的组织架构
文件、文件夹都要做好各司其职,不要怕麻烦,写好 __init__.py
,不要把很多文件胡乱的扔到单个文件夹里随意的调用,甚至没有文件夹。时间长了或者当别人用的时候,真的很乱。这次任务我实现了经典的 MVC
模式。
model
就是数据解析,存储和维护一些数据结构,如果想要的数据不能直接获取,也可以在model
里增加一些获取数据的接口。建议将model
封装为一个类,在一个方法里读取文件,解析得到数据结构,并放到类成员中,方便接口调用获取数据,也避免重复读文件和数据传来传去带来的拷贝开销。交由一个对象去维护数据,由对象的接口去操作数据。而不是将数据读取放到全局变量,任由各个代码、各个函数随意操作。view
是数据的展示,以什么形式和结构展示给用户,显示界面、写出文件或命令行输出等形式;control
是交互的控制,用于捕捉用户请求,按照请求访问model
的接口并获得想要的数据,再调用view
接口反馈给用户。
当需要获取很多种类型的数据时,开发重点在 model
部分,因为 control
只是调用获取数据的接口,view
只是展示数据。当需要 A
类型的数据时,control
调用 model
的 getA()
方法即可,当需要 B
类型的数据时,调用 model
的 getB()
方法。
重点就是这两个方法去如何实现,如何设计高效的数据结构去维护数据,来减少数据的拷贝和优化获取数据的效率。总不能 getA()
的时候重新读文件,getB()
的时候再去读文件,对吧。这就需要在 model
部分下工夫,比如这次就用到了数据结构中经典的 dfs
+树的后根法快速解析了数据。leetcode 没白刷了属于是
关于代码维护
额外的,在开发 model
时也有其他的收获:写代码尽可能将各个模块独立封装,写出高内聚,低耦合的传说级代码。虽然当函数很多时会很看着有些乱,怎么到处是函数?但是也有重要的优点:代码和数据重用方便。比如要增加一个新功能,只需要写一点函数,其他函数也许已经实现了,我们直接调用就好,而且不易出错。
如果写一个大函数完成一个功能 A
,在写另外一个大函数完成功能 B
,这两个大函数操作的变量会有重叠,也会有一些重复的逻辑。当其中的逻辑过于复杂时,难免出错。十分建议将功能剥离开来。
这种低耦合+配置文件的形式也可以灵活的解决一些暂时不确定的场景。领导告诉你说:暂时有 A,B,C,D,E
这五种类型,需要分类处理,后面可能会有改动。你兴冲冲的把这些类型作为字典的 key
完成了分类处理。
某天领导又说,把 A,B,C
归类为类型 1,把 D,E
归类为类型 2,根据不同的类型创建不同的文件夹,但是后面可能还会变动。不到半小时,又收到通知说把 D
归为类型 1,A
的名字改为 Afunc
,删除类型 2,并增加 F,G,H
为类型 3。既要修改类型,又要映射关系,去大段的代码函数里修改这些内容真的很累的,也很容易出错。这个时候可以在配置文件里写一个映射函数,每次修改这个小函数并调用就可以了。
1 | def map(name): |
总结,不要假设需求是不变的,这样写出来的代码很烂;需求发生改变时,代码修改难度也很大。
- 它就应该是这样,不存在其他情况;
- 这种情况不会出现,就先不考虑了;
程序员最好杜绝以上想法,不然写代码一时爽,改代码火葬场。场景会发生变化,需求永远是在变化。异常情况做好处理,减少代码的硬编码,降低代码功能的耦合度,针对接口编程,学过的设计模式也都可以用起来。避免需求发生变化时大量的修改代码,尽可能通过增加新接口和新函数来适应新的需求。