【问题标题】:How to dis-ambiguate operator definitions between objects/classes in a programming language?如何在编程语言中消除对象/类之间的运算符定义歧义?
【发布时间】:2011-07-16 06:26:36
【问题描述】:

我正在设计我自己的编程语言(称为 Lima,如果您在 www.btetrud.com 上关心它),并且我正在努力思考如何实现运算符重载。我决定在特定对象上绑定运算符(它是一种基于原型的语言)。 (它也是一种动态语言,其中 'var' 就像 javascript 中的 'var' - 一个可以保存任何类型值的变量)。

例如,这将是一个带有重新定义的 + 运算符的对象:

x = 
{  int member

   operator + 
    self int[b]:
       ret b+self
    int[a] self:
       ret member+a
}

我希望它的作用相当明显。当 x 既是右操作数又是左操作数时定义运算符(使用self 表示这一点)。

问题是当你有两个对象以像这样的开放式方式定义一个运算符时该怎么办。例如,在这种情况下你会做什么:

A = 
{ int x
  operator +
   self var[b]:
    ret x+b
}

B = 
{ int x
  operator +
   var[a] self:
    ret x+a
}

a+b   ;; is a's or b's + operator used?

所以这个问题的一个简单答案是“好吧,不要做出模棱两可的定义”,但它并不是那么简单。如果您包含一个具有 A 类型对象的模块,然后定义了 B 类型对象怎么办。

你如何创建一种语言来防止其他对象劫持你想对你的操作符做的事情?

C++ 将运算符重载定义为类的“成员”。 C++ 如何处理这样的歧义?

【问题讨论】:

  • 另外,如果你有一个 var 关键字,我希望你不会犯和 JavaScript 一样的错误,并且默认情况下将变量设置为全局变量,除非声明为 var。这会导致很多错误(您忘记使用var 的任何变量都会自动与程序中的所有其他同名变量共享)。
  • 你必须声明它,如果不是它是一个错误。所以不要犯同样的错误; )
  • 我不太清楚你对这门语言的真正目标是什么。你建议你对性能感兴趣,但听起来你在打鸭子,这通常不利于性能。我喜欢a < b < c 的链接,但一眼看去,我没有注意到您如何处理结果可能大于操作数类型的算术运算。
  • @supercat 目标是两全其美。主要是最大限度地提高开发效率,并使用用户创建的优化模块以完全自动化的方式最大限度地提高性能。程序员有权定义程序的预期输入(命令行、网络调用等),从而为优化器提供正常工作所需的信息。至于算术运算,lima 中的值实际上没有“类型”,并且数字大小没有上限(除了典型的内存限制)。
  • 欢迎在文档底部发表评论:btetrud.com/Lima/Lima-Documentation.html 而不是 SO

标签: overloading operator-keyword


【解决方案1】:

我建议给定X + Y,编译器应该同时查找X.op_plus(Y)Y.op_added_to(X);每个实现都应该包含一个属性,指示它是否应该是“首选”、“正常”、“后备”实现,并且还可以选择指示它是“通用”的。如果定义了两个实现,并且它们的实现具有不同的优先级(例如“首选”和“正常”),则使用类型来选择首选项。如果两者都被定义为具有相同的优先级,并且都是“通用的”,则支持X.op_plus(Y) 形式。如果两者都以相同的优先级定义并且它们不是“通用”,则标记错误。

我建议,优先考虑重载和转换的能力将恕我直言,对于一种语言来说,这是一个非常重要的特性。在两个候选者都做同样事情的情况下,语言对模棱两可的重载大喊大叫是没有帮助的,但是在两个可能的重载具有不同含义的情况下,语言应该大喊大叫,每个重载在某些情况下都是有用的。例如,给定someFloat==someDoublesomeDouble==someLong,编译器应该发出尖叫声,因为知道两个值表示的数值是否匹配会很有用,而且知道两个值是否匹配也很有用。左侧操作数拥有右侧操作数中值的最佳可能表示形式(针对其类型)。 Java 和 C# 在这两种情况下都不会标记歧义,而是选择对第一个表达式使用第一个含义,而对第二个表达式使用第二个含义,即使这两种含义在任何一种情况下都可能有用。我建议最好拒绝这样的比较,而不是让它们实现不一致的语义。

总的来说,作为一种哲学,我建议一个好的语言设计应该让程序员指出什么是重要的,什么不是。如果程序员知道某些“歧义”不是问题,但其他问题是,编译器应该很容易标记后者而不是前者。

附录

我简要浏览了您的建议;它看到您期望绑定是完全动态的。我使用过这样的语言(HyperTalk,大约 1988 年),它“很有趣”。例如,考虑“2X”

