{ 跳至内容 }

功能深度解析:用户自定义运算符

作者:Kamil Śliwak,Matheus Aguiar 发布于 2023年2月22日

解释器

亮点在于Solidity 0.8.19 版本发布,它支持在用户定义的值类型 (UDVTs) 上定义运算符

如果您没有关注最近的功能,UDVTs 是 Solidity 0.8.8 中引入的一类新类型。它们为基本值类型提供了一种抽象,从而产生了一种全新的类型。这类似于创建别名,但新类型与底层值类型以及从该底层类型派生的所有其他 UDVTs 都是不同的。使用运算符的能力旨在使 UDVTs 更接近于像内置类型一样自然地使用,并促进类型安全接口的使用。下面,我们将更详细地了解这种新机制。

快速回顾

在深入了解新功能及其对语言的影响之前,最好先回顾一下它所基于的两个现有功能:内置运算符和using for.

内置运算符

Solidity,与绝大多数编程语言一样,提供了一组运算符,作为常用算术和逻辑函数的语法糖。运算符和函数之间的区别纯粹是语法的,选择其中一个而不是另一个在很大程度上取决于可读性。使用运算符使表达式更简洁,只要读者能够理解,这将非常有用。每个语言中包含的内容差别很大。

以下是 Solidity 的完整列表

类别运算符
位运算&, |, ^, <<, >>, ~
算术运算+, -, *, /, %, **
比较运算==, !=, <, <=, >, >=
布尔运算!, &&, ||
增量/减量++, --
简单赋值=
复合赋值+=, -=, ^=, *=, /=, %=, &=, |=, ^=, <<=, >>=
其他deletenew.()[]?:

需要注意的是,在许多语言中,赋值是一个以特殊方式处理的语句。在 Solidity 中,与它所借鉴的类 C 语言一样,赋值运算符可以像其他运算符一样在表达式中自由使用。

在大多数情况下,内置运算符仅针对值类型定义。值得注意的例外是简单赋值和“其他”组。例如,您可以在括号或三元运算符的分支中放入几乎任何内容,作为更大表达式的部分,即使其类型无法命名甚至无法赋值给任何内容。

using for

由于运算符是纯粹的语法元素,因此您始终可以使用函数作为替代。Solidity 有一种经常以这种方式使用的机制:using for

指令using ... for ...可用于将函数附加到类型。然后,这些函数可以用作该类型任何对象的成员,并接收该对象作为其第一个参数。

pragma solidity ^0.8.13;

type Int is int;
using {add} for Int global;

function add(Int a, Int b) pure returns (Int) {
    return Int.wrap(Int.unwrap(a) + Int.unwrap(b));
}

function test(Int a, Int b) pure returns (Int) {
    return a.add(b);
}

目前,using for 有 3 种形式

  1. using L for T:将库 L 中的所有非私有函数附加到类型 T
  2. using L for *:将库 L 中的所有非私有函数附加到任何类型,包括语言中无法命名的类型和显式使用的类型(例如,字面量或数组切片的类型)。
  3. using {f, L.g, ...} for T:将给定的自由函数或库函数 fL.g,... 附加到类型 T

该指令可用于

  • ✅ 文件级别。
  • ✅ 合约和库定义内部。

该指令不可用于

  • ❌ 接口定义内部。

在合约内部使用时,它不会被继承。其影响仅限于包含它的合约,而不扩展到任何其他派生自它的合约。

在文件级别,该指令会影响该文件中定义的所有自由函数、合约和库。它不会影响导入包含它的文件的其他文件,除非它被标记为 globalglobal 将其影响扩展到所有文件。仅当源单元中定义了 UDVTs、结构体和枚举时,才能使用 global

引入用户自定义运算符

新功能扩展了 using for 语法的第三种形式,以允许将运算符绑定到函数。每当在类型的值上使用运算符时,都会调用该函数

pragma solidity ^0.8.19;

type Int is int;
using {add as +} for Int global;

function add(Int a, Int b) pure returns (Int) {
    return Int.wrap(Int.unwrap(a) + Int.unwrap(b));
}

