Pulpcode

捕获,搅碎,拼接,吞咽

0%

我的代码能有多灵活?

本故事纯属虚构,如有雷同,纯属巧合

某天,我刚午睡醒来,准备给自己做一杯手冲咖啡,拿着手冲壶走向饮水机的时候,无意中看到两个开发对着屏幕在那里指指点点的,仿佛在说着什么,只见一个说的头头是道,一个听得津津有味,我当时觉得他们肯定在谈论什么有意思的事情,所以凑过去打算听听,结果他们谈论的事情令我醍醐灌顶,可以说让我对编程有了新的思路,所以这里我尝试将他们的对话总结出来,分享给你们。

哦对了,他俩的名字我不能告诉你们,本来我想用鲍勃(Bob)和爱丽丝(Alice)来代指他俩的,但是又觉得这个梗早已经被编程界给玩烂了。所以我打算给他俩起名叫Foo和Bar。

Bar:“其实我一直想尝试让自己的代码变得灵活起来,比如把写死的东西给改成配置,但是我并不能满足于此,因为我总觉得自己代码还不够灵活,你能在给我一些思路么?”

Foo:“哈,说道思路,我确实可以给你提供一些,不过我尽量不会给你讲一些具体的例子,因为授人与鱼不如授人于渔,所以我会给你提供一些编程思想上的东西,帮你开阔视野。”

Bar:“好的,我洗耳恭听。”

Foo:“首先我们先来思考下,什么是所谓的灵活性。不说那些课本上比较官方的词汇,你所理解的代码灵活是什么样的?”

Bar:“最起码不用我每次有个什么新需求,都要让我改来改去的上线。”

Foo:“但是有的需求你是不得不上线。”

Bar:“对,这我知道,但是对于有些需求,其实是有些重复的东西,提取出来,然后在调整的时候,修改下配置就行了。”

Foo:“恩,很好,你提到你可以把某些共性的东西,提取出来,所以我们先来看你在初学代码时候的第一个例子。让你从1打印到10,你最开始会写出这样的代码.”

1
2
3
4
5
6
7
8
9
10
print 1
print 2
print 3
print 4
print 5
print 6
print 7
print 8
print 9
print 10

Foo:不过这种代码看上去是有点蠢,自从你学会了for循环之后,你尝试将共性的东西,抽象成为了一个函数,然后用一个for循环来调用这个函数:

1
2
3
4
def p(i):
print i
for i in range(1, 11):
p(i)

Foo:“但是为什么能这样做呢?”

Bar:“因为这些操作有某种共性,然后可以抽象出来。”

Foo:“但有个问题,你没有将这些可抽象的部分最大化的利用,也就是说还是有重复,有浪费,有优化的空间。”

Bar:“什么意思?”

Foo:“我再来问你,一个函数有什么?”

Bar:“有输入和输出,而且有的函数还会带有副作用,比如修改某个全局变量,或者操作磁盘,操作内存,操作终端什么的。”

Foo:“对,所以在上面那个例子中,这些函数的输入和输出并没有被利用,所有的自增变量都是从循环产生的。”

1
2
3
4
5
6
def addOne(i):
return i + 1

m = 0
for i in range(10):
m = addOne(m)

Foo:“看这次,因为一个函数的输入是另一个函数的输出,所以你用for循环的方式把他们给套起来了。所以这个时候,你其实在这样调用函数:”

1
addOne(addOne(addOne(addOne(addOne(addOne(addOne(addOne(addOne(addOne(0))))))))))

Bar:“嗯嗯,看上去输入和输出像是锁扣,将这一个个函数给扣起来了。”

Foo:“对,因为在这个时候,我们把一个大问题,分成了n个相同的子问题,然后再想办法逐一解决,但是其实还是可以有重复利用的地方的。”

Bar:“这还能怎么优化?”

Foo:“我们在编程语言的课程中,大概学到过,系统是如何保证函数调用的?”

Bar:“栈?”

Foo:“对,但实际上上面这段代码,我们利用一个for循环,一次一次的调用函数,然而函数其实完全可以自身调用自身,那么它就可以以自驱动的方式来完成这些调用。”

Bar:“自己调用自己,这不就是递归么?”

