21/8/2 读书笔记 数据库编程 函数式编程中的权责让渡
21/8/1 读书笔记
数据库系统概论 数据库编程
按照之前的介绍,标准SQL是一种非过程化的语言,缺乏流程控制能力,因此难以实现业务中的逻辑控制。
在程序设计的情景下,SQL需要进行适当的扩充以满足使用需要,主要分为两种方案:
- 嵌入式SQL:将SQL语句嵌入其他程序设计语言(称为宿主语言),比如C或Java,然后利用这些宿主语言的逻辑控制方式来补充SQL的缺陷。
- 过程式SQL:某些数据库系统提供拓展的SQL语言,使得SQL也能具有流程控制能力,称为过程化SQL(Procedural Language/SQL)。
对于嵌入式SQL,由于不同的宿主语言中SQL的嵌入方式不同,故不详细介绍嵌入过程。
对于过程式SQL,由于不同的数据库系统对于过程化SQL所提供的具体语法不同,故不详细介绍语法问题。
游标 cursor
SQL是面向集合的语言,因此一条SQL语句的对象可能是多个元组。但是在一般情况下,我们在过程控制中所定义的变量一次只能存储一个元组。因此系统提供游标来管理SQL的操作对象,即多个元组。
可以将游标理解成一个迭代器,系统为每个游标提供一个缓冲区用于存放SQL的结果。每次对游标进行一次FETCH操作,游标就会返回当前指向的元素,并接着指向缓冲区中的下一个元素。利用循环控制,对游标进行多次FETCH操作就能获得多个元组。
/*
以C语言作为宿主语言时的嵌入式SQL为例
'EXEC SQL'指明该语句是C语言下指定该语句为嵌入式SQL语句
*/
EXEC SQL BEGIN DECLARE SECTION;
char HSno[9];
char HSname[20];
EXEC SQL END DECLARE SECTION; // 声明变量,该变量能够在C语言中使用,也能被嵌入式SQL语句所感知
EXEC SQL DECLARE mycursor CURSOR FOR
SELECT Sno,Sname
FROM Student; // 定义一个Cursor,对应Student中的所有元组的Sno和Sname
EXEC SQL OPEN mucursor; // 打开mycursor
for(;;) {
EXEC SQL FETCH mycursor INTO :HSno,:HSname;
if(SQLCA.SQLCODE!=0) break; // 循坏终止条件
printf("%s-%s",Hsno,HSname); // 循环取出元组对象,并输出
}
EXEC SQL CLOSE mycursor; // 关闭mycursor
存储过程 与 函数
存储过程与函数都是会经过编译与优化后,存储在数据库服务器中的语句块,他们都是由过程式SQL书写的。由于存储过程与函数都是预先编译与优化好了,所以在调用时省去了编译优化的时间开销,因此可以用于描述一个可复用的SQL操作流程。
存储过程与函数的区别在于,存储过程可以没有返回值,而函数必须有一个返回值。
以下我们以存储过程为例:
CREATE OR REPLACE PROCEDURE Transfer(inAcc INT, outAcc INT, amount FLOAT) # 定义存储过程
AS DECLARE
inAccTotal FLOAT;
outAccTotal FLOAT; # 声明变量
BEGIN # 开启事务
SELECT total INTO outAccTotal FROM Account WHERE accountno = outAcc;
IF outAccTotal < amount THEN
ROLLBACK; # 回滚事务
RETURN;
END IF;
UPDATE Account SET total=total-amount WHERE accountno = outAcc;
UPDATE Account SET total=total+amount WHERE accountno = inAcc;
COMMIT; # 提交事务
END;
定义一个函数:
CREATE OR REPLACE FUNCTION func1(a INT, b INT) RETURNS INT
AS ...
执行存储过程或函数都可以使用CALL:
CALL PROCEDURE transfer(123,234);
CALL func1(12,23);
# 或者也可以直接将函数看做一个具有其返回类型的变量使用
CALL PROCEDURE transfer(func1(12,32),func1(23,32));
函数式编程思想 权责让渡
什么是权责?
权责是与我们在编写函数的过程中对于具体实现所需要进行的考虑息息相关,比如C语言编程中我们需要考虑内存管理,面向对象编程中我们需要考虑维护对象的状态等,都是属于我们程序编写人员的权责,需要我们在编写程序时就在代码中体现出对这些问题的考量。
权责让渡的基本思想就是让程序员将更多的任务与控制权转嫁给语言与运行时(Runtime),而不需要自己来负责。权责让渡使得程序员从繁琐的运行细节中解放出来,而在更高的抽象层次进行更加伟大的创造。
Java引入的自动垃圾回收机制就是权责让渡的一个典型例子,程序员再也不用像C语言时代那样自己用malloc来管理内存了,而是能放心地利用OOP的思想进行创作,而将优化内存的职责全权托付给JVM(以及编写和优化JVM效率的其他程序员)。
本章中将从5个角度来考虑我们在函数式编程中进行权责让渡的情景。
高阶函数 替代 了 迭代
在命令式编程中,对于筛选任务,我们需要用循环来对列表中每个元素进行测试,把符合标准的元素塞进一个新集合里面,最后返回这个集合。而函数式编程下我们采用更高阶的函数,一般来说是filter()就能轻松解决。
命令式编程下我们需要考虑循环边界,同时维护循环中的临时变量;而函数式编程下,我们只需要一句话就能将这些繁琐的细节统统抛之脑后。以Java 8为例:
List<Integer> retList = new ArrayList(); // 临时变量
for (Integer i:inputList) { // 管理迭代边界
if(test(i)) retList.add(i);
}
return retList;
//函数式编程更加简洁//
inputList.stream.filter(test).collect(toList());
闭包 包装了上下文
闭包(Closure)是所有函数式语言都具备的一种特性。
闭包实质上是一种特殊的函数(或者也可以认为是代码块),其在暗地里将所有与该代码块相关的所有变量(即上下文)一起绑定了起来,形成了一个函数。代码块和变量间的绑定是不需要程序员在编写时考虑的,而由语言与运行时来实现。以Groovy为例:
def Closure makeClosure(amount) {
// 注意,此处amount实际是makeClosure函数的局部变量
return {a -> a < amount}; // 返回的代码块与变量amount有联系,因此amount成为该代码块的上下文,而与之绑定
}
closure1 = makeClosure(100);
closure2 = makeClosure(200);
closure1(150); // false
closure2(150); // true
// 可以看出不同的闭包对象中所关联的上下文是独立的,及对应的变量amount的值是不同的
如果在面向对象编程下,我们为了实现闭包所想要表达的概念,相当于实现一个新的对象(对应闭包),并在该对象中管理一系列变量来表示状态(对应闭包的上下文),这使得状态的定义和维护的权责压在了程序员的肩上。
而引入闭包后,程序员能够不用考虑为一个闭包需要维护哪些状态,而将维护上下文的工作直接交给语言与运行时,自己只需要使用闭包时注意不同闭包对象间上下文独立即可。
同时闭包绑定上下文的时机不一定是即时的,这使得闭包也能支持推迟执行原则。闭包的延迟,简而言之就是闭包返回的内层函数不会立即执行, 而是在使用时才执行。比如在python中:
def count():
fs = []
for i in range(1, 4):
def c():
return i * i
fs.append(c)
return fs
# python认为,我们将c()插入fs中时,不属于使用时;而fs被返回时属于使用时,因此此时c()才与上下文变量i绑定
# 此时i = 3,故fs中所有的元素返回值都是9
f1, f2, f3 = count()
>>>f1: 9
>>>f2: 9
>>>f3: 9
# 可以再创建一层函数, 加深深度, 以便于将 循环变量 与 函数执行 的变量隔开
def count2():
def f(j):
def g():
return j*j
return g # 此时python认为是闭包使用时,故进行上下文绑定
fs = []
for i in range(1, 4):
fs.append(f(i)) # f()中立刻进行上下文绑定,因此i的当前值被保存下来
return fs
c1, c2, c3 = count2()
>>>c1: 1
>>>c2: 4
>>>c3: 9
事实上,我们可以发现,python认为如果函数执行到闭包所关联的上下文变量的作用域之外时,就说明闭包进入了使用时,此时进行上下文绑定。
柯里化&部分施用 部分替代了函数传值
柯里化(currying)与部分施用(partial application)是两个不同的概念。即使它们在使用效果上看起来差不多,它们都使得我们将一个多参数函数中的某些参数进行预设,从而得到一个参数更少的函数。
我们之前最熟悉莫过于默认参数(default parameter),可以将一个多参数函数中某些参数进行指定,从而使用较少的参数就能调用该函数。但是其与我们所讲的柯里化以及部分施用完全不同,默认参数只是将一个函数的默认情况进行了假定,而使得我们在使用时能够形成函数的重载(overload),进而让我们能在同一个作用域内使用同一个函数名称来接受和使用不同的参数列表,其本质上并不能“灵活”地改变函数本身。
柯里化和部分施用所能达到的“灵活”究极有多“灵活”呢?它们能够在运行时为一个多参数函数指定参数值,并由此形成一个新的函数,以供在作用域内反复调用。这就好比我们将默认参数的设定时机从编译前移到了运行时,极大地提高了程序员的编写效率。
以Groovy为例:
def mul = { x, y, z -> x * y * z}
def eight_mul = mul.curry(8)
def four_mul = mul.curry(4)
def five_five_mul = mul.curry(5).curry(5) // 柯里化 表示
def five_five_mul = mul.curry(5,5) // 部分施用 表示
eight_mul(1,5) // 8*5 = 40
four_mul(1,7) // 4*7 = 28
five_five_mul(5) // 5*5*5 = 125
我们注意到,柯里化和部分施用的区别在于:
- 柯里化认为参数是一个个进行绑定的,因此形成一条链条。函数柯里化的结果是返回该链条上的下一个函数。
- 函数部分施用将参数绑定到函数上,从而像固定了默认参数一样返回新的函数,其参数量更少。
这种区别在我们不按顺序绑定参数时体现地更为显著,以Scala为例:
//部分施用,Scala中用_跳过不绑定的参数//
def ticketPrice(defaultPrice: Double, age: String) : Double =
state match {
case "child" => defaultPrice * 0.5
case "adult" => defaultPrice
}
val childTicket = ticketPrice(_: Double, "child")
childTicket(100) // => 50
//柯里化,Scala采用多组参数列表//
def ticketPrice(age: String)(defaultPrice: Double) : Double =
state match {
case "child" => defaultPrice * 0.5
case "adult" => defaultPrice
}
val childTicket = ticketPrice("child")
childTicket(100) // => 50
柯里化和部分施用的定义十分微妙,并且对于语言的设计者来说在实现方面比较复杂,但是这并不妨碍其在编程中的作用:
- 将函数视作编程世界的新公民,利用柯里化和部分施用实现“函数工厂”,来在运行时生成需要的函数来使用。
- 柯里化与部分施用使得模板方法及相关设计模式失去了意义。
- 利用部分施用来隐藏参数,避免重复传入相同的参数,达到简洁的目的。
递归 取代顺序排列的想法
对于一个数组,我们通常的想法是一个数组的每个元素都对应着一个索引,而按索引顺序排列。因此我们会依次地按索引顺序遍历每个数组元素。
递归的思想下,我们认为\(一个数组 = 该数组当前的第一个元素 + 其他元素组成的另一个数组\),而我们递归地遍历一个数组就是不断地从该数组中取出第一个元素,然后递归地处理其他元素组成的另一个数组,直到我们当前处理的数组为空。以筛选操作为应用场景,我们以Scala为例:
def filter(xs: List[Int], p: Int => Boolean): List[Int] =
if (xs.isEmpty) xs
else if (p(xs.head)) xs.head :: filter(xs.tail, p)
else filter(xs.tail, p)
def dividesBy(n: Int)(x: Int) = ((x % n) == 0)
val nums = List(1,2,3,4,5,6,7,8,9,10,11)
filter(nums, dividesBy(3)) // List(3,6,9)
递归的缺点在于:
- 大多数语言对于递归时的栈优化并不好,使得递归的使用受到平台的技术限制
- 递归理解起来比较复杂
而相对于其缺点,递归的优点并不显而易见。考虑我们如果采用顺序遍历的方式,程序员需要创建一个临时变量用于存放作为返回值的数组,需要检查元素并不断向这个数组加入新的元素,需要在筛选结束后返回这个变量,因此实际上我们维护了一个状态,这个状态在当前情景中就是这个临时变量。我们再看递归中,我们并没有维护任何临时变量,我们的递归函数是没有状态的,我们所需要的返回值实际上存放在栈中,由语言在运行时维护以及返回,而不需要我们维护。
作业顺序重排 让我们更少考虑问题的实现细节
作业顺序重排,指语言在运行时将我们定义的函数调用顺序进行重排,以提高效率。
Java 8引入的Stream就是这个问题最好的例子,考虑以下这个例子:
public String cleanNames(List<String> names) {
return names.stream()
.map(e -> capitalize(e))
.filter(n -> n.length() > 1)
.collect(Collectors.joining(","));
}
我们将一组包含名字的列表筛选掉长度小于等于1的,最后组成一个大的字符串。
注意到,我们先map后filter,导致我们map的集合较大而filter的集合较小。通常map的效率低于filter,因此理论上我们应该先filter再map,这样效率更高。但是其实Java 8 也这样想,因此在运行时,它会很贴心地重新安排缓求值的顺序,先filter后map。
缓求值:map和filter都属于缓求值(lazy)的操作,它们会尽可能推迟执行,只有在后续遇到非缓求值(又称热情求值)的操作时才会让数据“流”过去。
Stream<String> temp = names.stream().map(e -> {Systeam.out.println("nope, I comes late");capitalize(e);}); System.out.println("I will be the first line!"); // 这一行会先打印出来 temp.collect(Collectors.joining(",")); // collect是热情求值,此时才会触发map缓求值执行(此处忽略filter)
事实上,函数式的表达方式极大地方便了编译器来帮我们做优化,因为没有一堆复杂的流程控制和大括号需要解析,只需要解析高阶函数的调用顺序就行。这也反映出权责让渡的优点,就是将更多的优化权利给语言和运行时,让他们能够更加容易进行优化来反过来造福使用这个语言搞开发的程序员。
ps:写完收工,去玩会儿Total Accurate Battle Grounds,这游戏太魔性了

浙公网安备 33010602011771号