C语言的抽象与函数指针--思想(转)
一、何为抽象?
从小到大,我们接触到的抽象,最熟悉的莫过于数学了。为什么这样说呢?
比如说,在小学的时候,老师总是拿了几个苹果来引诱我们:同学们,这里有几个苹果啊?于是我们流着口水一个个地数,一个苹果,两个苹果,三个苹果,然后说三个苹果!第二回,老师又拿了三只葡萄来引诱我们:同学们,这里有几只葡萄啊?于是我们又一只只数过来:一只葡萄二只葡萄三只葡萄,三只葡萄!第三天,老师又拿了三颗糖果来问我们:同学们,这里有几颗糖果啊?我们又数开了:一颗糖果,两颗糖果,三颗糖果!回答:三颗糖果。
每一次老师拿了不同的东西来的时候,我们都要从头到尾数一次:一个苹果,两个苹果,三个苹果……
稍稍长大了一些后,老师再拿了三个雪梨来问我们:同学们,这里有几个雪梨啊?这回我们学精了,不用一个雪梨二个雪梨地数,我们直接数:一二三,一共三个。
请注意到最后一次数雪梨的时候,我们不是数多少个雪梨,我们只是在数一二三,并没有说一个雪梨二个雪梨三个雪梨,换句话说,我们并不是在数讲台上的雪梨,我们只是在数一二三。而得出来结果却是正确的。为什么呢?
我们来分析一下。每次老师叫我们数的东西都是不同的,苹果,葡萄,糖果,雪梨。它们之间好像没有什么关系。而每次我们都能得出一个相同的结果:三。
这个“三”是何方神圣,能把这几堆风马牛不相及的东西联系起来?这几堆东西之间有什么共性是隐藏在它们各不相同的是外表之下的呢?
答案大家都知道了,这个共性就是“数量”,这几堆东西的数量,是它们唯一相同的地方。而这个“三”,就是用来刻划这一个共性——数量的。这几堆东西的数量是相同的,因此,这个“三”也就同时刻划了这三堆东西的数量这个共性。
这个例子很简单,道理也很明显,但是却是我们在数学上所做的第一次抽象!
至此,我们不妨这样诠释抽象的含义:抽象就是对某类事物的共同特征的刻划。
什么叫“共同特征”?在上面的例子中,“数量为三”就是那四堆东西的共同特征,把它抽象出来,就是一个“三”。当我们认识了一个苹果后,以后再看到苹果,就知道它是苹果,因为这两个苹果有共同的特征,我们把它抽象出来,形成一个“苹果”的概念,以后再遇到苹果,把这个实在的苹果跟抽象后的“苹果”一对照,就能知道那个是不是真的苹果。抽象的威力就是,把事物的共同特征刻划出来之后,就能把它应用到这一类事物中,而不必去对付这一类事物的具体的个体了。
把这个“三”抽象出来后,我们数苹果就方便多了,我们只需要数“一二三”,然后在结果后面接上苹果葡萄糖果雪梨后,我们就知道那里有三个苹果三个葡萄三颗糖果三个雪梨。
请注意把“三”抽象出来前后,我们数苹果方法的变化。而抽象之前,我们是对着一个苹果一个苹果地数,数完后一个葡萄一个葡萄地数,数苹果的方法不能用在葡萄上,而数葡萄的方法不能用在数苹果上。每数一样东西,我们就要学习一种数数的方法,多不方便。
而我们把“三”抽象出来后,我们数苹果的方法就成了数“一二三”,结果后面接上“个苹果”,数葡萄的方法就成了数“一二三”,结果后面接上“个葡萄”,数苹果的方法稍作修改就可以应用到数葡萄上。我们只学会了一种数数方法,却可以用它来数各种各样的东西,包括香蕉,椰子,桔子等等等等的东西,这就是抽象的威力。
同时,对这个“三”我们也可以发出这样的疑问:这个“三”是什么?这个“三”是那几堆东西各自的数量吗?不是,它不是那几堆东西各自的数量,它只是一个汉字,一个代号,3也可以叫“三”,III也可以叫“三”。把这个数量拿走以后,“三”,又是什么?(武林外传看多了……)
其实,“三”的确也不是什么,它只是一个刻划,在世界上找不到任何一个具体的东西可以直接对应到“三”的。我们运算的只是一个抽象的东西,得出一个抽象的结果,再把这个抽象的结果重新映射到具体的事物中去。我们处理事物的方法和具体的事物分离开来了!
小学里我们学习的抽象,主要是把数量抽象出来,并用具体数字来表现它们。对这些具体数字的运算,就代表了对这些具体的个体的运算。因此,小学的数学又叫“算术”,它是对具体的数进行“运算”的“技术”。而到了初中,我们学会了用变量来代替数字,从而把某一类运算抽象出来。
比如说求长方形的面积。在小学的时候,我们只会求那些长宽都是给定的“具体的”(这里这个“具体的”意思不是说它们可以在世界上找到对应物,什么意思?观众自己琢磨去吧,呵呵)数的长方形的面积,而上初中后,我们学会了用字母来表示数字,于是我们学会了任何一个长方形的面积,学会了用S=ab来表示一个长方形的面积。
再比如说说路程的长度。小学的时候,我们只会求速度与时间都是给定的“具体的”数的那种运动的路长度,而初中的时候,我们就学会了用S=vt来表示各种各样的运动的路程长度。
这又是一次进步!我们在已经抽象过的数字上面又进行了一次抽象。这次抽象中,我们看到了,其实求长方形的面积跟求路程的长度,本质上是一样的!于是我们又“无师自通”地学会那种具有“结果与两个变量成正比例”的特征的问题的解法,把这个“结果与两个变量成正比例”的特征抽象出来,就是S=ab这个等式。
中学的时候遇到的时候抽象,主要是用字母来代替数字,从而把数量之间的关系与具体的数字分离开来,这种数量之间的关系,就是数学中的“函数”了。中学的数字又叫“代数”。
上到大学,我们学到的数学,则是更高层次的抽象。大学的数学叫“数学分析”,是研究函数之间的关系,用f(x)来代表一个函数,正是对各种函数之间的共同特征的刻划。
比如说,一个有界函数函数在某个有限区间上是否可积,就要看它在这个有限区间上是否处处连续。具体这个函数是什么,我们不用理会。只要它满足这个条件,那它就是可积的。
上面说了一堆,就是要说明,抽象的威力,就是在于让我们可以专心处理某类事件的共同特征本身,而不用关系具体的个体。
二、C语言中的抽象
通过上面的分析,我们知道了抽象的威力。而对于一个程序语言来说,它的能力大小取决于它对具体事物的抽象能力。一种语言抽象的能力越大,它能对事物的描述就越本质。
有些观众可能会觉得,上面所说的抽象,好像跟程序设计中的某些原则有些类似。其实这很正常,程序设计本来就是从数学中来的,数学中的某些思想能在程序设计中得到体现也不奇怪。只是可能某种思想在某些语言中体现得比较明显面而另一些思想则体现在其它语言罢了。
而在C语言中,也具有三个层次的抽象能力,正好与上面所说的三个抽象层次相对应。
其一,在C语言中存在各种数据类型,可以对现实中的数量进行映射。这是第一个层次的抽象。如果没有这个抽象能力,那基本上这个语言就没有什么用了。一个例子是HTML,这个语言实际上不算编程语言,因为单靠它自己,连1+1都不能计算。因为它缺少表示数字的机制。它只能用于标记,属于一个标记符号。
其二,在C语言中可以定义函数,与代数中的函数有类似之处。可以让我们以相同的方法处理那些具有相同逻辑特征的运算。
比如说,我们要计算1,9,10,111的平方,我们当然可以这样写:
#include <stdio.h>
int main(void)
{
printf("%d\n", 1 * 1);
printf("%d\n", 9 * 9);
printf("%d\n", 10 * 10);
printf("%d\n", 111 * 111);
return 0;
}
但是这样写,并没有把“平方”这个共同的概念表示出来,于是我们在学习了C语言中的函数后,我们会把x * x这个模式抽象出来,把程序写成下面的样子:
#include <stdio.h>
int square(int x)
{
return x * x ;
}
int main(void)
{
printf("%d\n", square(1));
printf("%d\n", square(9));
printf("%d\n", square(10));
printf("%d\n", square(111));
return 0;
}
或者更简单的:
#include <stdio.h>
int square(int x)
{
return x * x ;
}
int print_int_square(int x)
{
return printf("%d\n", square(x));
}
int main(void)
{
print_int_square(1);
print_int_square(9);
print_int_square(10);
print_int_square(111);
return 0;
}
在第一个程序中,我们直接运算了1,9,10,111的平方,把它们打印出来。在第二个程序中,我们把这个“平方运算”抽象成函数square(),这下,我们不仅可以计算1,9,10,111的平方了,还可以计算任何一个整型数的平方。换句话说,square()不理会这个数是什么,只要求它是个整型数。在第三个程序中,我们是把打印语句也放在一个函数中,不过这并没有什么本质的不同。
其三,C语言中一个非常重要的特性,让它具有刻划第三个抽象层次的能力,这就是函数指针。
回头看上面第三个程序,我们为什么一定要让它打印一个数的平方呢?要是能让它在调用的时候再决定打印什么这不是更好吗?这个函数不是更通用吗?
于是我们写出第三个程序:
#include <stdio.h>
typedef int(*F_T)(int);
int square(int x)
{
return x * x ;
}
int print_int_fun(int x, F_T fun)
{
return printf("%d\n", fun(x));
}
int main(void)
{
print_int_fun(1, square);
print_int_fun(9, square);
print_int_fun(10, square);
print_int_fun(111, square);
return 0;
}
现在,print_int_fun()不关心这个数是什么,也不关心打印这个数的什么“亲戚朋友”了。它只管打印。
我们现在可以定义一个函数来计算一个整型数的立方,并且把它传递给这个print_int_fun(),就可以打印出这个数的立方了。
换句话说,print_int_fun()不仅处理变量,同时也处理函数,它具备了第三层抽象的能力。
三、主角——函数指针
使C语言具备第三层抽象能力的,是C语言中的函数指针。使用函数指针,我们可以实现模拟把函数作为一个参数传递进另一个函数中以供后者调用,使得调用者有一种模板的性质。
作为一个练习,观众们不妨看一下下面几个函数的作用:
T* map(T (*fun)(T), T arr[], int len)
{
for (int i = 0; i < len; ++i)
{
arr[i] = fun(arr[i]);
}
return arr;
}
R reduce(R (*fun)(R, T), R init, T arr[], int len)
{
R res = init; // 最终要返回的结果
for (int i = 0; i < len; ++i)
{
res = fun(res, arr[i]);
}
return res;
}
int filter(bool (*fun)(T), T arr[], int len)
{
int res = 0; // 在arr中能使fun()返回真值的元素个数
for (int i = 0; i < len; ++i)
{
if (fun(arr[i]))
{
arr[res++] = arr[i];
}
}
return res;
}
T* range(T arr[], T init, int len)
{
for (int i = 0; i < len; ++i)
{
arr[i] = init + i;
}
return arr;
}
在C++的STL中和Python,它们有对应的泛型算法,但是名字有些不同。在这里,我更喜欢用Python的术语,因为我不懂C++。-_-!
这四个函数都很简单易懂,这里就不作解释了。
四、例子
如果我们要写一个程序来计算1到100的各个数的和,我们可能会这样写:
int res = 0;
for (int i = 1; i <= 100; i++)
{
res += i;
}
printf("%d\n", res);
如果我们想要计算1到100之间的数的平方和的话,我们可能会这样写:
int res = 0;
for (int i = 1; i <= 100; i++)
{
res += i * i;
}
printf("%d\n", res);
如果要计算1/(1*3) + 1/(5*7) + 1/(9*11) + ... + 1/(397 * 399),我们可能会这样写:
double res = 0.0;
for (int i = 1; i <= 100; i++)
{
res += 1.0 / ((4 * i - 3) * (4 * i - 1));
}
printf("%lf\n", res);
如果要计算((2 * 4) / (3 * 3)) * ((4 * 6) / (5 * 5)) * ... * ((22 * 24) / (23 * 23)),我们可能会这样写:
double res = 1.0;
for (int i = 1; i <= 10; i++)
{
res += ((2.0 * i) * (2.0 * i + 2.0)) / ((2.0 * i + 1) * (2.0 * i + 1));
}
printf("%lf\n", res);
很明显,这四个程序具有相同的结构,只是在以下几个方面不同:结果的初值不同,每次的增量不同,增加增量的方法不同,项数长度不同。如果我们把这四个不同给提取出来作为参数,则可以把这个四个程序合并为一个:
#include <stdio.h>
double delta1(double n)
{
return n;
}
double delta2(double n)
{
return n * n;
}
double delta3(double n)
{
return 1.0 / ((4 * n - 3) * (4 * n - 1));
}
double delta4(double n)
{
return (((2 * n) * (2 * n + 2)) / ((2 * n + 1) * (2 * n + 1)));
}
double add(double x, double y)
{
return x + y;
}
double multi(double x, double y)
{
return x * y;
}
double sum(double (*fun)(double), double init, int len, double (*attach)(double, double))
{
double res = init;
for (int i = 1; i <= len; i++)
{
res = attach(res, fun(i));
}
return res;
}
int main(void)
{
double res1 = sum(delta1, 0.0, 100, add);
printf("%lf\n", res1);
double res2 = sum(delta2, 0.0, 100, add);
printf("%lf\n", res2);
double res3 = sum(delta3, 0.0, 100, add);
printf("%lf\n", res3);
double res4 = sum(delta4, 1.0, 10, multi);
printf("%lf\n", res4);
return 0;
}
这样是不是简单很多?
计算一个数的阶乘的程序大家都写过,但是大家写过这样的阶乘程序没有?
reduce(multi, 1, range(arr, 1, LEN), LEN);
其中,multi是把两个整型相乘的函数,arr是一个整型数组,LEN是它的长度,为10。而如果要计算1到1000的数中,平方数的个位数为4的数的立方和,则可以这样写:
int len = filter(square_end_with_4, range(arr, 1, 1000));
R res = reduce(add, 0.0, map(cube, arr, len), len);
利用上面的map(),reduce(),filter(),range()函数也可以把上面的程序改写:
int main(void)
{
#define LEN 100
R arr[LEN];
R res1 = reduce(add, 0.0, range(arr, 1.0, LEN), LEN);
printf("%lf\n", res1);
R res2 = reduce(add, 0.0, map(delta2, range(arr, 1.0, LEN), LEN), LEN);
printf("%lf\n", res2);
R res3 = reduce(add, 0.0, map(delta3, range(arr, 1.0, LEN), LEN), LEN);
printf("%lf\n", res3);
R res4 = reduce(multi, 1.0, map(delta4, range(arr, 1.0, 10), 10), 10);
printf("%lf\n", res4);
return 0;
}
最后,以一个求二叉树中的子结点的例子来结束这篇文章。
如果把二叉树的类型定义为Tree,并且定义Tree*的类型为PTree,并且已经定义好以下几个函数:
// 建立二叉树结点
PTree make_tree(PTree t, PTree l, PTree r, int val)
{
t->value = val;
t->left = l;
t->right = r;
return t;
}
// 获得二叉树的左子树
PTree get_left(PTree t)
{
return t == NULL ? NULL : t->left;
}
// 获得二叉树的右子树
PTree get_right(PTree t)
{
return t == NULL ? NULL : t->right;
}
// 获得二叉树的结点值
int get_value(PTree t)
{
return t == NULL ? -1 : t->value;
}
假如我们以结点N来表示根结点的右子树的右子树的左子树的右子树的左子树的左子树这个结点,计算N的结点的值,如果N不存在则返回-1。我们会怎么计算呢?
有些人可能会这样计算:
PTree n = root;
if (n->right)
{
n = root->right;
if (n != NULL)
{
n = root->right;
....
}
}
return get_value(n);
这个办法虽然可行,但是很笨重。或者有人会这样计算:
typedef PTree (*F_T)(PTree);
PTree n = root;
F_T arr[6] = {get_right, get_right, get_left, get_right, get_left, get_left};
for (int i = 0; i < 6; i++)
{
n = arr[i](n);
}
return get_value(n);
但是如果换了是我,我会这样计算:
typedef R PTree*;
typedef PTree (*T)(PTree);
R cat(R res, T n)
{
return n(res);
}
F_T fs[LEN] = {get_right, get_right, get_left, get_right, get_left, get_left};
Tree res = reduce(cat, &t01, fs, LEN);
return get_value(res);
别看这四个函数很小,但是它们在处理列表的时候非常有用,因为它们通过函数指针的方式,把列表的生成,遍历,筛选,求和都抽象起来了,所以它们能用于许多列表操作里面去!
函数指针是对解决某一类问题方法的抽象描述,抽象是因为它并不知道它所指向的方法究竟是怎么实现的,它并不能识别赋给他的方法的多样性,所有的函数都被转化成函数指针类型,它提供方法的统一接口。只有被调用时,才被具体化了,调用者按照自己的数据类型分配空间,只做一些参数压栈的工作,被调用者按照自己规定的参数数据类型去解释这些参数,按照自己的方法去运算。