Foo:“对,但其实‘自己调用自己’是一个很误导人的说法,让人感觉我们做了一个无限制的环,但实际上,我们只在描述一个函数的时候描述成‘自身调用’,但是它在栈中自我驱动的时候,就是不停的压栈然后再弹栈罢了。看这个例子的递归写法:”

1
2
3
4
5
def addToN(n):
if n == 0:
return 0
else:
return 1 + addToN(n-1)

Foo:“这样我们循环本身,都用递归调用给做了。”

Bar:“看上去这有点绕。”

Foo:“是有点绕,但是我说这些主要是表达两点,第一,我一直在尝试抽象共性,然后最大程度的利用它,然后让代码看上去越来越紧凑。”

Bar:“然后呢?”

Foo:“然后我想用那个循环改成递归的例子,来告诉你,要不断的打开自己的思维去思考问题。”

Bar:“什么意思?”

Foo:“那我问你,为啥那个循环可以被我改成递归?”

Bar:“因为一个调用的输入正好是一个调用的输出,我们可以用for循环驱动起来这些输入输出,也可以用函数自身调用的方式自驱动它们?”

Foo:“不,你没有说到本质”

Bar:“什么是本质?”

Foo:“最开始我们能够把一些类似的功能抽象成函数,是因为具有某种共性,但是当我们能够用输入和输出的方式来穿起这些方法的时候,其实我们在抽象出问题和子问题,也就是问题与问题的关系。”

Bar:“你的意思是,计算n个1的和可以拆分为1和 n-1个1的和这两个子问题?”

Foo:“对,而且相比与拆分为n个相同的子问题,然后用循环来设计解决步骤。我们在定义递归函数的时候,是把问题描述成更小的问题,关键这个问题与原问题在结构上等价,所以是在描述问题的时候,就把这个问题给解决了。”

Bar:“对啊,你这么一说是挺神奇的,抽象出更本质的模型,比折腾各种细节的优化更重要。”

Bar:“但我之前理解的建模好像都是定一个struct或者定义一个class啥的。”

Foo:“这个想法有点片面了,但是既然你说到class了,那你说说你是如何提高你class的重用性的。”

Bar:“我会把公共的那些部分,抽象成为基类,然后让其它类去继承这些基类,这样避免不必要的重复代码。”

Foo:“嗯嗯,但是事实如你所愿么?”

Bar:“并不是,你很难抽象出来好用的基类,要么就是那些你并不想要的属性和方法被平白无故的继承获得了,要么就是你各个子类之间还是会有重复的部分。”

Foo:“所以oop的书会告诉你,‘宁用组合,不用继承’。”

Bar:“对,我在《设计模式》上看到过这个说法,所以我在设计的时候,也是‘宁用组合,不用继承’,但是我一直不知道原因是什么。”

Foo:“原因就是静与动的区别。”

Bar:“怎么感觉你不是在讲编程,而是在讲太极啊!”

Foo:“哈哈,之所以说静,是因为这些上述的这些可重用都是在我们书写代码,定义程序的时候完成的,或者说编译前,这就是所谓的,但是如果想更灵活,我们常常需要在程序运行的时候,还能对程序做某种可操作性,这就是所谓的”。

Bar:“那如何进行的灵活性啊?”

Foo:“之前提到的组合就是动的,因为你在用继承时候,像是把父类的代码直接获得了,减少了代码的书写,提高了重用性。而在组合中,你根本不知道什么样类型会被添加进来,只要是它的子类,任何类型的类,都可以在运行的时候被动态的添加进来,这就非常灵活了。”

Bar:“这不就是面向对象(oop)的精髓么?”

Foo:“对啊,oop的书,不是经常说,多态才是oop的精髓么,那既然说到面向对象了,我在跟你扯扯《设计模式》的东西。”

Bar:“恩,我知道《设计模式》就是为了提高软件设计的灵活性,但是设计模式有好多啊,貌似有23种。”

Foo:“是的,但是如果按照创建型模式,结构型模式,行为型模式一做归类,那就没多少了,其实就可以按照使用方式归为三大类。”

Bar:“有点晕,没听懂是啥意思。”

