zl程序教程

您现在的位置是:首页 >  其他

当前栏目

本立道生,Go interface背后的对象模型

2023-04-18 12:54:46 时间

不知不觉间已经写了十来篇推文了。二哥花了不少篇幅剖析容器网络的底层信息,也拿Cilium做例子介绍了CNI是什么,它是如何配合K8s完成容器网络相关的配置和维护的。

为什么要花这么多篇来介绍网络呢?其实答案很简单,因为它非常非常重要,从K8s三大规范CRI、CNI、CSI就可以看出来它的地位:三足其一啊。单单理解计算机组网技术就已经比较困难了,更何况我们现在要研究的是在它的基础上建立起来的容器网络。

刘超在《趣谈网络协议》开篇词中说:“集群规模一大,我们首先想到的就是网络互通的问题;应用吞吐量压不上去,我们首先想到的也是网络互通的问题。不客气地讲,很多情况下,只要搞定了网络,一个大型系统也就搞定了一半。” 然!

不过二哥也不能一直唠叨网络,毕竟我肚子里也就那么点墨水。但以后我会间歇性地穿插一些网络相关的文章,嗯,有机会多学了一些之后,总想上来卖弄一番。

今天这篇我们聊聊Go语言 interface背后的数据模型。Go之于K8s的意义如同C之于Unix,了解Go,会用Go才能比较畅快地游弋于云原生的海洋,毕竟这里的炙热项目大都由Go写成。Go真是从它的若干前辈语言们那里学习了很多东西,既有C的短小、直接、精悍,又有C++的抽象、多态。

Linus说他不喜欢C++,一部分原因是C++编译器偷偷地在目标代码里干了很多事情以完成C++的诸如虚拟继承、多重继承等OO相关的语意,这使得大神觉得这种行为超出了他的控制,他喜欢从C代码一眼看穿汇编代码的那种直接,而不是编译器自动生成额外代码所引起的雾里看花。很不幸,从这个角度来说,Linus得失望了,因为Go也有这个特(毛)点(病):Go也为它的interface语意在编译的时候自动加入了额外的代码。

回忆起来,二哥当时学习Go语言速度非常的快。毕竟久经C和C++的蹂躏,学习新语法,再用它来写代码可谓驾轻就熟。但有几个问题一直困扰着我:类型断言和反射到底是如何实现的?interface value到底包含了什么?本文来聊聊这方面的内容,希望你看完后和顿悟之前的我有一样的感觉:看前咬紧笔头,困惑不已。看后恍然大悟,频频点头以示认同。同时又嘲笑自己,为何之前总是无法参透其中的奥秘?

关于对象模型

在聊Go interface之前,我们先聊下对象模型。本立而道生,欲求正道,当先务本,在编程语言领域,对象模型是本,而由此展开的各种语意则为道。

语法(grammar)是关于语言的结构或语法。它需要回答问题:如何构造一个有效的句子?所有语言,包括人类(也称为“自然”)语言都有语法,即规定句子是否被正确构造的规则。 语意(semantics,也译作语义)是关于句子的意思。它需要回答问题:这句话有效吗?这句话是什么意思?

这里所说的对象,是object。我们谈到object的时候,一般说的是一段数据。什么是对象模型呢?我们可以简单地把它理解为数据的布局和结构。实际上建立数据模型的目的不单单是为了结构化展示和存储数据,围绕着数据模型,往往包含有额外的自洽性理论和基于这个模型而展开来的应用。

class Point
{
  public:
    Point(float xval);
    virtual ~Point();
    float x() const;
    static int PointCount();
  protected:
    virtual ostream&
    print(ostream &os) const;
    float _x;
    static int _point_count;
};

示例 1:包含五种member类型的一个C++ class定义

布局是需要仔细设计和反复斟酌的。举个例子,我们知道在C++中对于class,有两种data member: statci和非static,以及三种member function:static,非static和virtual,如上面的示例所示。

当这5种类型的member同时出现在一个class Point中。Point的一个实例在内存中该如何布局呢?或者说,我们该如何巧妙地在内存里布局static和非static这两种data member使得它既能完成数据存/取这个基本的任务,又能很好地配合C++语言完成封装、多态(polymorphism)、多重(multiple)继承、虚拟(virtual)继承等设计目标?

而改变布局是有成本的。这对于程序语言设计本身尤为明显,尤其是当语言自身的实现需要与数据绑定的时候。布局确定下来后,基本上语言的设计只能围绕这个布局来展开。

例如为了实现多态,我们知道需要需要在类的实例里面留有一个叫vptr(virtual pointer)以指向虚函数表(virtual function table)。但随之而来的问题是:vptr该放在object的哪里?开头还是末尾?

struct no_virts{
  int d1, d2;
}
class has_virts: public no_virts
{
  public:
    virtual void foo();
     // ... 
  private:
    int d3;
};

no_virts * p = new has_virts;

示例 2:简单的多态

图 1:相同的代码,不同的vptr 摆放位置

