为什么需要泛型编程?

想象一下,我们在超市买东西,每次排队结账的时候,如果每种商品都要去不同的柜台排队,是不是很麻烦?泛型编程就像是一个“万能收银台”,让不同类型的商品(在这里指的是数据类型)都可以在一个地方处理,大大提高了效率。

在静态语言中,比如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>;的引用,其中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); // 输出: [1, 2, 5, 8, 9]
        // 对字符串列表进行快速排序
        quickSort(words);
        System.out.println(words); // 输出: [JavaScript, Java, C++, Python]
    }
}
Java泛型的局限性

Java 泛型的默认实现方式仍然是类型擦除,也就是说,泛型信息不会被保留到运行时。这种做法就像是在编写脚本时使用了某种特效,但是实际上演时并不会出现这种特效,它只是为了编写时的方便。

实际擦除时,编译器泛型类型参数会被替换为它们的上边界(如果没有指定上边界,则替换为 Object),举个例子:

// 泛型类
public class Box<T> {
    private T content;

    public void setContent(T content) {
        this.content = content;
    }

    public T getContent() {
        return content;
    }
}

擦除后,Box<T>会变成:

public class Box {
    private Object content;

    public void setContent(Object content) {
        this.content = content;
    }

    public Object getContent() {
        return content;
    }
}

类型擦除导致Java泛型有一些局限性,比如:

  • 性能不会提升:因为实际上并没有为不同的基本数据类型生成专用代码。
  • 通过反射可以绕过编译器限制:这就像是在舞台背后悄悄改变了道具,观众并不知情。
  • 泛型数组的问题:你不能创建泛型数组,因为类型信息在运行时是不可用的,这就像是你不能在没有模具的情况下制作形状各异的糖果。

Java 泛型是在 Java 5 中引入的,为了确保新版本的 Java 能够与之前版本的代码兼容,Java 泛型采用了类型擦除的机制。怎么考虑兼容性问题呢?

使用类型擦除后,泛型信息不会保留在编译的字节码中,因此使用泛型的 Java 5 或更高版本编译的代码能够在没有泛型支持的旧版本 JVM 上运行;如果采用类似C++的方式,类文件格式、运行时类型检查、类加载器在旧版本的JVM中都无法处理好,必须使用新版JVM才行。

再则不使用类型擦除是,现有的类库或API使用泛型后,会迫使所有使用这些API的代码也必须更新以适应泛型,不更新则可能会产生一些方法签名或者方法重载上的冲突。这个更新是一个繁琐的过程,可能会引入大量的错误。

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); // 输出: [1, 2, 5, 8, 9]
        // 对字符串列表进行快速排序
        QuickSort(words);
        Console.WriteLine(words); // 输出: [JavaScript, Java, C++, Python]
    }
}

看起来和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)对代码进行静态类型检查。

总结下,在动态语言中选择性地使用泛型,可以获得更多的类型安全和可维护性。

结论

泛型编程让我们可以用一套代码来处理多种数据类型,它提高了代码的复用性,减少了重复劳动。每种语言实现泛型的方式各有特色,但都是为了让编程更加高效和简洁。不过,每种实现都有其优缺点,了解这些可以帮助我们更好地选择和使用泛型编程。

关注萤火架构,加速技术提升!

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