List中能装各种不同类型的数据。
List v = new ArrayList();
v.add("Hello");
v.add("World");
使用装箱的语言示例。C(void*)、Go(interface{})、无泛型的Java(Object)、无泛型的Objective-C(id)。
基础装箱的问题:
String s = (String) v.get(0);
v.add(1);
v.add(true);
为了解决基础装箱的2个问题,引入了泛型,但是运行时仍然和以前一样完全使用基本装箱方法。这种方法通常被称为类型擦除,因为类型系统中的类型都被"擦除"了,都变成了同一类型(比如Object)。
List v = new ArrayList();
v.add("test"); // A String that cannot be cast to an Integer
Integer i = (Integer)v.get(0); // Run time error
List v = new ArrayList();
v.add("test");
Integer i = v.get(0); // (type error) compilation-time error
基础装箱方法的另一个限制是,装箱类型是完全不透明的。这对于堆栈这样的数据结构来说是没有问题的,但是像通用排序函数这样的功能就需要一些额外的函数,比如特定类型的比较函数。那么怎么来实现这些指定函数,通常有2种方案:虚方法表(vtables)和字典传递。
如果我们想暴露类型特化的函数,同时又要坚持装箱策略,那么我们只要确保有统一的方法可以从对象中找到给定类型的函数就可以了。这种方法叫做 “vtables”(由 "虚拟方法表 "缩写而来),它的实现方式是,在通用结构中的每个对象的偏移量为0的地方,都有一个指向函数指针表的指针。这些表通过在固定的偏移量处索引某些指针,让通用代码以同样的方式为每个类型查找特定类型的函数指针。
这就是Go中接口类型的实现方式,以及Rust中dyn trait对象的实现方式。当你把一个类型转换为一个接口类型时,它会创建一个包装器,这个包装器包含一个指向原始对象的指针和一个指向该接口特定类型函数的vtable的指针。然而这需要额外的指针和内存,这也是为什么Go中的排序需要切片实现Sort.Interface接口,而非切片元素实现Comparable接口。
Go和Java实现可排序数组的方式:
Go
package sort
type Interface interface {
Len() int // Len 为集合内元素的总数
Less(i, j int) bool //如果index为i的元素小于index为j的元素,则返回true,否则返回false
Swap(i, j int) // Swap 交换索引为 i 和 j 的元素
}
//定义interface{},并实现sort.Interface接口的三个方法
type IntSlice []int
func (c IntSlice) Len() int {
return len(c)
}
func (c IntSlice) Swap(i, j int) {
c[i], c[j] = c[j], c[i]
}
func (c IntSlice) Less(i, j int) bool {
return c[i] < c[j]
}
func main() {
a := IntSlice{1, 3, 5, 7, 2}
b := []float64{1.1, 2.3, 5.3, 3.4}
c := []int{1, 3, 5, 4, 2}
fmt.Println(sort.IsSorted(a)) //false
if !sort.IsSorted(a) {
sort.Sort(a)
}
if !sort.Float64sAreSorted(b) {
sort.Float64s(b)
}
if !sort.IntsAreSorted(c) {
sort.Ints(c)
}
fmt.Println(a)//[1 2 3 5 7]
fmt.Println(b)//[1.1 2.3 3.4 5.3]
fmt.Println(c)// [1 2 3 4 5]
}
Java:
class Simpson implements Comparable {
String name;
Simpson(String name) {
this.name = name;
}
@Override
public int compareTo(Simpson simpson) {
return this.name.compareTo(simpson.name);
}
}
public class SimpsonSorting {
public static void main(String... sortingWithList) {
List simpsons = new ArrayList<>();
simpsons.add(new SimpsonCharacter("Homer "));
simpsons.add(new SimpsonCharacter("Marge "));
simpsons.add(new SimpsonCharacter("Bart "));
simpsons.add(new SimpsonCharacter("Lisa "));
Collections.sort(simpsons);
simpsons.stream().map(s -> s.name).forEach(System.out::print);
Collections.reverse(simpsons);
simpsons.stream().forEach(System.out::print);
}
}
面向对象编程语言很好地利用了vtables.例如java,不需要独立的包含vtables的接口对象,而是在每个对象的开头有一个vtable指针。类似 java的语言有继承和接口系统,完全可以用vtables来实现。vtables除了能提供额外功能外,还解决了之前需要构造新的接口类型的问题。
一旦有了vtables,就可以让编译器也生成其他类型信息,如字段名、类型和位置,这些都不困难。这样就可以用同样的代码访问一个类型中的所有数据,而这些代码也可以检查其他任何类型中的数据,这就是“反射”功能。它可以用来实现任意数据类型的序列化功能。作为装箱范式的扩展,它有同样的问题,即它只需要一份代码,但是需要大量动态查找,就会导致反射操作的性能很低。
具有反射功能的语言以及将其用于序列化的例子包括Java、C#、Go、Object-C
Swift也有反射,不过swift的反射是只读的,所以称为自省.
https://juejin.cn/post/6933007157247868941
反射很强大,可以完成很多不同的元编程任务(修改对象的属性、直接调用对象的方法、动态代理),但是有一点它不能做,那就是创建新的类型或者编辑现有字段的类型信息。如果通过反射来增加了这样的能力,最终就会得到动态类型语言。譬如Python、Ruby、JS
除了将vtables与对象关联起来,实现动态接口的另一种方式是将所需的函数指针表传递给需要他们的通用函数。这种方法在某种程度上类似于在调用时构造Go式的接口对象,只是将函数指针表作为一个隐藏的参数传递,而不是作为现有的参数之一打包在一起。
Swift的泛型实现更为有趣,通过使用字典传递,同时把类型的大小以及如何移动、复制、释放放到函数指针表中(VWT),该表可以提供所需的信息,以同一的方式处理任何类型、不需要装箱。这样一来,Swift就可以在没有单态化的情况下实现泛型,也不需要把所有的类型都使用统一的表达。虽然仍存在动态查找成本,然而也节省了分配内存、内存和 缓存不连贯的成本 。并且还能通过通过编译器优化进行函数的单态化处理。
还有一种为装箱类型实现接口的方法是在对象的固定部分添加类型ID,就像vtable指针会访问的位置,然后为每个接口方法生成函数,在所有实现该接口方法的类型上有一个大的switch语句,并派发到正确的特定类型方法。
interface A {
func add()
func minus()
}
class Foo : A {}
class Bar:A {}
interface A {
switch ID:
case identify_A:
call A.add();
case identify_B:
call B.add();
func add();
}
暂时没有语言使用这种技术,但是C++编译器和Java虚拟机在使用profile-guided优化来了解某个通用调用点主要作用于某些类型的对象时,会做类似的事情。他们会对每个通用类型检查以代替调用点,然后对该通用类型进行静态调度,通常的动态调度作为后备情况。这样分支预测器就可以预测出将采取的通用情况分支,并通过静态调用继续调度指令。
另一种泛型的实现方法时单态化。在这种方式中,需要找到某种方法来为每种类型输出多个版本的代码。编译器在编译时,代码会经过多个表达阶段,理论上我们可以在其中任何一个阶段进行复制。
单态化最简单的方法就是在源代码层面进行复制。这样编译器甚至不需要支持泛型,C和Go(编译器不支持泛型)等语言的用户通常会这样做。
在C语言中,可以使用预处理程序,在宏或头文件中定义数据结构,并多次包含defines,在Go中,可以用genny脚本来简化生成代码的工作。
这样做的缺点是,复制源代码需要很多弊端和边缘情况需要考虑,对基本相同的代码进行多次解析和类型检查也给编译器带来很多额外的工作。其次,根据语言和工具的不同,这种泛型方法写起来和用起来都很丑。
c使用宏来实现泛型
//宏定义实现泛型
//在宏定义中出现#和##,通常起到下面的作用:
// #表示:对应变量字符串化
// ##表示:把宏参数名与宏定义代码序列中的标识符连接在一起,形成一个新的标识符
#define GNERIC_STACK(STACK_TYPE,SUFFIX,STACK_SIZE) \
static STACK_TYPE stack##SUFFIX[STACK_SIZE]; \
static int top_element##SUFFIX=-1; \
bool is_empty##SUFFIX(){ \
return top_element##SUFFIX==-1; \
} \
\
bool is_full##SUFFIX(){ \
return top_element##SUFFIX==STACK_SIZE-1; \
} \
\
void push##SUFFIX(STACK_TYPE val){ \
assert(!is_full##SUFFIX()); \
top_element##SUFFIX+=1; \
stack##SUFFIX[top_element##SUFFIX]=val; \
} \
\
void pop##SUFFIX(){ \
assert(!is_empty##SUFFIX()); \
top_element##SUFFIX-=1; \
} \
\
STACK_TYPE top##SUFFIX(){ \
assert(!is_empty##SUFFIX()); \
return stack##SUFFIX[top_element##SUFFIX]; \
}
GNERIC_STACK(int,_int,10)//##起到连接作用,比如is_empty##SUFFIX(),SUFFIX为_int,即最后生成is_empty_int()
GNERIC_STACK(double,_double,10)//同上
void test(){
push_int(5);
push_int(10);
push_int(22);
push_double(22.2);
push_double(-33.3);
push_double(-45.4);
……
}
int main() {
test();
return 0;
}
源代码生成有个好处,可以用全能的编程语言来生成代码,而且使用的是用户已经熟悉的方法。
一些以其他方式实现泛型功能的语言也包含了一种干净的代码生成方式,以解决其泛型系统没有涵盖的更一般的元编程用例。最明显的例子是D 语言的string mixin,它可以在编译中间使用D的所有功能将D代码生成为字符串。
import std.stdio;
string print(string s)
{
return `writeln("` ~ s ~ `");`;
}
void main()
{
mixin (print("str1"));
mixin (print("str2"));
}
输出为:
str1
str2
Rust中的过程宏,将token(编译器解析的源代码)作为输入,输出token流,并且内置了可用于进行token和string转换的工具。使用token的方式来保留源代码文件信息,这样如果宏生成的代码出现了编译错误,就可以很方便地定位到导致编译错误产生的源文件及代码行数。(直接通过源代码拷贝来生成代码,当出现编译错误时,是无法定位到错误的具体位置的)
// 函数式宏
#[proc_macro]
pub fn make_hello(item: TokenStream) -> TokenStream {
let name = item.to_string();
let hell = "Hello ".to_string() + name.as_ref();
let fn_name =
"fn hello_".to_string() + name.as_ref() + "(){ println!(\"" + hell.as_ref() + "\"); }";//通过字符串来声明和定义一个函数
fn_name.parse().unwrap()
}
make_hello!(world);
make_hello!(张三);
fn main() {
// 使用make_hello生成
hello_world();
hello_张三();
}
编译输出:
Hello world
Hello 张三
有些语言确实更进一步(D string、Rust中都是通过字符串来生成代码),提供了在宏中消费和产生抽象语法树(AST)类型的功能。这方面的例子包括模板Haskell、Nim macros、OCaml PPX和几乎所有的Lisps。
AST宏的问题是,你不希望用户学习一堆构造AST类型的函数。Lisp系列语言解决了这个问题,其语法和AST有非常直接的对应关系,但构造过程仍然会很繁琐。因此,我提到的所有语言都有某种形式的 "引用 "原语,你在语言中提供一个代码片段,它就会返回语法树。这些引用原语也提供方法来拼接语法树的值,就像字符串拼接一样。下面是模板Haskell中的一个例子。
-- using AST construction functions
genFn :: Name -> Q Exp
genFn f = do
x <- newName "x"
lamE [varP x] (appE (varE f) (varE x))
-- using quotation with $() for splicing
genFn' :: Name -> Q Exp
genFn' f = [| \x -> $(varE f) x |]
在语法树级别而不是token级别做过程宏的一个缺点是,语法树类型经常会随着新的语言特性增加而改变,而token类型可以保持兼容。
下一种泛型的实现方式,是把生成代码推进到编译的下一阶段。在C++和D中使用这种方式,可以在类型和函数上指定模板参数,当实例化一个特定类型的模板时,该类型会被替换到函数或类中,然后进行类型检查,以确保组合是有效的。
template T myMax(T a, T b) {
return (a>b?a:b);
}
template struct Pair {
T values[2];
};
int main() {
myMax(5, 6);
Pair p { {5,6} };
// This would give us a compile error inside myMax
// about Pair being an invalid operand to `>`:
// myMax(p, p);
}
模板的问题在于,如果库中包含一个带有模板类型的函数,当用户用错误的类型来实例化它,那么编译错误时很难理解。这与动态语言处理用户可能传递错误类型很相似。D语言有一个非常有趣的解决方式,这也与很多动态语言的解决方案类似:只需使用帮助函数来检查类型是否有效,如果失败,错误类型指向帮助函数。
// We're going to use the isNumeric function in std.traits
import std.traits;
// The `if` is optional (without it you'll get an error inside like C++)
// The `if` is also included in docs and participates in overloading!
T myMax(T)(T a, T b) if(isNumeric!T) {
return (a>b?a:b);
}
struct Pair(T) {
T[2] values;
}
void main() {
myMax(5, 6);
Pair!int p = {[5,6]};
// This would give a compile error saying that `(Pair!int, Pair!int)`
// doesn't match the available instance `myMax(T a, T b) if(isNumeric!T)`:
// myMax(p, p);
}
C++20有一个叫做 "概念(concepts) "的功能,除了设计上更像定义接口和类型约束外,它的作用是一样的.
// This concept tests whether 't + u' is a valid expression
template
concept can_add = requires(T t, U u) { t + u; };
// The function is only a viable candidate if 't + u' is a valid expression
template requires can_add
auto add(T t, U u)
{
return t + u;
}
D的模板有很多扩展,允许你使用编译期函数评估和静态if等功能,可以使模板的行为就像函数一样,在编译时接受一组参数,并返回一个非通用的运行时函数。解释:在编译期,根据传入的模板类型生成特定类型的函数。
还有一些语言把 "泛型只是编译期函数 "的概念更进一步的运行,比如Zig。Zig在编译时和运行时都使用同一种语言,函数根据是否标记为comptime的参数进行区分。Zig语言:由于泛型编程与comptime参数绑定,Zig没有传统的菱形括号<>语法。
Zig的泛型编程
/// Compares two slices and returns whether they are equal.
pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
if (a.len != b.len) return false;
for (a) |item, index| {
if (b[index] != item) return false;
}
return true;
}
下一种类型的单态化泛型,是在类型检查之后,把代码生成的过程再推进一步。上文提到用C++可以像动态类型语言中的获取泛型库函数内的错误类型,这是因为模板参数中基本只有一种类型。所以这就意味着我们可以通过在我们的元级代码中增加类型系统来解决这个问题,并静态检查它们是否支持你使用的操作。这就是泛型在Rust中的工作方式,在语言层面来说也是Swift和Haskell中泛型的工作方式。
在Rust中,你需要在你的类型参数上声明 “trait bounds”,其中trait就像其他语言中的接口一样,声明了类型提供的一系列函数。Rust编译器会检查你的泛型函数的主体是否能与任trait bounds的类型一起工作,也不允许你使用trait bounds没有声明的函数。这样Rust中泛型函数在实例化时,就永远不会在库函数得到编译器错误。编译器也只需要对每个泛型函数进行一次类型检查。
fn my_max(a: T, b: T) -> T {
if a > b { a } else { b }
}
struct Pair {
values: [T; 2],
}
fn main() {
my_max(5,6);
let p: Pair = Pair { values: [5,6] };
// Would give a compile error saying that
// PartialOrd is not implemented for Pair:
// my_max(p,p);
}
在语言层面上,以装箱方式实现的泛型所需要的类型系统和这个十分类似,这也是为什么Rust可以使用同一个类型系统来支持这两种泛型的原因! Rust 2018甚至增加了统一的语法,其中v: &impl SomeTrait参数会被单态化,但v: &dyn SomeTrait参数会使用装箱。这一方式也让Swift的编译器和Haskell的GHC等编译器即使默认使用装箱来实现泛型,也可以单态化作为优化手段。
在Swift中,同样的泛型代码,不同的编译优化参数,会导致编译器选择不同的实现方式。