答案是都可以,图1展示了两种摆放方式,事实上C++编译器cfront采用的是左边的方式,而微软最初的编译器则使用了右边的方式。不管哪种方式,一旦确定下来了,与此相关的虚函数表的引用方式也就确定了。如在多重虚拟继承场景下,把一个子类对象指定给基类对象的指针或引用时,对于是否需要编译器去调整或修改地址这个问题的答案也就确定了。这些细节不是本文的重点,对细节感兴趣的同学可以私信和我讨论。二哥在此想说的一个观点是:对象模型无论是对程序语言设计还是对应用程序开发,都是至关重要和基础性的。

二哥作为一个老司机无论在开发一个新功能还是重构一个已有功能时,总是想办法先把数据模型定下来。为什么呢?引用Stanley B. Lippman大师(对,也是C++ Primer一书的作者)在《Inside the C++ Object Model》中的一句话来回答:不同的对象模型,会导致“现有的程序代码必须修改”以及“必须加入新的程序代码”两个结果。

Go interface的背后

前面铺垫了那么多对象模型的概念,终于来到了本文的重点部分了。

我们先来看下面一段简单的示例代码。它被注释分成三部分:

  • 定义了一个interface String,包含一个method String(),用来把内容字符串化以便于打印。
  • 定义了一个函数ToString(),它的输入参数是一个any interface{},如果any.(Stringer)类型断言成功地话,则使用method String()输出string,否则进行一般化处理。
  • Binary实现了接口Stringer。
//==========Part 1==========
type Stringer interface {
    String() string
}

func ToString(any interface{}) string {
    if v, ok := any.(Stringer); ok {
        return v.String()
    }
    switch v := any.(type) {
    case int:
        return strconv.Itoa(v)
    case float:
        return strconv.Ftoa(v, 'g', -1)
    }
    return "???"
}

//==========Part 2==========
type Binary uint64

func (i Binary) String() string {
    return strconv.Uitob64(i.Get(), 2)
}

func (i Binary) Get() uint64 {
    return uint64(i)
}

//==========Part 3==========
b := Binary(200)
s := Stringer(b)
fmt.Printf("output: %s", ToString(s))

示例 3:Go interface和对它的简单实现

这真是一段功能简单的代码。在Part 3部分,根据Go语法我们知道s是一个interface类型的变量,同时它的值称为“接口值(interface value)”,包括两部分:动态类型和动态值。我们来思考三个问题:

  • 接口值到底是什么?它是指针吗?如果是的话,指针所指向的那块内存内容是什么?
  • any.(Stringer)这样的类型断言到底是如何实现的?
  • 反射所依赖的底层数据结构到底是什么?

我们借助图2来回答这个问题。interface value代表了一个two-word的结构体,一个word为8个字节(参考Go size长度定义)。结构体分为两部分,tab和data,它俩都是指针,分别对应接口值的动态类型和动态值。

s := Stringer(b)这样的赋值操作背后代表的是在two-word的结构体中设置tab和data。而s = Stringer(Binary(400)) 则表示s会指向一个新产生的two-word结构体。

图 2:interface value对象模型,引用自Russ Cox的“Go Data Structures: Interfaces”一文

two-word结构体的tab部分是一个指针,它指向一个叫interface table(itable,C中叫Itab)的结构体,。itab的开头处是关于与该interface相关的实际类型相关的meta data,由它们可以很方便地推出 s.(type)的取值,如果你使用过Go的类型分支(type switch)的话,此刻需要明白这个功能真正的数据源自这里。

itab接下来的部分是函数(函数地址)列表。这里需要强调的是这个列表只列出interface所定义的method,换句话说,Binary所定义的Get()方法不会出现在这个itable里面,因为interface String没有定义它。

two-word结构体的data部分也是一个指针,它指向实际类型所对应的数据部分。比如这个例子里Binary所需要占据的8个字节内存。

对于s.String()这个代码,我们一定不会陌生,没错,实际上它完成了类似C++多态的功能。说到多态,我们就不可避免地提到一个词:动态分发。

如果你了解CPU所支持的函数调用过程,同时也在下意识里认同C语言的函数调用方式是高级语言函数调用范式的话,就会很想知道下面几个问题的答案:

  • s.String()是如何定位到不同的String()函数实现的?
  • 在func (i Binary) String() 这个函数里,类似uint64(i) (将i.Get()展开来)这样的对i引用,到底是如何知道i的内存地址的?

是的,这两个问题也是支持多态(动态分发)的语言需要回答的问题,对于C++,我们知道它是借助于虚函数表及this指针来实现的。我们来看看Go编译器是如何借助图2这个对象模型来优雅、简单地完成动态分发的。

s.String()的等价C表达式是:s.tab->fun[0](s.data)

结合图2,你一定能明白这个等价表达式在干什么,以及对象模型在这中间所起到的关键作用。我就不赘述了。

类似地,你也一定会大概明白s.(T)s.(type)reflect.TypeOf(s)reflect.ValueOf(s)是如何工作的。

所谓本立而道生,我想应该就是说一旦知道了本质,与之有关的所有现象都能理解了的意思吧。