为什么需要泛型编程?
想象一下,我们在超市买东西,每次排队结账的时候,如果每种商品都要去不同的柜台排队,是不是很麻烦?泛型编程就像是一个“万能收银台”,让不同类型的商品(在这里指的是数据类型)都可以在一个地方处理,大大提高了效率。
在静态语言中,比如C++或Java,我们经常需要对不同类型的数据执行相同的操作。如果为每一种数据类型都编写一遍处理逻辑,比如集合、字典、排序等,不仅会导致代码量大增,而且也不经济。泛型编程的出现,就是为了解决这一问题。
在编程语言的发展历程中,不同语言对范型编程做了各种各样的支持和尝试,下面我们就做个总结介绍。
C语言中的泛型编程尝试
使用void*指针
在C语言中,我们可以用void*指针来实现对不同数据类型的通用处理。就像一把万能钥匙,它可以打开任何锁,但是万能钥匙也有它的问题:
- 没有类型检查:你不会知道这把钥匙究竟打开了哪个锁。
- 处理数组困难:就像万能钥匙难以区分哪些锁是连在一起的。
- 内存操作复杂:需要自己控制内存拷贝和比较,就像万能钥匙需要自己调整形状一样,非常容易出错。
使用宏
宏是C语言中的一种功能,可以让你写一些看起来像是通用代码的东西。它就像是一张能写任何内容的空白支票。但是,宏也有它的缺点:
- 无法做类型检查:这张支票上可以写任何数字,但是并不知道这些数字是否合法。
- 参数为表达式或函数时,宏替换会导致重复执行:就像你不小心在支票上写了两遍数字,钱就会被取走两次。
- 无法调试:当支票出了问题,你也不容易找到问题所在。
数据结构适配问题
在C语言中,想要让泛型编程适配各种数据结构非常复杂,因为每种数据结构在内存分配和释放、对象复制方式上都有所不同。这就像试图制作一个能适应所有人脚型的鞋子,几乎是不可能的。
C语言的例子
这里我提供一个简单的例子,使用void*指针来创建一个泛型的数组排序函数。
首先,我们需要一个比较函数的原型,这个比较函数需要能够比较两个任意类型的元素:
// 返回值为正数、零或负数,分别表示第一个参数大于、等于或小于第二个参数
int compare(const void* a, const void* b);
接下来,我们定义一个冒泡排序函数:
// 泛型冒泡排序函数
void genericBubbleSort(void* array, size_t length, size_t size, int (*compare)(const void*, const void*)) {
char temp[size]; // 临时存储元素的空间,大小等于数组元素的大小
for (size_t i = 0; i < length - 1; ++i) {
for (size_t j = 0; j < length - i - 1; ++j) {
void* a = (char*)array + j * size;
void* b = (char*)array + (j + 1) * size;
if (compare(a, b) > 0) {
// 如果a > b,则交换两个元素
memcpy(temp, a, size);
memcpy(a, b, size);
memcpy(b, temp, size);
}
}
}
}
如果我们要排序一个整数数组,还要定义一个整数比较函数:
int intCompare(const void* a, const void* b) {
int arg1 = *(const int*)a;
int arg2 = *(const int*)b;
if (arg1 < arg2) return -1;
if (arg1 > arg2) return 1;
return 0;
}
最后,我们可以使用这个泛型排序函数来排序任何类型的数组:
int main() {
int intArray[] = {42, 23, 4, 16, 8, 15};
size_t intLength = sizeof(intArray) / sizeof(intArray[0]);
// 使用泛型排序函数和整数比较函数对整数数组进行排序
genericBubbleSort(intArray, intLength, sizeof(intArray[0]), intCompare);
// 打印排序后的数组
for (size_t i = 0; i < intLength; i++) {
printf("%d ", intArray[i]);
}
return 0;
}
C++中泛型编程的实现
泛型编程的典范:STL
C++的标准模板库(STL)是泛型编程的最早实现之一,它提供了算法的泛型、类型的泛型和数据结构的泛型。STL就像是一个工具箱,里面有各种工具,可以处理各种类型的“修理”工作。
类和模板
在C++中,类和模板是实现泛型编程的两大工具:
- 类:通过构造函数、析构函数、拷贝构造函数等标准化内存的分配、释放和复制。类似于给每个数据类型定制了一套“操作手册”。
- 模板:模板允许你编写类型无关的代码,就像填写一份“任何类型都适用”的表格。编译器会根据使用的类型来“填充”这份表格,生成专属代码。
另外C++中还引入了一种迭代器机制,让程序能够以统一的方式遍历各种数据容器,就像是一个通用的“遥控器”,可以控制所有类型的“电视”。
空间换时间
在C++中,泛型编程符合“不要重复自己”(DRY)原则。你只需要写一遍代码,就可以用于多种数据类型。但是编译时,编译器会将这些代码展开,为不同的数据类型生成特定的代码,所以使用范性时编译出的文件可能会增大不少,但是这样执行效率更高。这是一种空间换时间的策略。
这就像是有一个自动化的工厂,输入原材料(类型),然后生产出各种产品(专用代码)。
C++的范性排序例子
这个例子使用模板函数来实现一个简单的插入排序算法。
// 模板函数,实现插入排序
template <typename T>
void insertionSort(std::vector<T>& vec) {
for (size_t i = 1; i < vec.size(); ++i) {
T key = vec[i];
int j = i - 1;
// 将vec[i]插入到已排序的序列vec[0..i-1]中
while (j >= 0 && vec[j] > key) {
vec[j + 1] = vec[j];
--j;
}
vec[j + 1] = key;
}
}
// 辅助函数,用于打印vector中的元素
template <typename T>
void printVector(const std::vector<T>& vec) {
for (const T& val : vec) {
std::cout << val << " ";
}
std::cout << std::endl;
}
int main() {
std::vector<int> intVector = {42, 23, 4, 16, 8, 15};
std::vector<float> floatVector = {3.14, 1.59, 2.65, 3.58};
// 对整数vector进行排序
insertionSort(intVector);
printVector(intVector);
// 对浮点数vector进行排序
insertionSort(floatVector);
printVector(floatVector);
return 0;
}
在这个例子中,我们定义了一个insertionSort函数模板,它接受一个类型为std::vector;的引用,其中T是一个占位符,可以是任何类型。当你调用insertionSort函数时,编译器会根据传入的实际类型自动生成对应的函数实例,一个处理int,另一个处理float。
模板提供了类型安全和代码重用的优势,这是C语言中泛型编程所不具备的。
虚拟机语言中的泛型实现
Java中的泛型
Java对泛型的支持在语言、类型系统和编译器方面有了较大的发展。在Java中,开发者可以使用类型参数化的方式定义类、接口和方法。这让代码更加可读、灵活,并增加了类型安全性,减少了运行时的类型错误。
此外,Java还提供了类型推断的功能,使得在声明变量或方法参数时可以省略类型参数,编译器会自动推断类型参数。
看个例子:
import java.util.Arrays;
import java.util.List;
public class QuickSort {
public static <T extends Comparable<T>> void quickSort(List<T> list) {
quickSort(list, 0, list.size() - 1);
}
private static <T extends Comparable<T>> void quickSort(List<T> list, int left, int right) {
if (left < right) {
int pivotIndex = partition(list, left, right);
quickSort(list, left, pivotIndex - 1);
quickSort(list, pivotIndex + 1, right);
}
}
private static <T extends Comparable<T>> int partition(List<T> list, int left, int right) {
T pivot = list.get(left);
int i = left;
int j = right;
while (i < j) {
while (i < j && list.get(j).compareTo(pivot) >= 0) {
j--;
}
list.set(i, list.get(j));
while (i < j && list.get(i).compareTo(pivot) <= 0) {
i++;
}
list.set(j, list.get(i));
}
list.set(i, pivot);
return i;
}
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(5, 2, 8, 9, 1);
List<String> words = Arrays.asList("Java", "Python", "C++", "JavaScript");
// 对整数列表进行快速排序
quickSort(numbers);
System.out.println(numbers);
// 对字符串列表进行快速排序
quickSort(words);
System.out.println(words);
}
}
Java泛型的局限性
Java 泛型的默认实现方式仍然是类型擦除,也就是说,泛型信息不会被保留到运行时。这种做法就像是在编写脚本时使用了某种特效,但是实际上演时并不会出现这种特效,它只是为了编写时的方便。
实际擦除时,编译器泛型类型参数会被替换为它们的上边界(如果没有指定上边界,则替换为 Object),举个例子:
// 泛型类
public class Box<T> {
private T content;
public void setContent(T content) {
this.content = content;
}
public T getContent() {
return content;
}
}
擦除后,Box会变成:
public class Box {
private Object content;
public void setContent(Object content) {
this.content = content;
}
public Object getContent() {
return content;
}
}
类型擦除导致Java泛型有一些局限性,比如:
- 性能不会提升:因为实际上并没有为不同的基本数据类型生成专用代码。
- 通过反射可以绕过编译器限制:这就像是在舞台背后悄悄改变了道具,观众并不知情。
- 泛型数组的问题:你不能创建泛型数组,因为类型信息在运行时是不可用的,这就像是你不能在没有模具的情况下制作形状各异的糖果。
C#中的泛型
相比Java的语法糖,C#语言在开发、编译、运行时都支持泛型,它提供了真正的泛型支持。
上边Java的代码用C#实现:
using System;
using System.Collections.Generic;
public class QuickSort
{
public static <T> void QuickSort<T>(List<T> list) where T : IComparable<T>
{
QuickSort(list, 0, list.Count - 1);
}
private static <T> void QuickSort<T>(List<T> list, int left, int right) where T : IComparable<T>
{
if (left < right)
{
int pivotIndex = Partition(list, left, right);
QuickSort(list, left, pivotIndex - 1);
QuickSort(list, pivotIndex + 1, right);
}
}
private static <T> int Partition<T>(List<T> list, int left, int right) where T : IComparable<T>
{
T pivot = list[left];
int i = left;
int j = right;
while (i < j)
{
while (i < j && list[j].CompareTo(pivot) >= 0)
{
j--;
}
list[i] = list[j];
while (i < j && list[i].CompareTo(pivot) <= 0)
{
i++;
}
list[j] = list[i];
}
list[i] = pivot;
return i;
}
public static void Main()
{
List<int> numbers = new List<int> { 5, 2, 8, 9, 1 };
List<string> words = new List<string> { "Java", "Python", "C++", "JavaScript" };
// 对整数列表进行快速排序
QuickSort(numbers);
Console.WriteLine(numbers);
// 对字符串列表进行快速排序
QuickSort(words);
Console.WriteLine(words);
}
}
看起来和Java代码差不多,但是编译时不会做泛型擦除。在C#中,泛型可以在开发、编译和运行时提供真正的类型安全,因为编译器会强制检查类型参数的一致性。
C#泛型的展开逻辑
但是我们要清楚计算机执行的机器码中可没有泛型,泛型代码最终还是要展开为具体的类型,并翻译为相应的机器码。在.NET平台,这件事是虚拟机干的。针对int、double等基本数据类型,虚拟机会为每一种类型生成具体的算法程序实例,也就是说针对使用了基本数据类型的泛型代码,用了多少种基本数据类型,就会有多少种实例,这样处理更高效,因为值类型不需要装箱和拆箱。对于引用类型,虚拟机只会生成一个统一的算法程序实例,所有的引用类型共享这个实例,这样比较节省内存空间,也减少了即时编译的开销。
Go语言中的泛型
Go是在1.18版本开始支持泛型的,在此之前一直通过 interface{} 实现类似泛型的能力,不过它需要在运行时进行断言判断,还有类型转换的开销,代码上也不够优雅。
Go语言中的泛型是通过类型参数实现的。类型参数定义在函数或类型(如结构体、接口、切片等)之上,允许在声明时不指定具体的类型,而是在使用时指定。
举个例子:
package main
import "fmt"
// 泛型函数,用于交换两个值
func Swap[T any](a, b T) (T, T) {
return b, a
}
func main() {
// 使用泛型函数
a, b := 42, "Hello"
swappedA, swappedB := Swap(a, b)
fmt.Printf("Original: %d, %s\n", a, b)
fmt.Printf("Swapped: %d, %s\n", swappedA, swappedB)
}
Go是完全的静态语言,虽然有垃圾回收,但是没有虚拟机。Go中的泛型实例是在编译时展开的,类似C#的泛型实例化结果,对于每个不同的值类型,编译器会生成一个独立的泛型实例,对于引用类型的泛型实例,只需要生成一次,然后在需要时进行复制或传递引用,这样可以节省内存并避免重复的实例化开销。
动态语言中的泛型
完全动态语言(如JavaScript和Python)由于其类型系统的特性,对泛型的支持与静态类型语言(如C#和Java)有所不同。在动态语言中,变量通常不需要在编译时声明其类型,因此,函数和数据结构可以更自然地处理不同类型的数据,而无需显式的泛型机制。
JavaScript本身并不支持泛型,但是它的超集TypeScript是支持泛型的,而且还支持接口。
// 定义一个泛型函数,接受一个参数并返回与参数相同类型的数组
function identity<T>(arg: T): T {
return arg;
}
// 使用泛型函数
let resultString: string = identity("Hello, TypeScript!");
let resultNumber: number = identity(42);
console.log(resultString); // 输出: Hello, TypeScript!
console.log(resultNumber); // 输出: 42
Python3.5之后,引入了类型提示(Type Hints)和typing模块,可以用来指示函数预期接受和返回的类型,这为Python增加了一种类似泛型的能力。看一下Python的例子:
from typing import List, TypeVar
T = TypeVar('T') # 声明一个类型变量
def print_list(items: List[T]) -> None:
for item in items:
print(item)
# 可以传入任意类型的列表
print_list([1, 2, 3]) # 输出: 1 2 3
print_list(['apple', 'banana', 'cherry']) # 输出: apple banana cherry
在这个例子中,我们使用了TypeVar来定义一个类型变量T,然后使用 List[T] 指示 print_list 接受任何类型的列表。这样的类型提示让我们的代码更可读、可维护,同时也支持类型检查器(如mypy)对代码进行静态类型检查。
总结下,在动态语言中选择性地使用泛型,可以获得更多的类型安全和可维护性。
结论
泛型编程让我们可以用一套代码来处理多种数据类型,它提高了代码的复用性,减少了重复劳动。每种语言实现泛型的方式各有特色,但都是为了让编程更加高效和简洁。不过,每种实现都有其优缺点,了解这些可以帮助我们更好地选择和使用泛型编程。
关注萤火架构,提升技术不迷路!