什么是好代码?
我个人认为好代码有很多种评判标准,我们经常提起的就是一下三类:
- 使用更好的算法优化代码执行效率。
- 代码的可读性非常强。
- 代码的可拓展性非常强。
实际上对于用算法来优化代码,第一种情况很少见,一般来说,我们平时的业务代码基本上碰不上算法,即便需要算法(比如加密啥的),也仅仅是调用现有的库来进行操作,所以我认为 保证代码的可读性和可拓展性
就是写出好代码的关键所在。
如何增强可读性?
提高代码的可读性,应该从以下几点进行操作:
- 代码的命名规范。
- 避免if-else的嵌套。
命名规范
关于代码的命令规范的问题,每个语言有它对应的风格,当然你自己也可形成自己的风格,常见的命名格式有:1.Camel-case(驼峰命名法)2.Pascal-case(大驼峰)3.Snack-case(蛇形)。比如Java一般都用驼峰,C#用大驼峰,python用蛇形等等...
有些语言需要在变量命名上予以特殊取分,比如Go语言语法上就对命名规范进行了要求,大写开头的命名方式是public属性,小写开头为private属性,Dart语言则是以下划线进行区分。在C++中我们则一般将class的成员命名以m开头表示member,struct成员则需要以下划线结尾。
以上都是对于各类语言的不同和自身习惯的不同进行的命名规范调整,而有些命名规范是我们大家基本的共识,1.作用域大的变量命名尽量详细。2.作用域小的变量命名尽量简单。 一个典型的例子如下:
int printf( const char* format, ... ); //printf的函数签名,参数语义肯定要清晰
for(int i=0;i<10;i++){
//这里for循环内的变量作用范围小,且意义也本就清晰,不需要变量名强行解释,比如命名为index就行不好的做法
...
}
复制代码
避免嵌套
避免if-else嵌套的最简单直接的方式就是通过return过滤条件,把内层if提取出来。 比如下面是截取的一段LRU算法中的put方法实现。
void put(int key, int value)
{
auto node = _get(key);
if (node)
{ //key已经存在,注意:还需要更新到最新的优先级
node->value_ = value;
_update(node);
return;
}
if (m_cache.size() >= m_capacity)
{ //cache已经满了
_remove();
_insert(key, value);
return;
}
//cache未满
_insert(key, value);
}
复制代码
如果不尽早分情况return,可能会出现下面这样的代码:
void put(int key, int value)
{
auto node = _get(key);
if (node==nullptr)
{ //key不存在
if (m_cache.size() >= m_capacity)
{ //cache已经满了
_remove();
_insert(key, value);
}else{//cache未满
_insert(key, value);
}
}else{
//key存在
node->value_ = value;
_update(node);
}
}
复制代码
哪个可读性更好,不言而喻。。没错这题力扣的官方题解就是写的第二种代码,有兴趣的可以看看这个链接
把里层的if提取出来不香嘛?
如何增强可拓展性?
可扩展性,重点就放在代码复用。而代码复用的关键方式一般有下面几种:
- 将逻辑分步,并创建为对应的函数。
- 利用好面向对象,采用良好的设计模式。
关于第二点设计模式,这里想一下子讲清楚是比较困难的,而且平时用到设计模式的场景也很少,所以着重讲解第一点。
此处讲解好第一点,逻辑分步,分解为对应函数(或方法)。
以LRU缓存实现为例
代码如下:
struct Node
{
int key_{};
int value_{};
Node *next{};
Node *prev{};
};
class LRUCache
{
public:
LRUCache(int capacity)
{
m_capacity = capacity;
m_head.next = &m_rear;
m_rear.prev = &m_head;
}
~LRUCache(){
_destroy();
}
int get(int key)
{
auto p = _get(key);
if (p == nullptr) //不存在
return -1;
_update(p); //更新优先级
return p->value_;
}
void put(int key, int value)
{
auto p = _get(key);
if (p)
{ //key已经存在,注意:还需要更新到最新的优先级
p->value_ = value;
_update(p);
return;
}
if (m_cache.size() >= m_capacity)
{ //cache已经满了
_remove();
_insert(key, value);
return;
}
//cache未满
_insert(key, value);
}
private:
//得到key对应的cache映射
Node *_get(int key)
{
auto it = m_cache.find(key);
if (it == m_cache.end())
{
return nullptr;
}
return it->second;
}
//分离节点
void _separate(Node* node){
node->prev->next = node->next;
node->next->prev = node->prev;
}
//插入到头
void _insert_head(Node* node){
node->next = m_head.next;
node->prev = &m_head;
m_head.next->prev = node;
m_head.next = node;
}
//删除尾部节点(最近最少使用
void _remove()
{
auto* p = m_rear.prev;
if(p != &m_head){
m_cache.erase(p->key_); //删除映射关系
_separate(p);
delete p;
}
}
//更新优先级
void _update(Node *node)
{
auto *prev = node->prev;
if (prev == &m_head) //排除已经是优先级最高的情况
return;
_separate(node);
_insert_head(node);
}
//插入操作
void _insert(int key, int value)
{
auto *node = new Node;
node->key_ = key;
node->value_ = value;
_insert_head(node); //更新到最近使用的优先级
m_cache[key] = node; //更新映射关系
}
//内存释放
void _destroy(){
auto* cur = m_head.next;
while(cur!=&m_rear){
auto* next = cur->next;
delete cur;
cur = next;
}
}
private:
int m_capacity{};
Node m_head{};
Node m_rear{};
unordered_map<int, Node *> m_cache;
};
复制代码
在分析之前,我们先简要的分析LRU算法的实现,LRU算法是一种用于缓存淘汰的算法机制,翻译为最近最少使用,顾名思义,也就是最少使用的缓存将会被淘汰。(缓存淘汰也就是当缓存已经满了的时候,需要选择其中一个缓存淘汰,然后置换一个新的缓存进去)
我们怎么体现出一个缓存不断更新的过程呢?怎么凸显出一个缓存最近最不常用呢?我们会想到,可能需要设置一个优先级,一旦缓存被使用优先级就提高,然后其他的缓存优先级都会降低,一旦缓存满了就淘汰最低的优先级就行了。
正好,有个数据结构非常适合这样的机制——队列,当我们使用某个缓存的时候,把它放到队首即可,表示最高优先级,在队尾的永远是最低优先级的。但这样又出现一个问题——没法把中间的元素选中并插入到队首,怎么办呢?
最终的解决方案是,我们通过双向链表自建一个双端队列,这样我们就可以对中间的任意节点进行操作,为了快速查找到对应的节点,我们通过哈希表进行映射,得到节点的地址。
结构如下:
关于双向链表的实现细节,我们如图加入一个空的头节点和一个空的尾节点是最好的,这样在取下节点进行淘汰的过程中就无需考虑节点是否为空的特殊情况!
现在我们正式考虑代码的重用
- 在这个算法的实现过程中,双向链表插入头部的操作和分离节点的操作(由于淘汰节点的位置是随机的,故只有分离操作可重用)是需要大量重用的。故上述代码实现了
_insert_head()
和_separate()
方法。 - 算法实现过程中,肯定需要对node节点的快速定位,也就是通过key查找到对应指针,故封装
_get()
方法。
实际上真正需要重用的代码也就只有这两个操作,其余函数的建立更多的是为了可读性。
我们正确书写这段算法的思路应该是,比如为了实现put操作(往缓存中放入数据)。
- 数据的key是否已经存在
-->
存在则将value更改后,并更新这个节点到头部,对应写出_update()
方法的调用,然后返回。 - 由于前面已经返回,则接下来的代码key一定不存在,故继续判断缓存容量是否已满
-->
如果满了则删除链表尾部节点,对应写出_remove()
方法的调用,然后再插入当前数据到头部,对应写出_insert()
方法调用,然后返回。 - 除去前面的所有情况,剩下的就是key不存在且未满的情况
-->
只需插入数据到头部,对应写出_insert()
方法的调用。
代码如下:
void put(int key, int value)
{
auto p = _get(key);
if (p)
{ //key已经存在,注意:还需要更新到最新的优先级
p->value_ = value;
_update(p);
return;
}
if (m_cache.size() >= m_capacity)
{ //cache已经满了
_remove();
_insert(key, value);
return;
}
//cache未满
_insert(key, value);
}
复制代码
分析完整个实现过程,我们发现在保证了可读性的同时,实现了 _insert()
操作的重用!
这样写代码,思路清晰,且又保证了代码的规范。
以后端接口实现为例
之前做过一个go语言的抖音项目,里面就利用好了逻辑分步为对应函数的方式来让拓展性和可读性并存。
如以下 service
层的代码:
完整代码链接 query_user_login.go
// QueryUserLogin 查询用户是否存在,并返回token和id
func QueryUserLogin(username, password string) (*LoginResponse, error) {
return NewQueryUserLoginFlow(username, password).Do()
}
func NewQueryUserLoginFlow(username, password string) *QueryUserLoginFlow {
return &QueryUserLoginFlow{username: username, password: password}
}
type QueryUserLoginFlow struct {
username string
password string
data *LoginResponse
userid int64
token string
}
func (q *QueryUserLoginFlow) Do() (*LoginResponse, error) {
//对参数进行合法性验证
if err := q.checkNum(); err != nil {
return nil, err
}
//准备好数据
if err := q.prepareData(); err != nil {
return nil, err
}
//打包最终数据
if err := q.packData(); err != nil {
return nil, err
}
return q.data, nil
}
复制代码
很明显,我这里通过 Do
方法,将整套该层的逻辑给分开了,分别用 checkNum()
等方法进行具体逻辑的实现。
这样一套流程下来,思路不会断层,而且保证了代码的可读性与拓展性!