关于“具名类型值向空接口赋值策略”的讨论

关于“具名类型值向空接口赋值策略”的讨论

本议题采用 实时线上会议 / 论坛回帖 结合的模式,可直接回复本贴或参与线上会议讨论,线上会议时间为 2023年5月13日20点至21点,会议视频:

以下为正文:


背景知识:接口的一些实现细节

  1. 从概念上看,一个接口值(接口类型的实例)主要由两部分组成:具体类型、以及该具体类型的值;其中,具体类型值保存在接口值内 data 指针地址处;

  2. 将一个具体类型的值向接口赋值时,执行的是传值复制,既分配一段存储该具体类型的内存,地址存入接口值的 data 处,然后将具体类型值拷贝至其中。


背景知识:凹语言中具名类型方法(Method)的定义

下面是一个凹语言中为具名类型定义方法并使用的例子:

type T struct {
	member: int
}

func T.Set(n int) {
	this.member = n
}

func T.Print() {
	println("member:", this.member)
}

func main() {
	var v T
	v.Set(42)
	v.Print()
}

其中 func T.Set(){...}func T.Print(){...} 分别为类型 T 定义了名为 SetPrint 的方法。熟悉 Go 语言的朋友很容易看出,凹语言中的方法定义更接近C++风格,与 Go 相比有两个显著的不同:

  1. 方法函数都可以改变类型实例的内部状态。如果套用 Go 的术语,可以理解为:凹语言方法没有值类型接收器(receiver),只有指针型接收器;

  2. 在方法内部,使用 this. 选择器访问类型实例的成员。

由于方法接收器只能为指针,因此凹语言中的值类型只能实现空接口,而不能实现非空接口。例如:

type T struct {
	member: int
}

func T.Set(n int) {
	this.member = n
}

func T.Print() {
	println("member:", this.member)
}

type Printer interface {
	Print()
}

func main() {
	var v T
	v.Set(42)
	
	var p Printer
	p = &v
	p.Print()

	p = v  //Invalid
}

在上例中,p = &v 可以赋值,p.Print() 执行正常;但 p = v 编译不过,因为类型 T 的值类型不满足 Printer 接口定义。


具名类型值向空接口赋值的两种策略

为什么要将讨论对象限定为“具名类型值向空接口赋值”呢?因为根据前述介绍,在凹语言中,值类型无法实现非空接口,因此其只能向空接口赋值。

策略1:严格遵守原始语义,使用值拷贝,向空接口传入的是值,取回时拿到的也是值;

策略2:向空接口中传入具名类型值(非指针)时,拷贝一份,但是实际存入空接口中的是该拷贝的地址,并且标记为指针;也就是说传入时是值,但取回时是地址(该地址是复制品的地址,因此不会影响原始父本)。下面的例子可以更直观的解释该策略:

type T struct {}

func T.Print() { println("This is T.") }

type Printer interface {
	Print()
}

func main() {
	var v1 T
	var i1 interface{} = v1

	var v2 *T = i1.(*T)  //This will work
	v2.Print()
	var i2 Printer = i1.(Printer)  //This will work too
	i2.Print()
}

在该策略下,v1 赋值给了空接口 i1,但 i1 实际保存的类型为 *T,后续可以通过 i1.(*T) 将其取出,并且由于 *T 满足 Printer 接口定义,i1 甚至可以转换类型为 Printer

注意,该代码仅为展示策略2之用,目前的凹编译器并未实现该策略。

由于凹语言中不存在值类型接收器,因此如果严格遵守原始语义,将值类型赋值给空接口只能起到数据包装的作用——即使该类型实现了非空接口,也无法直接访问。

在策略2中,将值类型赋值给空接口时,实际上改变了类型(由 T 变为 *T),这使得接口“升格”。但缺点是改变了语义,可能导致与 Go 的不兼容性。

2 个赞

请问凹语言设计是如何对 Go 进行兼容呢?

策略2可能有歧义:i1.(*T)i1.(T) 都满足,那么 i1 的值是什么类型?

目前凹的语法特性是Go真子集,并且语义与其一致

1 个赞

不会有这种情况,因为(T)不满足任何非空接口。
将T赋值给i1时,策略2实际上干的事情是:拷贝一份,并将复制品的地址(*T)赋值给i1。
所以在策略2下,接口内部保存的永远是(*T)

  1. 如果要兼容GO语法,应当传什么类型,取出来就是什么类型。
    var i interface{} = T; ==> var t = p.(T);

  2. 如果传值,而取出来的是指针,会给人一种误导,可以直接修改原值(实际上和原值没关系)。
    因为直接传指针的情况,取出来也是指针,但是可以修改原值

  3. 如果不考虑interface{}可以容纳任何类型的规范,我更建议将接口定义为’方法集合的引用’。
    即非空接口和空接口赋值都采用指针,在使用的体验上更加一致。
    而且无论传值还是传指针,实际上都会触发内存逃逸,但传指针可以减少内存复制情况

