聊聊编程范式
当你学会了越来越多的编程语言后,你就会发现,不同的语言的设计思路、用法习惯天差地别。为什么会有这么大的差别?这是一件很有趣的事情,今天我们就来聊聊。
值得一提的是,本文将会涉及很多种不同的语言,读者在看到自己熟练的语言时,会觉得非常简单,但看到自己不熟悉的语言时,看了这篇文章后,你又会觉得非常神奇。
重要
在阅读本文之前,首先要了解堆和栈的概念,本文就不花篇幅讲解了。如果对此不了解,建议先去查阅相关资料,弄明白这两个重要概念。这里用简短的话进行总结:
- 栈是函数的临时工作区,函数里定义的局部变量都放在这里。函数执行完毕,栈上的变量自动销毁。
- 堆是内存中的一片自由区域,用于存放需要长期存在、跨多个函数使用的变量。堆上的内存不会自动消失,你必须手动释放。
栈的分配和释放速度比堆快得多(只需移动指针),因此临时变量优先使用栈可以提升性能。但访问速度上两者差异不大。
我们先以 C++ 为例,这是一个 C++ 的函数:
int *test() {
int a = 5;
return &a;
}它返回一个指向int的指针。这段代码有问题吗?编译的时候,是能够编译通过的。但是运行起来就有可能有问题,有可能没问题。
我们在声明int a = 5;时,a这个变量会被分配到栈上,在函数退出时,它就被释放掉了,但是我们这里却把它的地址返回了出来。其它代码拿到它的地址时,如果这个地址暂未被其它变量占用,那目前存放的还是5,那就没有问题;如果这个地址已经被别的变量占用了,那拿到的就是别的变量存放的值。
那如何避免这个问题呢?就要显式将其分配在堆上:
int *test() {
int *a = new int(5);
return a;
}使用new关键字,就会将变量强制分配在堆上,离开了函数它仍然存在。但这会引发一个新的问题,你在用完了这个变量后,要手动调用delete a;将其释放,否则它的内存就一直被占用,这样程序运行久了内存占用会越来越大,这就是“内存泄漏”。
内存管理问题在 C++ 中是一个很麻烦的事情,这也让很多程序员对 C++ 感到非常头痛。于是,就有了 Java 语言:
public class Test {
public int test1() {
int a = 5;
return a;
}
public Person test2() {
Person p = new Person();
return p;
}
}我们看到这里有两个函数:test1和test2。由于 Java 中没有指针的概念,因此我们在例子中只能将a和p返回。那这两个函数有什么不同呢?在test1中,由于a是一个基础类型int,它会以值的方式返回出来,就如同 C++ 中的按值返回。在test2中,由于p是new出来的一个对象,并不是基础类型,我们得到的是它的引用,因此返回的也是该对象的引用。
那我们需不需要手动释放p呢?在 Java 中是不需要的,因为 Java 有垃圾回收(Garbage Collection,简称GC)机制。它通过某种机制判断,如果p不再会被用到了,就会自动将其释放。这就解决了 C++ 程序员诟病的内存管理问题。
那么其它语言呢?其实大部分应用层开发语言(如 Java、Python、Go、JavaScript)都有 GC 机制,你不需要手动管理内存。除非你对性能有严格的要求,否则在大部分业务需求中,都没必要使用 C++ 等系统编程语言进行开发,用其它一门有 GC 机制的语言将会大大提高你的开发效率。
我们继续来看一段 Java 代码:
public class Test {
public void test() {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
System.out.println(i);
}
}).start();
}
}
}这段代码的用途就是启动 10 个线程,然后分别打印 0 ~ 9 到控制台。我们先不说它有并发问题,首先它直接在红色的这一行编译报错。报错的原因很简单,i是基本类型的变量,它在另外的线程使用了,运行到System.out.println(i);这一行的时候,有可能外面的函数已经返回了,i可能都已经释放了,这时候你就不该拿到i。甚至,只要你进入了一个匿名类,即使你不是启动了一个新的线程,Java 为了严谨,你也不能用外面的对象。有没有方法能用呢?有,就是加个final:
public class Test {
public void test() {
for (int i = 0; i < 10; i++) {
final int ii = i;
new Thread(new Runnable() {
public void run() {
System.out.println(ii);
}
}).start();
}
}
}为什么这里不报错了呢?其实这里是一个语法糖,它实际上是偷偷藏了一个参数,把ii作为一个参数传进去,就可以被里面引用了。只不过它没有明确的写出来这个参数,它实际的效果就等于通过参数的方式传入进去,这就叫做语法糖。
那为什么不加final不行呢?因为如果这个变量可能会变的话,你就无法保证里面读到的值是变之前还是变之后的,你就不知道作为一个参数传进的去语法糖应该取变之前的还是变之后的,这就会乱套。Java 为了避免产生更多的问题,直接不允许你这样写。
或者我们有一种更加通用的写法:
public class Test {
public void test() {
for (int i = 0; i < 10; i++) {
int[] ii = new int[]{i};
new Thread(new Runnable() {
public void run() {
System.out.println(ii[0]);
}
}).start();
}
}
}为什么这样写可以呢?看看这个new关键字,很显然数组会被分配到堆上,进行引用传递,因此就可以传出去使用了。
通过上面的例子,我们可以得到这样一个结论:想要把函数内部的局部变量的引用(或指针)传递到函数外面去,只要把它分配到堆上即可。
接下来我们用 Go 实现相同的代码:
func test() {
for i := 0; i < 10; i++ {
ii := i
go func() {
fmt.Println(ii);
}()
}
}这里的ii可以变,但是在 Go 中这样传进去却不会报错。这是为什么呢?因为 Go 的 GC 机制和 Java 有些不太一样。Go 有一个逃逸分析的机制,它无论是什么类型,它都会去分析。如果它只在本函数中使用,函数结束了就不会再用了,那分配在栈上就足够了;如果离开了函数还可能被使用,那就只能分配在堆上。上面这个例子中,ii肯定就只能分配在堆上了。
既然有了 GC 和逃逸分析,Go 就可以放心地写出我们最上面 C++ 那样写会有问题的代码:
func test() *int {
a := 5
return &a
}逃逸分析会发现a会在函数外面使用,它就会被分配在堆上。而由于 GC 机制,它也会正确地自动回收。
Java 也有逃逸分析,为什么不能这样做呢?有以下几个原因:
- 一方面,Java 没有指针,什么类型是值传递,什么类型是引用传递,在 Java 中是定死的。
- 另一方面,Java 的逃逸分析和 Go 不一样,它只是会分析那些本该分配在堆上的
new出来的对象,如果该对象只在函数内部使用,那分配在堆上就有些浪费了,JVM 会做优化,将其打散分配在栈上,离开函数时自动就释放了。这是 JVM 的优化手段,不是语言规范保证的特性。 - 最重要的一方面是,一个语言的规范从一开始就定死了,很多 JVM 底层能够运行都建立在这个规范的基础上。语言的设计者如果修改以前的规范,可能会导致底层出现大问题。
接下来我们聊一聊类(Class)和对象(Object),这是面向对象编程(OOP)中很重要的概念。当然了,下面的内容和上文的关系非常紧密,一定要先将上文理解通透。
在 Java 中,我们可以这样声明类,以及类里面的域(Field)和方法(Method):
public class Person {
private int age = 0;
public void grow() {
age++; // 长大了一岁
}
public int getAge() {
return age;
}
}这段代码很简单,就不详细讲解了。
JavaScript 语言我们肯定不陌生,现代浏览器都支持 JavaScript 语言。换句话说,浏览器就是 JavaScript 语言的一种运行环境。
早期的 JavaScript 语言没有类的概念。为了写类似上面的那一段简单的 Java 代码,程序员需要构造非常复杂的原型(prototype)链,这是一个令人诟病的地方。于是,聪明的程序员们想到了另外一个写法:
function createPerson() {
let age = 0;
const grow = function() {
age++;
};
const getAge = function() {
return age;
};
return { grow, getAge };
}如果你没系统性地学过 JavaScript,对上述代码肯定很陌生。但实际上,它和上面的 Java 代码的Person类实现了完全一样的功能。如果你理解了上文中所说的逃逸分析,你就会发现age在这里由于会被传入其它函数中,因此在这个例子上它会“逃逸”,只能分配在堆中。这样,createPerson函数就会返回一个对象,对象有两个方法grow和getAge,这就和上面的 Java 代码想要表示的内容一模一样了。
那么 Go 能不能这样写呢?当然可以:
func createPerson() (func(), func()) {
age := 0
grow := func() {
age++
}
getAge := func() int {
return age
}
return grow, getAge
}根据我们上文的知识,Go 这样写当然没有问题。但为什么 Go 很少有人这么写呢?因为 Go 的语言设计还是更建议使用struct并声明方法的方式,也就是在一定程度上类似 Java 中的类。
归根结底还是因为早期的 JavaScript 没有类的概念。现代的 JavaScript 有了类的概念,官方也建议大家优先使用类,但由于常年累月的积累,非常多的大型框架仍然使用了上面的那种写法。
回到文章的开头,为什么不同语言的范式相差那么多,想必大家应该也有一个感性的认识了。这就是编程神奇的地方。