function test(Int a, Int b) pure returns (Int) {
    return a + b; // Equivalent to add(a, b)
}

运算符只能在 global 指令中绑定,其定义必须是 pure 自由函数。 using for 中指定的类型必须是用户定义的值类型,并且必须是函数的所有参数及其返回值的类型。唯一的例外是比较运算符定义,其中返回类型必须是 bool

由于目前不允许对 UDVTs 进行隐式转换,因此用户自定义运算符必须始终使用精确的类型调用。

可以定义以下运算符

类别运算符
位运算&, |, ^, ~
算术运算+, -, *, /, %
比较运算==, !=, <, <=, >, >=

在上面列出的运算符中,~ 是单目运算符,- 可以是单目运算符也可以是双目运算符,其余始终是双目运算符。请注意,Solidity 没有单目 +。单目和双目 - 被认为是不同的运算符,需要单独定义。- 指的是单目还是双目运算符取决于函数的参数数量。

无法替换运算符定义。这既适用于编译器提供的内置运算符定义,也适用于用户提供的绑定到函数的运算符。一旦绑定了运算符,就不能更改其定义。

运算符绑定与附加函数

用户自定义运算符独立于附加函数。可以单独执行其中一个,也可以同时执行两者,甚至可以在同一个 using for 指令中执行。

以下示例说明了这一点

pragma solidity ^0.8.19;

type Int is int;
using {add as +} for Int global;
using {sub as -, sub} for Int global;

function add(Int a, Int b) pure returns (Int) {
    return Int.wrap(Int.unwrap(a) + Int.unwrap(b));
}

function sub(Int a, Int b) pure returns (Int) {
    return Int.wrap(Int.unwrap(a) - Int.unwrap(b));
}

function test(Int x, Int y) pure {
    x + y;
    x.add(y); // ERROR: Member "add" not found or not visible after argument-dependent lookup in Int.

    x - y;
    x.sub(y); // OK -- "sub" was also attached in "using for"
}

在上面的示例中,函数 add() 用作运算符 +,不能作为 x.add(y) 调用(但当然也可以作为 add(x, y) 调用)。另一方面,sub() 既可以用作运算符 -,也可以在类型上调用。

带有运算符的 using for global 的规则与附加函数的规则相同(此外,运算符必须是全局的),即仅允许在文件级别使用,类型必须在同一文件中定义,并且使其在任何地方都可用。请注意,只有指令本身必须与类型位于同一位置。运算符定义可以从单独的文件导入。

参数清理

如果您决定在运算符实现中使用内联汇编,则在使用未填充整个堆栈槽的类型时应注意一个危险的陷阱。在内联汇编中访问 Solidity 变量会绕过通常会掩盖值脏位的变量清理机制。在内联汇编中,您必须手动执行此清理。

考虑一个示例,该示例天真地访问运算符的参数和返回值,而没有正确清理 uint8

pragma solidity ^0.8.19;

type U8 is uint8;
using {yoloAdd as +, yoloDiv as /} for U8 global;

function yoloAdd(U8 x, U8 y) pure returns (U8 z) {
    assembly {
        z := add(x, y) // Wrong! No cleanup.
    }
}

function yoloDiv(U8 x, U8 y) pure returns (U8 z) {
    assembly {
        z := div(x, y) // Wrong! No cleanup.
    }
}

contract C {
    function divAddNoOverflow(U8 a, U8 b, U8 c) external pure returns (U8) {
        return a / (b + c);
    }
}

现在,让我们看看调用 divAddNoOverflow(4, 0xff, 3) 时会发生什么。您可能期望它返回 4,即 8 / ((255 + 3) % 256)。毕竟,z 基于 uint8,并且对 255 无检查地加 3 应该会环绕并为您提供 2。8 / 2 是 4。但事实并非如此。实际结果为零。