例如,如果您有一个抽象的“不可变数字列表”类型,其中一个成员报告长度或返回特定索引处的数字,您可以指定如果两个实例具有相同的长度,则它们是相等的,并且每个索引都返回相同的数字。虽然可以通过检查每个项目来比较任何两个实例的相等性,但如果例如一个实例是“BunchOfZeroes”类型,它只保存一个整数 N=1000000,实际上并没有存储任何项目,另一个实例是一个“NCopiesOfArray”,它保存 N=500000 和 {0,0} 作为要复制的数组.如果要比较这些类型的许多实例,则可以通过让此类比较调用一个方法来提高效率,该方法在检查整个数组长度后检查“模板”数组是否包含任何非零元素。如果不是,则可以将其报告为等于零串数组,而无需执行 1,000,000 次元素比较。请注意,通过双重分派调用此类方法不会改变程序的行为——它只会让它更快地执行。

【讨论】:

  • 感谢您的回答,但是要考虑所有这些条件是大量的认知开销,因此会使系统更难推理。我同意你的观点,如果两个选项在模棱两可的情况下做同样的事情,编译器不应该抱怨。我认为编译器应该能够弄清楚这一点,而不是依赖程序员告诉它相信它们是相同的(当程序员对此有误时可能会导致问题)。 Lima 确实允许您选择使用哪个 - 将运算符作为函数调用:meta[obj1].operator[+][obj1 obj2]
  • @BT:一般来说,编译器不可能知道两个方法是否做同样的事情,除非有东西告诉它。至于认知负荷,“某处”总是需要一定程度的复杂性。试图过度简化设计的一部分通常会导致其他地方的额外复杂性或“令人惊讶的行为”。例如,有多少人会期望在 Java 中,Math.Round(2147483646L) 会产生 2147483647?这种行为是 Java 的“排名”原语的结果,而不是使用更详细的类型转换规则。
  • 我不同意这是不可能的。困难是的,不可能的。无论如何,程序员可以知道两个函数是否相同,编译器应该能够知道。然而,即使编译器不知道两个函数是否相同,缺点也只是一个错误,程序员必须指定使用哪个对象的运算符。我也不同意简化一个领域必然会导致其他地方出现令人惊讶的行为。复杂性不是零和游戏。这就是语言设计艺术的用武之地。但请随时对我的语言进行严厉的批评; )
  • @BT:良好的语言设计会识别出令人惊讶或恼人的行为,并尝试找出缓解这些行为所需的最小复杂性。此外,我赞同这样一种理念,即花费在计算机推断人们想要什么上的许多努力可以更好地用于让人们更容易指定他们想要什么以最少的重复。此外,应该尝试让语义上不同的事物使用不同的语法,即使它们碰巧生成相同的代码。我对 Java/.NET 浮点规则的不满之一是……
  • ...像someVariable = (float)Math.Sin(otherVariable); 这样的语句可能意味着“我想将计算尽可能准确地存储在someVariable 中,我相信它是float”,或者“我知道@987654332 @ 是 double,但我想强制将该值四舍五入为 float 精度”。在任何一种情况下,强制转换的运行时效果都是相同的,但是如果需要更改 someVariable 以使用更高的精度,则对两者使用相同的语法会使重新编写代码变得更加困难。
【解决方案2】:

大多数语言会优先考虑左边的类。我相信,C++ 根本不允许您重载右侧的运算符。当您定义operator+ 时,您正在为该类型在左侧定义加法,对于右侧的任何内容。

事实上,如果您允许 operator + 在类型位于右侧时工作,那将毫无意义。它适用于 +,但考虑 -。如果类型 A 以某种方式定义 operator -,并且我执行 int x - A y,我不希望调用 A 的 operator -,因为它会反向计算减法!

在具有更广泛operator overloading rules 的Python 中,有一个单独的反向方法。例如,__sub__ 方法在此类型位于左侧时重载 - 运算符,__rsub__ 在此类型位于右侧时重载 - 运算符。这类似于在您的语言中允许“自我”出现在左侧或右侧的功能,但它会引入歧义。

Python 优先考虑左边的东西——这在动态语言中效果更好。如果Python遇到x - y,它首先调用x.__sub__(y)x是否知道如何减去y。这可以产生一个结果,或者返回一个特殊值NotImplemented。如果 Python 发现返回了 NotImplemented,它会尝试另一种方式。它调用y.__rsub__(x),如果知道y 在右侧,它就会被编程。如果这也返回NotImplemented,则引发TypeError,因为该操作的类型不兼容。

我认为这是动态语言的理想运算符重载策略。

编辑:稍微总结一下,你有一个模棱两可的情况,所以你真的只有三个选择:

  • 优先考虑一侧或另一侧(通常是左侧)。这可以防止具有右侧重载的类劫持具有左侧重载的类,但不能反过来。 (这在动态语言中效果最好,因为方法可以决定它们是否可以处理它,并动态地推迟到另一种。)
  • 让它成为一个错误(正如@dave 在他的回答中所建议的那样)。如果有不止一个可行的选择,那就是编译器错误。 (这在静态语言中效果最好,您可以提前捕捉到这一点。)
  • 只允许最左边的类定义运算符重载,就像在 C++ 中一样。 (那么你的 B 类就是非法的。)