Foo:“其实,设计模式总共分为三大类,创建型的设计模式,主要探讨如何如果灵活的获得一个对象,不同的模式只不过对场景做了细化,比如抽象工厂用来创建产品簇,单例模式用来维护唯一的一个全局对象。结构型模式主要探讨在系统交互的时候,使用接口,中间层的方式来降低系统耦合。这使得在交互的双方,都依赖于接口编程,不依赖于实现。而行为型模式,主要是可以在运行的时候,动态的替换接口的实现类,使得一个对象的行为,能够在运行的时候被改变,而不需要修改代码。”

Bar:“你这么一说,我的思路就清晰多了。”

Foo:“其实面向对象的本质,就是在系统中定义一些模型,然后让这些模型,相互发送消息。你的类定义的公有方法,其实在定义协议。方法的入参就是消息体,方法的返回结果就是通信结果。把系统拆散成一个个对象的消息交互,要比一个功能什么都做,灵活许多。就好比阻塞函数,其实是把io操作和进程本身绑定到一起了,你除了等待阻塞什么都不能干,但是非阻塞函数,就可以让io和进程用消息的方式进行通信,对二者进行解耦,提高灵活性。”

Bar:“嗯嗯,看来我之前并没有理解面向对象的本质。这么看来运行时的灵活性比编译时的灵活性更灵活。”

Foo:“并不是这样的,你一开始不是提到配置文件么,那我先给你举一个配置文件的例子来总结下运行时的灵活性。”

Bar:“我继续洗耳恭听。”

Foo:“你在一开始写代码的时候,会定义很多的魔数,所谓魔数,就是指你并不知道他是干什么的,而且还可能写的哪里都是。像是这样:”

1
2
if  i == 5:
xxx

Foo:“之后你尝试写一些const变量,来把他们收集到一起。”

1
2
const MAX_SIZE = 10
if i == MAX_SIZE

Foo:“不过看似更灵活的方式是把这些变量提成一个个配置,在程序启动的时候去加载它们,然后让你在修改的时候,不用重新编译。”

1
2
3
4
5
6
7
# 某config.config
MAX_SIZE 10