请注意,局部变量占用整个堆栈槽,并且内联汇编中的所有计算都在 256 位数字上执行。add(0xff, 3) 的结果实际上是 0x0102,并且 yoloAdd() 没有清除高位。yoloDiv() 然后接收该值作为 y,并且同样没有清除高位。这会导致整数除以 258 而不是 2。

可以通过手动清理避免此问题。如果 add(x, y) 被替换为

and(0xff, add(and(0xff, x), and(0xff, y)))

并且 div(x, y) 被替换为

and(0xff, div(and(0xff, x), and(0xff, y)))

示例

未经检查的计数器

此示例说明了如何使用运算符绕过针对专门为不需要这些检查的情况定义的类型的已检查算术运算

pragma solidity ^0.8.19;

type UncheckedCounter is uint;

using {add as +, lt as <} for UncheckedCounter global;

UncheckedCounter constant ONE = UncheckedCounter.wrap(1);

function add(UncheckedCounter x, UncheckedCounter y) pure returns (UncheckedCounter) {
    unchecked {
        return UncheckedCounter.wrap(
            UncheckedCounter.unwrap(x) +
            UncheckedCounter.unwrap(y)
        );
    }
}

function lt(UncheckedCounter x, UncheckedCounter y) pure returns (bool) {
    return UncheckedCounter.unwrap(x) < UncheckedCounter.unwrap(y);
}

contract C {
    uint internalCounter = 12;

    function testCounter() public returns (uint) {
        for (
            UncheckedCounter i = UncheckedCounter.wrap(12);
            i < UncheckedCounter.wrap(20);
            i = i + ONE
        )
            ++internalCounter;

        return internalCounter;
    }
}
更复杂的抽象示例

这是一个更复杂的示例,它展示了用户自定义运算符的多个方面。它执行的确切计算并不重要,重点是在更大的上下文中展示语法。

redBlueScore.sol

pragma solidity ^0.8.19;

import {Red, Blue, Score, RED_LIMIT, BLUE_LIMIT} from "./types.sol";

contract RedBlueScore {
    Red public redGauge;
    Blue public blueGauge;

    function voteRed(Red value, Red base) public {
        require(-RED_LIMIT <= value * base && value * base <= RED_LIMIT);
        redGauge = redGauge + value * base.exp(3);
    }

    function voteBlue(Blue value) public {
        require(-BLUE_LIMIT <= value && value <= BLUE_LIMIT);
        blueGauge = blueGauge + value - Blue.wrap(100);
    }

    function calculateScore() public view returns (Score) {
        return
            redGauge.toScore() / RED_LIMIT.toScore() -
            blueGauge.toScore() / BLUE_LIMIT.toScore();
    }
}

types.sol

pragma solidity ^0.8.19;

import "./operators.sol" as op;

type Red is int;
using {op.RedLib.toScore, op.RedLib.exp, op.addRed as +, op.mulRed as *, op.unsubRed as -} for Red global;
using {op.lteRed as <=, op.gtRed as >} for Red global;

type Blue is int;
using {
    op.addBlue as +,
    op.unsubBlue as -,
    op.subBlue as -,
    op.BlueLib.toScore,
    op.lteBlue as <=,
    op.gtBlue as >
} for Blue global;

type Score is int128;
using {op.addScore as +} for Score global;
using {op.subScore as -} for Score global;
using {op.divScore as /} for Score global;

Red constant RED_LIMIT = Red.wrap(10);
Blue constant BLUE_LIMIT = Blue.wrap(20);

operators.sol

pragma solidity ^0.8.19;

import {Red, Blue, Score} from "./types.sol";

library RedLib {
    function toScore(Red x) internal pure returns (Score) {
        return Score.wrap(int128(Red.unwrap(x))) + Score.wrap(10);
    }

    function exp(Red x, uint y) internal pure returns (Red) {
        return Red.wrap(Red.unwrap(x) ** y);
    }
}

library BlueLib {
    function toScore(Blue x) internal pure returns (Score) {
        return Score.wrap(int128(Blue.unwrap(x))) - Score.wrap(20);
    }

    function add(Blue x, Blue y) external pure returns (Blue) {
        return Blue.wrap(Blue.unwrap(x) + Blue.unwrap(y));
    }
}