3很有建设性,线上会议详谈?

嗯,到时候会参加的

我的建议是重新定义接口:接口只允许存放指针,即接口变量赋值时只接受指针类型。对于字面量和常量,可以用一个语法糖来生成一个临时变量的地址。

// 空接口
var i interface{}

// 普通变量
var a: int = 1
i = &a // OK
i = a // Error:接口不能直接赋值,只接受指针

// 复合类型
var b: T = T{...} 
i = &b // OK
i = b // Error: 接口不能直接赋值

// 指针类型
var c: &T = &T{....}
i = c // OK:c本身就是指针
i = &c // OK。但需要注意,这时候i的类型是 **T,即指针的指针

// 字面量
i = 123 // Error:123不是指针

t := 123 // 临时变量
i = &t // 再取地址

// 语法糖:&符号。
i = &123 // 等价于 { t :=123; i = &t; }

const PI = 3.14 // 写在main函数外
i = PI // Error
i = &PI // OK

就线上会议和回复的情况来看,原帖中的策略2大家都认为问题比较多,尤其是存入和取出的类型不一致容易产生歧义。剩下的争论焦点就一个:是否允许向接口传递非指针?

其实我们忽略了一个关键:指针是不是值?柴树杉提到的“可以参考函数传值调用的方式理解向接口赋值”说到了问题的根本,即:传值是更通用、普适的方式。

通过接口传送简单类型值、字面量等“非指针值”的需求是实际存在的,因此:

后端、运行时应该保持“向接口赋任何类型值”的能力,并针对“向非空接口所赋的值一定为指针”这一特性进行优化

前端语法部分,是否允许向空接口传递非指针,以及如何解决由此引发的副作用,维持开放状态,可以继续讨论。

刚听会议。为使更多人了解此语言特性的目的,可否以简单例子演示此特性的必需性?即给出一个具体任务,必须用此语言特性来解决或者相对其他方法有明显优势。再从达成共识的需求出发进行分析也许更有的放矢。

是的,所以会上最后我说如果对于语义有新的提议,回帖的时候最好附带该提议对应的代码及行为解释。赵普明最新的提议就是按这个来的。

有没有可能从功能出发逐步分析呢?

  • 为何需要接口(可引用其他语言的功能说明)
  • 为何需要空接口
  • 为何需要具名类型值

这样讲的话,光背景知识的篇幅就需要类似于Go圣经第七章(接口)那么长。

凹语言 大部分语法特性的“初始值”都来自于 Go 中的对应物。讨论某个具体语法语义时,关注的是它与原始语法语义(既Go 中对应物)的区别,所以一些背景知识和上下文会被省略掉。

了解。我试着把参考资料列一下,请指正。

接口类型是对其它类型行为的抽象和概括
它不会暴露出它所代表的对象的内部值的结构和这个对象支持的基础操作的集合;它们只会表现出它们自己的方法。也就是说当你有看到一个接口类型的值时,你不知道它是什么,唯一知道的就是可以通过它的方法来做什么。

  • 为何需要空接口。在上面书中未搜到“空接口”。从“(接口类型)只会表现出它们自己的方法”看,Go 设计时空接口也许是个未被重视的边缘情况。如果它的语义是“任意类型”,根据上述思路接口应该包含“任意类型的方法”,而不是不包含任何方法。从 Go 1.18 开始可用 any 代替空接口看,空接口的必要性也许值得商榷。

  • 为何需要类型:类型的语义和功能见 上面书中此节

变量或表达式的类型定义了对应存储值的属性特征,例如数值在内存的存储大小(或者是元素的bit个数),它们在内部是如何表达的,是否支持一些操作符,以及它们自己关联的方法集等。

与空接口类似,同样允许不包含方法的“空”类?

  • 为何需要具名类型:与“具名”相对的,不知有“匿名类型”吗?
  • 为何需要具名类型值?
  • 为何需要具名类型值向空接口赋值?

这里有有些术语是针对语言开发者,或者本身就是 Go 的术语,比如:

“空接口”指 interface{},它是最重要的接口:它是万能的数据包装器,也是所有接口的超集;

k := struct {name: string; age: i32}{"李四", 66} 中,k 是一个匿名结构体的值,或者说变量,当谈及语言实现时,常使用“值”这个术语,因为“值”既 Value 对象与 AST 中的某个结点有对应关系。

面向用户的文档肯定不会是这种风格。