# 某处代码
load(“config.config")
define MAX_SIZE

Foo:“不过你可能更希望的是连编译都不编译,而是在运行的时候,通过被动消息通知,或者是主动定时更新的方式,更新程序的上下文。”

1
2
def update():
MAX_SIZE = fetch_config()

Foo:“但是你不拘泥于灵活的修改配置这么简单,你希望动态的调整逻辑,所以你的配置变成了一个个条件+结论的样子”

1
2
3
conditionA, conditionB, conditionC action1
conditionD, conditionE, conditionF action2
conditionG, conditionH, conditionI action3

Foo:“然后你的代码就在尝试匹配这些条件,然后做相应的动作。”

Foo:“不过后来你觉得,不如为你的程序实现一个领域语言啥的,你来解释这些脚本算了,这不是更灵活么?”

1
2
3
4
5
6
7
8
dsl.cc
if xxx:
action1
else:
action 2

load(dsl.cc)
result = exe()

Foo:“最后你发现你的代码越来越像一个解释器了。”

Bar:“你这么一串我豁然开朗啊,最灵活的,不就是写实现一个解释器,虚拟机么。”

Foo:“是的,你的编写的java服务器代码,就是在给java解释器添加功能,只不过java这种静态语言的解释器更安全,python这种动态语言的解释器更灵活罢了。”

Bar:“看来还是动态的灵活啊。”

Foo:“也不是。”

Bar:“你又要刷新我的三观了啊。”

Foo:“我们之前说的思路都是对于解决问题本身,但是我们现在应该思考我们的工具本身。”

Bar:“什么意思?我们的工具不是虚拟机么?”

Foo:“我是说代码本身,我们为了更灵活,还可以用代码来生成代码。”

Bar:“好神奇!”

Foo:“还记得最开始我给你提到的那个例子吗?”

Bar:“打印n个数?”

Foo:“对,直接写n个print,这看上去是有点蠢,但是如果我们的代码是用代码生成的呢?”

1
2
for i in range (10):
print "print %s\n" % i

Bar:“你是说先用代码来生成这些代码,然后在运行生成的这些代码?”

Foo:“恩,这是个思路,虽然这个例子看上去有点蠢,毕竟你要运行另一份代码,但其实,我们的语言编译器一直在试图支持这样的功能。”

Bar:“比如说?”

Foo:“比如像是c语言这样的偏向于硬件的语言,有宏这个东西,宏能够根据在编译前(预处理)的时候,对代码进行替换,这就是一种用代码来生成代码了。”

1
2
3
#define sqrt(x) x*x

int a = sqrt(5);

Foo:“还有像是c++模板这样的功能,比宏更安全,也更强大,它能在编译器编译前,先用模板生成想要的代码,比如c++的泛型就是用定义的template来生成多个类。”

1
2
3
4
5
6
7
class Printer {
public:
template<typename T>
void print(const T& t) {
cout << t <<endl;
}
};

Foo:“用类生成类的思路,不仅仅是想c++这种静态类型语言,像是python,ruby这些语言,因为本身作为动态语言就更灵活,生成代码就更容易,比如元类。”

1
2
# 用元类在代码中动态的创建类。
Hello = type('Hello', (object,), dict(hello=fn)) # 创建Hello class

Foo:“而且我认为,这些动态语言的函数可以作为一阶公民(可以被赋值,被当做入参,被当做返回值),所以本身也可以把某些特性理解为用函数来生成函数。比如:”

1
2
3
4
5
6
7
8
9
10
11
def addn(a):
def f(b):
return a + b
return f

>>> add5 = addn(5)
>>> add6 = addn(6)
>>> add5(5)
10
>>> add6(5)
11

Foo:“甚至在我眼里,重载函数也是一种代码生成代码,因为重载函数本身其实是语法糖,你相同的函数名,其实在编译的时候,编译器会给它们起不同的名字的。当然这个其实不值一提。”

Bar:“这么说来用代码生成代码好强大啊。”

Foo:“这你就觉得强大了啊,还有更强大的,因为上述这些语言都是偏向人类阅读书写的,所以本身看上去就是线性的。但是学过编译原理的你应该知道语言会经过词法分析,语法分析,变成一颗抽象语法树。可以说代码从线性结构变成树形结构。”

Bar:“嗯嗯,然后呢?”

lisp

Foo:“那么你想象一下,如果你的代码本身就是一棵树呢?看这个lisp代码”

1
2
3
4
5
6
(defun area-circle(rad)
"Calculates area of a circle with given radius"
(terpri)
(format t "Radius: ~5f" rad)
(format t "~%Area: ~10f" (* 3.141592 rad rad)))
(area-circle 10)

Foo:“那么想象一下,这种语言支持一种宏的东西,但是远比C的宏强大,因为在lisp中,‘代码即数据’,你可以用一个表达式去生成另一个表达式,而表达式本身也是数据。”

Bar:“卧槽,lisp好强大,你教我lisp吧”

Foo:“哈哈,我只懂一点点lisp,很多细节都不是很了解,学好这些东西,最后本质就是在学数学。”

Bar:“嗯嗯,程序的本质就是数学。”

Foo:“不过说了这么多,我还想告诉你的是,程序灵活性与安全性往往是对立的,比如动态语言要比静态语言灵活,但是因为没有类型检查,所以很容易出现bug。”

Bar:“嗯嗯”

Foo:“这个时候类型系统就很重要了,你看上面提到的静态的灵活性,都在通过类型系统来做某些限制,来保证程序的安全。静态语言的多态之所以安全,是因为在编译器做了类型检查,但是动态语言就很容易运行时异常了。而且,那种灵活的可配置项,理论上在加载之前,是要做校验的,否则也可能把服务器的上下文给搞坏了。”

Bar:“嗯呢,我以后会注意的。”

Foo:“不过说到这,这位同学,你一直站在这里偷听到现在不好吧?”

当时吓了我一跳。连忙道歉说:“没有没有,我就是凑个热闹,我就没听懂你们在聊什么,只是不明觉厉。想不到狮吼功还有一招大喇叭。”

说完我迅速离开去接热水了。但是在冲咖啡的时候,我一直在想,一定要把这次听到的写成博客。