using {BlueLib.add} for Blue;

function addRed(Red x, Red y) pure returns (Red)  { return Red.wrap(Red.unwrap(x) + Red.unwrap(y)); }
function mulRed(Red x, Red y) pure returns (Red)  { return Red.wrap(Red.unwrap(x) * Red.unwrap(y)); }
function unsubRed(Red x)      pure returns (Red)  { return Red.wrap(-Red.unwrap(x)); }
function lteRed(Red x, Red y) pure returns (bool) { return Red.unwrap(x) <= Red.unwrap(y); }
function gtRed(Red x, Red y)  pure returns (bool) { return !(x <= y); }

function addBlue(Blue x, Blue y) pure returns (Blue) { return x.add(y); }
function unsubBlue(Blue x)       pure returns (Blue) { return Blue.wrap(-Blue.unwrap(x)); }
function subBlue(Blue x, Blue y) pure returns (Blue) { return x + -y; }
function lteBlue(Blue x, Blue y) pure returns (bool) { return Blue.unwrap(x) <= Blue.unwrap(y); }
function gtBlue(Blue x, Blue y)  pure returns (bool) { return !(x <= y); }

function addScore(Score x, Score y) pure returns (Score) { return Score.wrap(Score.unwrap(x) + Score.unwrap(y)); }
function subScore(Score x, Score y) pure returns (Score) { return Score.wrap(Score.unwrap(x) - Score.unwrap(y)); }
function divScore(Score x, Score y) pure returns (Score) { return Score.wrap(Score.unwrap(x) / Score.unwrap(y)); }

AST 更改

新功能为编译器生成的抽象语法树 (AST) 引入了两个新属性

  1. UsingForDirective 节点上的 functionList 现在可以包含以下结构的运算符条目
    {
      "operator": "+",
      "definition": {
        /* function */
      }
    }
    
    除了当前结构如下所示的函数条目之外
    {
      "function": {
        /* function */
      }
    }
    
  2. BinaryOperationUnaryOperation 节点现在可能具有 function 属性。如果存在,则该属性表示该操作实际上是用户定义运算符的调用,并指定用于定义它的函数的 ID。

这有效地引入了一种执行函数调用的新方法,该方法不受 FunctionCall 节点的表示。它会影响任何需要检测和跟踪函数调用的工具,例如,构建控制流图。

设计原理

为什么 Solidity 需要用户自定义运算符?

Solidity 中用户定义的值类型(UDVT)的支持,如果没有更自然的操作方式,就不能算完整。从一开始就设想了使用运算符的能力,而没有立即提供的主要原因是对与我们计划引入的其他功能的交互存在不确定性。我们决定最好以最小的可用状态发布此功能,并随着时间的推移进行扩展,同时考虑用户反馈。

UDVT 通常旨在满足两种需求

  1. 在对具有相同底层值类型但概念上表达不同事物的值的执行操作时,提高类型安全性。例如,不同维度的数量都可以存储为uint,但如果没有显式转换就将它们相加是一个错误,只有在编译器知道它们是不同种类值的时才能防止此错误。

  2. 能够使用值类型作为底层表示来定义新类型。最好的例子是定点类型。

    定点类型最初计划成为语言的组成部分,而部分实现的ufixedfixed 类型是该旧设计的残余。然而,在尝试就该功能的最终设计达成一致时,我们意识到没有哪个实现能够在大多数情况下提供足够好的权衡,也无法成为合适的默认选择。我们决定将选择权留给库作者,而是专注于提供构建自己的定点类型所需的原语,使其感觉像原生类型一样。用户定义的运算符是朝着这个目标迈出的一步。

用户定义的运算符会使审计更难吗?

在我们迄今收到的反馈中,最常见的担忧之一是该功能对合约审计的影响以及可能掩盖恶意代码的可能性。我们想在这里解决这些问题。