唯一的其他选择是为运算符重载引入一个复杂的优先系统,但是你说你想减少认知开销。

【讨论】:

  • 有趣,但这里的问题是,虽然在逻辑上消除了歧义,但它仍然增加了认知负荷。我正在尝试尽可能减少认知负担。但是+1!
  • 我明白了......我现在才意识到你的代码是如何工作的:A 中的运算符 + 是当 A 在左侧时。 B中的运算符+是当B在右边时。因此,您确实拥有 Python 的 addradd 的灵活版本。我会在我的答案中添加更多内容。
  • @B T 请注意,如果您添加了 Python 的 NotImplemented 事物,您可以像 Python 一样有效地创建具有优先级的类。例如,假设您有一个 Int 类和一个 Float 类,并且无论 Float 在哪一侧,您都希望 Float 加法优先。好吧,您可以在左侧和右侧同时使用 Int 和 Float 重载 +,但除非遇到 Int,否则 Int 返回 NotImplemented。因此,如果任何一方是浮点数,它将动态地推迟到浮点数。
  • 所以 Lima 实际上有可选的强类型,所以它不需要 NotImplemented 的东西来做你说的,它就像: operator+[self int[b]: ret # x+b]。
  • 就您的编辑而言,我喜欢选项 2,但我担心任何歧义错误都需要重写整个系统(第 3 方模块和所有)。那不是我想要的情况。选项 3 意味着运算符几乎必须是可交换的——这是我不喜欢的限制。 1 是可以的,但如果有可能以这种方式创建一个合理的系统,那么以某种方式完全避免歧义会很好。
【解决方案3】:

我将通过说“呃,不要做出模棱两可的定义”来回答这个问题。

如果我在 C++ 中重新创建您的示例(使用函数 f 而不是 + 运算符和 int/float 而不是 A/B,但实际上并没有太大区别)。 ..

template<class t>
void f(int a, t b)
{
    std::cout << "me! me! me!";
}

template<class t>
void f(t a, float b)
{
    std::cout << "no, me!";
}

int main(void)
{
    f(1, 1.0f);
    return 0;
}

...编译器会准确地告诉我:error C2668: 'f' : ambiguous call to overloaded function

如果您创建的语言足够强大,则总有可能在其中创建没有意义的东西。发生这种情况时,举起双手说“这没有意义”可能是可以的。

【讨论】:

  • 我同意这一点。但我认为这对于静态语言更有意义。在动态语言中,歧义错误通常在运行时看起来很奇怪。默认情况下发生的情况更常见(例如默认为左侧重载)。
  • 所以最初我认为我还没有完全掌握所有的可能性。但现在我想我做到了。您要么遇到其他人定义了对象 A,而您定义了与对象 A 一起操作的对象 B,或者您定义了这两个对象。在这两种情况下,我认为要求不编码歧义是完全合理的。我想我实际上会选择“不要做出模棱两可的定义”+1(只是不给你答案,因为 mgiuca 帮了很多忙。而且因为他的回答给出了我所有的选择——包括你的选择)
【解决方案4】:

在 C++ 中,a op b 表示 a.op(b),所以它是明确的;该命令解决了它。如果在 C++ 中,你想定义一个左操作数是内置类型的操作符,那么操作符必须是一个有两个参数的全局函数,而不是一个成员;但是,操作数的顺序再次决定了调用哪个方法。定义两个操作数都是内置类型的运算符是非法的。

【讨论】:

  • 我不知道你在说什么,确切地说。我看不出我在哪里说过任何阻止复制构造函数成为成员的东西。事实上,我认为 OP 所说的大多数运算符都是成员函数是理所当然的。不,根据定义,类类型不是内置类型。规则是任何用户定义运算符的至少一个操作数必须是用户定义类型。无论如何,如果您想告诉我我所做的什么陈述是错误的,我会全力以赴。
  • @Mahesh 他没有错,他只是专注于内置类型。第一句话涵盖了类类型。在 C++ 中有两种方法可以进行运算符重载。您可以使其成为左侧类的方法,也可以使其成为重载以指定两个参数的类型的全局函数。他是说内置类型只能用后一种方法重载(因为它们没有类)。对于您无法修改其定义的任何类也是如此。
  • @mgiuca 对不起,你们都是正确的。删除我的评论和 +1。
  • 听起来与 python 处理它的方式相似(根据 mgiuca)。很有趣。
  • @BT 是的,除了两个重要的区别:a) C++ 没有 Python 具有的反向版本,b) C++ 允许您在类之外定义运算符重载函数,这意味着即使您没有对该类的写访问权限,您也可以为特定类型增加运算符的行为。
猜你喜欢
  • 2023-03-03
  • 1970-01-01
  • 2022-06-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-09-27
  • 2017-12-22
相关资源
最近更新 更多