删除链表的特点节点,在传统的教科书中是这样写的:
node * remove_if(node * head, remove_fn rm) {
for (node * prev = NULL, * curr = head; curr != NULL; ) {
node * const next = curr->next;
if (rm(curr)) {
if (prev)
prev->next = next;
else
head = next;
free(curr);
} else {
prev = curr;
}
curr = next;
}
return head;
}
对上面的代码进行解读,可以翻译成这样:
for(遍历链表) {
if(判断是否要删除) {
if(是否头节点) {
头结点删除处理
} else{
中间结点删除处理
}
} else {
当节点要删除时,不更新prev值
}
更新遍历
}
虽然代码量不大,但是由于删除的流程存在不同的情况,如遍历下一个节点时是否做过删除操作、删除的节点是否头节点这些情况,因此不得不编写相关代码处理这些边界情况,导致代码的逻辑增加一定的复杂度。
当业务中的数据模型不一致的时候,自然需要额外的代码去处理这些不一致的情况,代码自然也会变得复杂,这是一种因果关系。
这些复杂度是这个模型的最小复杂度,因此,如果不从源头(模型)入手,妄想通过照搬什么设计模式,封装啥的技巧,来让代码简洁,无不异于掩耳盗铃,自欺欺人。
解决问题的前提,必然是学会怎么去面对问题。
现在看下linus版的代码对此的优化,虽然使用了指针的指针这一高超技巧,但是去细品,会发现指针的指针只是一个引子,而这个引子真正引发的局势变化才是真正的魅力,代码如下:
void remove_if(node ** head, remove_fn rm) {
for (node** curr = head; *curr; ) {
node * entry = *curr;
if (rm(entry)) {
*curr = entry->next;
free(entry);
} else {
curr = &entry->next;
}
}
}
指针,究其本根其实就是一个64(32)位二进制的内存地址,就像卫星标示你当前所处的坐标,而指针的指针则是卫星的坐标。
在了解了上述的技巧之后,对linus的代码就可以这么翻译:
for(遍历链表) {
if(判断是否要删除) {
修改对应的内存地址
} else {
更新遍历
}
}
why are you so 简单?
指针的指针这一技巧的引入,将原先存在不同情况的业务模型,转变成了只有一种情况的模型。这时代码只需要处理一种情况,这样代码自然也变得简单了。(需要啥设计模式)
这是怎么实现的呢?主要是因为指针的指针将头结点和中间节点都抽象成一个存放节点指针的内存地址。因为不管是头结点还是中间结点,必然都有一个内存地址存放具体结点的指针。(存放头结点指针的内存地址就是函数中变量地址,java的话就是栈变量)。这样当所有的结点都归一成一种模型的时候,我们所需要做的事情就是处理这种模型就行了,化繁为简,化腐朽为神奇。
linus通过修改问题,达到简化模型的目的,那有其他方式来达到这一目的呢?比如增,删?
例子
下面是对原始版的删除节点的java代码转换:
public static Node del(Node head, int n) {
Node prev = null;
Node curr = head;
while (curr != null) {
Node next = curr.next;
if(curr.val == n) {
if(prev != null) {
prev.next = next;
} else {
head = next;
}
} else {
prev = curr;
}
curr = next;
}
return head;
}
还是一样的复杂,通过换语言并不能改变你所要面对的问题的复杂性。
增:增加辅助线
小时候,当解答不规则的几何题时,在常规的解题思路不适用时,通常可以通过增加辅助线,将一个非常规几何形状变成我们熟知的形状后,进行求解,然后减掉增加的部分,就可以得到答案。
那么代码要怎么增加辅助线呢?
这里就可以先想象一下,在不考虑各种乱七八糟的边界情况下,比如现在遍历到链表的中间,发现要删除的节点,并把它删除要怎么写?
if (cur.next.val == n) {
cur.next = cur.next.next;
}
那再加上循环遍历,就有了这样的代码:
Node cur = head;
while (cur != null && cur.next != null) {
if (cur.next.val == n) {
cur.next = cur.next.next;
continue;
}
cur = cur.next;
}
这样删除中间节点的代码就完成了。但是,这样的代码无法处理头结点问题?
那我们就可以通过增加辅助线,将头节点变成中间节点,如下:
Node hh = new Node(-1);
hh.next = head;
Node cur = hh;
while (cur != null && cur.next != null) {
if (cur.next.val == n) {
cur.next = cur.next.next;
continue;
}
cur = cur.next;
}
return hh.next;
增加一个辅助头节点,并添加到头部位置,原先的头节点就成了中间节点,最后删除辅助线,不就可以得到我们的答案吗?
删:分而治之
同样以不规则几何题一样,除了画辅助线,我们还可以将非常规几何形状进行切分成几个规则的几何形状,然后对这几个几何形状进行求解就行了。
代码怎么去进行切割呢?代码的切分思维不是去对代码动手,而是从代码要处理的业务流程进行处理,将一个有不同特殊边界的业务模型,切分成特殊边界代码模块和常规流程代码模块即可。
而这里代码就可以分成两个场景出来,一个是处理删除头结点问题,一个是处理删除中间节点问题。
那代码就可以这样写,先处理头结点问题,再处理中间节点问题。
public static Node del(Node head, int n) {
if(head.val == n) {
head = head.next;
}
Node cur = head;
while (cur != null && cur.next != null) {
if(cur.next.val == n) {
cur.next = cur.next.next;
continue;
}
cur = cur.next;
}
return head;
}
这样处理过后的代码一样显得简单。
感慨
其实抽象的过程就是建模的过程,建模的目的就是建立一个符合实际业务场景的模型。一个好的模型不仅能让开发更容易开发和维护,同时也可以让后续的需求能以最高的工作效率实现。
但是在实际的项目开发的过程中,往往忽略建模的过程(可能没有这个概念),即使有进行相关讨论,那也是开发在了解了产品需求之后,开发们自己进行讨论,产品在这里介入是很少的,这样的行为,在产品相对简单的时候,是可行的。但在产品具有相对复杂的情况下,开发所了解到业务背景有时可能是错误,后续的开发自然会存在很多的问题和“理应该实现但实现不了”的严重问题出现。
这也是为什么在ddd的那本书中,作者在前面一部分,强调了业务专家和开发人员之间的沟通,甚至为沟通提出相关指导意见出来。
反观现在的开发氛围,开发们都有一种忽视业务,空谈技术的心态,甚至出现为了方便技术实现而扭曲产品需求的存在。孰不知程序员的价值并不是你技术知识有多牛逼,也不是你996的勇猛,而是你所实现的产品价值的多少。
说到模型,有一个很奇特的现象,开发虽说会按照一个模型去写代码,但是他们眼中的模型,却是产品画出来的实打实的需求模型,真的是产品怎么说就怎么做,不会对需求进行整理,妄想把一个不懂技术产品当成“架构专家”。
这也难怪产品跟开发的争吵总是少不了,产品跟开发本是互利共生的关系,现实中却是互怼的关系,真是魔幻的现实。
同时现在的项目开发都是以快速迭代的方式进行的,在这样的模式下,产品的雏形往往都是很简单的,而这也让开发们不注重建模,随便搭几张表就直接撸代码,假设在前期的设计本身就出现跟需求背后的事实逻辑存在差异的话,在后续的产品迭代过程中,实际产品就会与预想的产品一步步脱轨,直接成为一个失败的项目。
事无巨细,认真对待,保持敬畏,即使需求再简单,也应当要建立与其相关的模型,从一个实实在在的模型去开发,将一切开发中会遇到的问题和难点,在开发前期进行暴露并解决,这才是一个专业的业务程序员的工作方式。
如果要说成为专家有哪些必备素质,那应该是一颗敬畏之心。
君子终日乾乾,夕惕若厉,无咎。可惜的是,咱们能很好地做到“终日乾乾”,但又有多少人能做到“夕惕若厉”?
题外:悲观式代码修补
在开发过程中,发现有一种挺有意思的修bug的思路。
比如上述删除节点的代码中,在“删:分而治之”的版本中,如果在删除头结点的代码出现问题,导致没有删除,而走下面删除中间节点代码的情况下,要怎么去修复代码呢?
悲观式修bug的方式,会在删除中间节点模块代码之后,在补上重新删除头结点的代码。。。这也难怪为什么代码会越来越复杂的原因吧。