首先,任何新的语法都会增加语言的复杂性,并且在滥用时具有一定的潜力使代码变得更模糊、更难理解。它无法完全避免,并且始终是所有好坏使用方式之间的权衡。我们认为,改进 UDVT 的人体工程学非常重要,足以超过我们考虑的任何缺点。

运算符提供的可读性优势不容忽视。当涉及许多函数调用时,即使包含相对简单的表达式的代码也可能难以一目了然。

if (equals(add(mul(a, x), mul(b, y)), add(div(c, z), mod(d, w)))) {
    return add(div(c, z), mod(d, w));
}

链接附加函数在某种程度上通过将操作名称放在参数之间(类似于中缀运算符)来提供帮助

if (a.mul(x).add(b.mul(y)).equals(c.div(z).add(d.mul(w)))) {
    return c.div(z).add(d.mod(w)));
}

但是,它仍然远不及使用运算符可以实现的简洁、简洁的表示法

if (a * x + b * y == c / z + d % w) {
    return c / z + d % w;
}

最后一个例子是我们希望真实代码看起来的样子。顺便说一句,您是否发现了第二个例子中的错误?

认识到该功能的实际范围也很重要。您可能会从 C++ 等语言中想象出一个复杂的系统,该系统具有复杂的规则和许多特殊情况。在目前的实现中,该功能被刻意限制得很严格。我们尝试同时限制意外并避免过早地添加我们尚未仔细考虑的情况的支持。

在当前的实现中,无法

  1. 对 UDVT 以外的类型使用运算符。

    • 不允许对引用类型(如结构体或数组)使用用户定义的运算符。
    • 无法在内置值类型、函数类型或枚举类型上定义它们。
    • 无法使用类型通配符 (*) 将它们定义为未命名类型。
  2. 具有非pure 的运算符。运算符不能修改存储。它们最多只能进行纯外部调用。

    从技术上讲,使用技巧所能做到的最糟糕的事情是修改内存(仅通过内联汇编)或进行静态调用(通过与已部署代码不匹配的接口)。

  3. 对混合类型使用运算符。运算符的参数和返回值类型必须相同。此外,也不可能进行隐式转换,因此很容易确定特定操作是调用用户定义的运算符还是内置运算符(只需查看类型)。

  4. 重新定义运算符。您不能用自定义定义替换任何内置运算符。对于用户定义的运算符,只能提供一个定义。

  5. 将运算符绑定到合约函数。这也意味着运算符定义不能被继承、虚拟或保持未实现。

  6. 将运算符绑定到重载函数。

  7. 使用内置函数定义运算符。例如,您不能将运算符直接绑定到keccak256()

  8. 更改运算符的优先级或结合性。

  9. 定义任何具有更复杂语义的运算符。您不能定义

    • &&||,因为它们具有短路行为。
    • ++--,因为它们修改其参数,并且还具有语义不同的单独的前缀和后缀变体。
    • +=-=^=*=/=%=&=|=^=<<=>>=,因为它们修改了其参数。
    • =()?:.,因为它们是所有类型的内置运算符。
    • deletenew[],因为它们不能与 UDVT 一起使用。
  10. 定义语言中不存在的自定义运算符,例如 === 或一元 +

  11. 批量或意外地绑定运算符。using L for T 语法用于将整个库附加到类型,但不包括运算符。运算符必须始终显式绑定。

除了这些限制之外,我们还决定根据在发布 Solidity 0.8.18 之前收到的反馈进一步限制它们。这意味着您也无法

  1. 使用库函数定义运算符。这排除了外部、公共和私有函数。现在也无法将修饰符应用于运算符,因为自由函数不能具有修饰符。
  2. 具有非全局运算符,这有几个后果
    • 运算符只能在也定义类型的文件中绑定。它们在类型可用的任何地方都可用,并且在不同的作用域中不能具有不同的定义。
    • 在合约级别无法进行本地绑定(即在合约和库内部)。这也使得无法为在合约或库内部定义的本地类型定义运算符。
  3. 定义 !<<>>**

我们可能会在将来解除一些这些限制,但我们将仔细考虑每种情况。其中一些,例如无法使用重载函数,源于语法的现有限制,但另一些,例如禁止重新定义内置运算符,则是非常有意的,旨在限制潜在的滥用。

最终,任何审计都必须包括对类型定义和函数的仔细审查,我们认为这将揭示大多数恶意行为的情况。鉴于上述限制,不良运算符除了误导之外,无法做更多的事情,这将在定义审查中出现。奇怪的行为,例如执行任何非加法操作的 + 应该会引发警示,就像名为 transfer() 但不进行任何转账的函数一样。

只要审阅者了解所涉及的类型,审查运算符的调用也不应该构成重大问题,这始终是理解表达式的必要条件。这不是什么新鲜事,因为内置运算符的行为始终受操作数类型的影响。例如,整数类型的溢出语义取决于相关类型的尺寸。

请注意,用户定义的运算符不能更改现有代码的语义。对于内置类型,行为根本无法更改。对于 UDVT,任何运算符都必须是用户定义的,因此看到它与看到函数调用没有什么区别。一个运算符只能有一个定义,该定义必须在定义类型的文件中显式绑定,并且不能被隐藏。在这两种情况下,名称都可能具有误导性,可能需要查看定义。

还值得注意的是,该功能主要用于提供通用类型的可重用库中。在这种情况下,一旦库经过审计,关于运算符具有误导性或意外行为的担忧应该降到最低。

用户定义运算符和 UDVT 的未来

现在我们了解了新功能及其局限性,每个人心中可能都会出现一个问题:“它会一直这样吗?”该功能在短期和长期内将如何发展?

字面量后缀

UDVT 的下一步是能够提供用户定义的值类型的自定义字面量。在表达式中轻松使用字面量值是使类型感觉像原生类型的重要组成部分。Solidity 已经提供了分数字面量,我们的目的是使它们能够与自定义定点数字实现一起使用。为此,我们将允许定义后缀函数,这些函数通过转换语言中已有的字面量来返回 UDVT

type UFixed128x18 is uint128;

function uf(int16 mantissa, uint8 exponent) pure suffix returns (UFixed128x18) { ... }
UFixed128x18 circumference = 2 uf * 3.14 uf * radius;

虽然应用后缀将涉及函数调用,但最终,在编译时评估的帮助下,字面量后缀将变得与原生字面量一样高效。

更多运算符

许多运算符已从初始实现中排除。其中一些很可能在某个时候成为用户可定义的

  1. <<>>** 因其非统一的参数而被排除在外。对于内置类型,右侧始终是无符号类型。
  2. ! 被排除在外,因为我们必须决定它应该返回与参数相同的类型还是仅返回 bool。我们倾向于后者,但这需要更多考虑。
  3. ++-- 非常方便,我们希望最终能够拥有它们。它们只需要以特殊的方式处理,并且有多种方法可以做到这一点。
  4. 只要相应的算术/按位运算符可用,复合赋值运算符(如 +=)很可能在将来自动提供。无论如何,目前不允许直接实现,因为语言中没有对值类型的引用。

另一方面,我们不太可能允许重新定义其他一些运算符

  1. = 已由编译器为所有类型提供,我们极不可能允许重新定义它。

复杂类型的运算符

虽然 UDVT 是主要目标,但在某些情况下,构建具有更复杂底层表示(例如结构体或动态数组)的自定义类型也可能是合理的。阻碍我们在此方面前进的主要因素是即将对类型系统进行的更改,这些更改是我们近期路线图的重要组成部分。为了避免以后引入破坏性更改,我们决定推迟对这些运算符的支持,直到这些更改完成。

哪些方面不会改变?

由于对功能安全性的担忧,在运算符方面有一些关键要素不太可能发生变化。运算符可能始终是全局的,并在文件级别使用自由函数进行定义。将无法替换内置运算符或重新定义已定义的用户定义运算符。

上一篇

下一篇

参与进来

GitHub

推特

Mastodon

矩阵

了解更多

博客文档用例贡献关于论坛

2024Solidity 团队

安全策略

行为准则