Skip to main content

Unit Test

Unit Test a Smart Contract

chaijs

https://docs.alchemy.com/docs/how-to-unit-test-a-smart-contract

Foundry forge

中文:https://learnblockchain.cn/docs/foundry/i18n/zh/forge/writing-tests.html

原文:https://book.getfoundry.sh/forge/writing-tests

example: https://github.com/erc6900/reference-implementation/blob/a641a767edae50094a7f9381ef0e58ac1b5119b3/test/utils/OptimizedTest.sol#L4

AccountExecHooksTest.sol

这段代码是一个智能合约测试脚本,用于测试UpgradeableModularAccount合约中与执行钩子(hooks)相关的功能。这个测试脚本是使用foundry框架编写的,foundry是一个以太坊智能合约开发工具,它包含测试框架、合约编译器和部署工具。

测试脚本AccountExecHooksTest继承自OptimizedTest,它使用了foundryvm对象来模拟区块链环境,进行合约调用、设置预期的事件(event)发出、处理状态变更等。

这个测试脚本的主要目的是验证UpgradeableModularAccount合约中的执行钩子是否按照预期工作。执行钩子允许在执行特定函数之前和之后运行自定义逻辑。这些钩子通常用于验证、状态更新、事件记录等。

测试脚本中的关键部分包括:

  1. setUp():在每个测试开始前运行,用于设置测试环境,包括部署所需的合约实例,如EntryPointSingleOwnerPluginMSCAFactoryFixtureUpgradeableModularAccount

  2. test_preExecHook_install():测试安装具有前置执行钩子的插件。

  3. test_preExecHook_run():测试执行具有前置执行钩子的插件,验证钩子是否被正确调用。

  4. test_preExecHook_uninstall():测试卸载具有前置执行钩子的插件,确保卸载后钩子不再被调用。

  5. test_execHookPair_install():测试安装具有成对的执行钩子(前置和后置)的插件。

  6. test_execHookPair_run():测试执行具有成对执行钩子的插件,验证前置和后置钩子是否按顺序正确调用。

  7. test_execHookPair_uninstall():测试卸载具有成对执行钩子的插件。

  8. test_postOnlyExecHook_install():测试安装仅具有后置执行钩子的插件。

  9. test_postOnlyExecHook_run():测试执行仅具有后置执行钩子的插件。

  10. test_postOnlyExecHook_uninstall():测试卸载仅具有后置执行钩子的插件。

  11. test_overlappingPreExecHooks_install():测试安装两个具有相同前置执行钩子的插件。

  12. test_overlappingPreExecHooks_run():测试执行具有重叠前置执行钩子的插件。

  13. test_overlappingPreExecHooks_uninstall():测试卸载具有重叠前置执行钩子的插件。

  14. test_execHookDependencies_failInstall():测试插件依赖关系,确保当插件具有不满足的依赖时安装会失败。

  15. _installPlugin1WithHooks()_installPlugin2WithHooksExpectSuccess()_installPlugin2WithHooksExpectFail()_uninstallPlugin():这些是辅助函数,用于在测试中安装和卸载插件,同时检查预期的事件是否发生。

这个测试脚本是对智能合约开发的一个很好的实践示例,它展示了如何通过自动化测试来确保合约的各个组件按照预期工作。通过这种方式,开发者可以在合约部署到主网之前捕捉到潜在的错误和问题。

测试:期望事件的数据匹配

test_preExecHook_install

test/account/AccountExecHooks.t.sol

function test_preExecHook_install() public {

// _installPlugin1WithHooks(bytes4 selector, ManifestFunction preHook, ManifestFunction postHook)
_installPlugin1WithHooks(
_EXEC_SELECTOR,
ManifestFunction({
functionType: ManifestAssociatedFunctionType.SELF,
functionId: _PRE_HOOK_FUNCTION_ID_1,
dependencyIndex: 0
}),
ManifestFunction({functionType: ManifestAssociatedFunctionType.NONE, functionId: 0, dependencyIndex: 0})
);
}



// 执行一个安装函数,该函数会触发两个事件
function _installPlugin1WithHooks(
bytes4 selector,
ManifestFunction memory preHook,
ManifestFunction memory postHook
) internal {
m1.executionHooks.push(ManifestExecutionHook(selector, preHook, postHook));
mockPlugin1 = new MockPlugin(m1);
manifestHash1 = keccak256(abi.encode(mockPlugin1.pluginManifest()));
// 测试预期
vm.expectEmit(true, true, true, true);
emit ReceivedCall(abi.encodeCall(IPlugin.onInstall, (bytes(""))), 0);
// 测试预期安装完成
vm.expectEmit(true, true, true, true);
emit PluginInstalled(address(mockPlugin1), manifestHash1, new FunctionReference[](0));

// 执行安装
// @file: src/account/UpgradeableModularAccount.sol
account.installPlugin({
plugin: address(mockPlugin1),
manifestHash: manifestHash1,
pluginInstallData: bytes(""),
dependencies: new FunctionReference[](0)
});
}

installPlugin

src/account/UpgradeableModularAccount.sol
  /// @inheritdoc IPluginManager
function installPlugin(
address plugin,
bytes32 manifestHash,
bytes calldata pluginInstallData,
FunctionReference[] calldata dependencies
) external override wrapNativeFunction {
// @file: src/account/PluginManagerInternals.sol
_installPlugin(plugin, manifestHash, pluginInstallData, dependencies);
}

_installPlugin

src/account/PluginManagerInternals.sol
  function _installPlugin(
address plugin,
bytes32 manifestHash,
bytes memory pluginInstallData,
FunctionReference[] memory dependencies
) internal{

// ....


// Initialize the plugin storage for the account.
// 初始化插件的存储
// solhint-disable-next-line no-empty-blocks
try IPlugin(plugin).onInstall(pluginInstallData) {}

catch (bytes memory revertReason) {
revert PluginInstallCallbackFailed(plugin, revertReason);
}

emit PluginInstalled(plugin, manifestHash, dependencies);
}

多事件测试匹配

如果您期望在合约执行的过程中发出多个不同的事件,您需要为每个事件分别设置 vm.expectEmit 来定义对应的预期。对于每个预期的事件,您都需要指定期望的 topics 和 data 是否应该匹配。

例如,如果您的合约在某个函数调用中按顺序发出了两个不同的事件 EventAEventB,那么您的测试代码可能会像这样:

// 设置对第一个事件的预期
vm.expectEmit(true, true, false, false);
emit EventA(expectedParam1, expectedParam2); // 预期收到的事件,前两个参数对的上

// 调用合约的函数,该函数将触发事件
contract.functionThatEmitsEvents();

// 设置对第二个事件的预期
vm.expectEmit(false, false, true, true);
emit EventB(expectedParam3, expectedParam4); // 预期收到的事件,后两个参数对的上

在这个例子中,vm.expectEmit 被调用了两次,每次都是在调用引发事件的函数之前。每个 vm.expectEmit 调用都设置了对接下来将要发出的特定事件的预期。这样,测试框架就可以检查实际发出的事件是否符合这些预期。如果任何一个事件不符合预期,测试将会失败。

test_preExecHook_run

test/account/AccountExecHooks.t.sol
function setUp() public {
entryPoint = new EntryPoint();
singleOwnerPlugin = _deploySingleOwnerPlugin();
factory = new MSCAFactoryFixture(entryPoint, singleOwnerPlugin);

// Create an account with "this" as the owner, so we can execute along the runtime path with regular
// solidity semantics
account = factory.createAccount(address(this), 0);

m1.executionFunctions.push(_EXEC_SELECTOR);

m1.runtimeValidationFunctions.push(
ManifestAssociatedFunction({
executionSelector: _EXEC_SELECTOR,
associatedFunction: ManifestFunction({
functionType: ManifestAssociatedFunctionType.RUNTIME_VALIDATION_ALWAYS_ALLOW,
functionId: 0,
dependencyIndex: 0
})
})
);
}


function test_preExecHook_run() public {
test_preExecHook_install();

vm.expectEmit(true, true, true, true);
emit ReceivedCall(
abi.encodeWithSelector(
IPlugin.preExecutionHook.selector,
_PRE_HOOK_FUNCTION_ID_1, // functionId
address(this), // caller
0, // msg.value in call to account
abi.encodeWithSelector(_EXEC_SELECTOR) // 下面对应这个函数,这个函数之前会调用 preExecutionHook
),
0 // msg value in call to plugin
);

// 这里执行了一个函数调用,调用的是 account 合约的 _EXEC_SELECTOR 函数
(bool success,) = address(account).call(abi.encodeWithSelector(_EXEC_SELECTOR)); // 这里是执行了一个函数调用
assertTrue(success);
}

MSCAFactoryFixture createAccount

test/mocks/MSCAFactoryFixture.sol
    function createAccount(address owner, uint256 salt) public returns (UpgradeableModularAccount) {
address addr = Create2.computeAddress(getSalt(owner, salt), _PROXY_BYTECODE_HASH);

// short circuit if exists
if (addr.code.length == 0) {
address[] memory plugins = new address[](1);
plugins[0] = address(singleOwnerPlugin);
bytes32[] memory pluginManifestHashes = new bytes32[](1);
pluginManifestHashes[0] = keccak256(abi.encode(singleOwnerPlugin.pluginManifest()));
bytes[] memory pluginInstallData = new bytes[](1);
pluginInstallData[0] = abi.encode(owner);
// not necessary to check return addr since next call will fail if so
new ERC1967Proxy{salt: getSalt(owner, salt)}(address(accountImplementation), "");

// point proxy to actual implementation and init plugins
UpgradeableModularAccount(payable(addr)).initialize(plugins, pluginManifestHashes, pluginInstallData);
}

return UpgradeableModularAccount(payable(addr));
}

UpgradeableModularAccount _doPreExecHooks 取钩子遍历执行

src/account/UpgradeableModularAccount.sol
function _doPreExecHooks(bytes4 selector, bytes calldata data)
internal
returns (PostExecToRun[] memory postHooksToRun)
{
HookGroup storage hooks = getAccountStorage().selectorData[selector].executionHooks;
uint256 preExecHooksLength = hooks.preHooks.length();
uint256 postOnlyHooksLength = hooks.postOnlyHooks.length();
uint256 maxPostExecHooksLength = postOnlyHooksLength;

// There can only be as many associated post hooks to run as there are pre hooks.
for (uint256 i = 0; i < preExecHooksLength;) {
(, uint256 count) = hooks.preHooks.at(i);
unchecked {
maxPostExecHooksLength += (count + 1);
++i;
}
}

// Overallocate on length - not all of this may get filled up. We set the correct length later.
postHooksToRun = new PostExecToRun[](maxPostExecHooksLength);
uint256 actualPostHooksToRunLength;

// Copy post-only hooks to the array.
for (uint256 i = 0; i < postOnlyHooksLength;) {
(bytes32 key,) = hooks.postOnlyHooks.at(i);
postHooksToRun[actualPostHooksToRunLength].postExecHook = _toFunctionReference(key);
unchecked {
++actualPostHooksToRunLength;
++i;
}
}

// Then run the pre hooks and copy the associated post hooks (along with their pre hook's return data) to
// the array.
for (uint256 i = 0; i < preExecHooksLength;) {
(bytes32 key,) = hooks.preHooks.at(i);
FunctionReference preExecHook = _toFunctionReference(key);

if (preExecHook.isEmptyOrMagicValue()) {
// The function reference must be PRE_HOOK_ALWAYS_DENY in this case, because zero and any other
// magic value is unassignable here.
revert AlwaysDenyRule();
}
// 这里执行 preExecHook
bytes memory preExecHookReturnData = _runPreExecHook(preExecHook, data);

uint256 associatedPostExecHooksLength = hooks.associatedPostHooks[preExecHook].length();
if (associatedPostExecHooksLength > 0) {
for (uint256 j = 0; j < associatedPostExecHooksLength;) {
(key,) = hooks.associatedPostHooks[preExecHook].at(j);
postHooksToRun[actualPostHooksToRunLength].postExecHook = _toFunctionReference(key);
postHooksToRun[actualPostHooksToRunLength].preExecHookReturnData = preExecHookReturnData;

unchecked {
++actualPostHooksToRunLength;
++j;
}
}
}

unchecked {
++i;
}
}

// Trim the post hook array to the actual length, since we may have overallocated.
assembly ("memory-safe") {
mstore(postHooksToRun, actualPostHooksToRunLength)
}
}

ResultConsumerPlugin(address(account))

test/account/AccountReturnData.t.sol
    function test_returnData_execFromPlugin_fallback() public {
// 这里调用了插件的函数,但是调用者是 account,而不是插件。这是因为 account 是一个 UpgradeableModularAccount 合约实例,它实现了 IPluginExecutor 接口。所以它可以调用 IPluginExecutor 的 executeFromPlugin 函数。
// checkResultEFPFallback 是 ResultConsumerPlugin 的一个函数,它接收了一个 bytes32 类型的参数,返回一个 bool 类型的结果。
// ResultConsumerPlugin将 address(account) 作为参数传递给了 ResultCreatorPlugin 的 checkResultEFPFallback 函数。


// ???啥玩意
bool result = ResultConsumerPlugin(address(account)).checkResultEFPFallback(keccak256("bar"));

assertTrue(result);
}

ResultConsumerPlugin

contract ResultConsumerPlugin is BaseTestPlugin {
ResultCreatorPlugin public immutable resultCreator;
RegularResultContract public immutable regularResultContract;

constructor(ResultCreatorPlugin _resultCreator, RegularResultContract _regularResultContract) {
resultCreator = _resultCreator;
regularResultContract = _regularResultContract;
}

// Check the return data through the executeFromPlugin fallback case
// 通过executeFromPlugin回退案例检查返回数据
function checkResultEFPFallback(bytes32 expected) external returns (bool) {
// 这行代码意味着调用者(msg.sender)是一个实现了IPluginExecutor接口的合约。
IPluginExecutor(msg.sender).executeFromPlugin(abi.encodeCall(ResultCreatorPlugin.foo, ()));

// 这里调用了插件的函数,但是调用者是 account,而不是插件。这是因为 account 是一个 UpgradeableModularAccount 合约实例,它实现了 IPluginExecutor 接口。所以它可以调用 IPluginExecutor 的 executeFromPlugin 函数。
bytes32 actual = ResultCreatorPlugin(msg.sender).foo();

return actual == expected;
}

// Check the rturn data through the executeFromPlugin std exec case
// 通过executeFromPlugin std exec案例检查返回数据
function checkResultEFPExternal(address target, bytes32 expected) external returns (bool) {
// This result should be allowed based on the manifest permission request
// 这个结果应该是允许的,因为它符合插件清单的权限请求
bytes memory returnData = IPluginExecutor(msg.sender).executeFromPluginExternal(
target, 0, abi.encodeCall(RegularResultContract.foo, ())
);
// 把数据进行解析了,拿到数据
bytes32 actual = abi.decode(returnData, (bytes32));

return actual == expected;
}

function onInstall(bytes calldata) external override {}

function onUninstall(bytes calldata) external override {}

function pluginManifest() external pure override returns (PluginManifest memory) {

}

function _innerPluginManifest() internal view returns (PluginManifest memory) {
}
}

solidity 合约中有时候会进行合约类型转化以使用合约的方法,但是内部却是被转化对象方法的调用?

是的,你的理解是正确的。这段代码是 Solidity 测试代码的一部分,用于测试智能合约的行为。让我们逐步分析这段代码。

第一部分:测试函数

function test_executeFromPluginExternal_Allowed_IndividualSelectors() public {
// 调用setNumberCounter1
// 这行代码意味着调用者(msg.sender)是一个实现了IPluginExecutor接口的合约。
EFPCallerPlugin(address(account)).setNumberCounter1(17);
// 调用getNumberCounter1
uint256 retrievedNumber = EFPCallerPlugin(address(account)).getNumberCounter1();

assertEq(retrievedNumber, 17);
}

这部分代码是一个测试函数,它的目的是验证 EFPCallerPlugin 合约的 setNumberCounter1getNumberCounter1 方法是否正常工作。

  1. EFPCallerPlugin(address(account)).setNumberCounter1(17);:这行代码将 account 地址转换为 EFPCallerPlugin 类型,并调用 setNumberCounter1 方法,设置一个值为 17 的状态变量。
  2. uint256 retrievedNumber = EFPCallerPlugin(address(account)).getNumberCounter1();:然后,它再次将 account 地址转换为 EFPCallerPlugin 类型,并调用 getNumberCounter1 方法,检索之前设置的状态变量。
  3. assertEq(retrievedNumber, 17);:最后,使用 assertEq 函数来验证检索到的值是否为 17,以确保状态变量被正确设置和返回。

第二部分:合约函数

function setNumberCounter1(uint256 number) external {
// 这个函数调用了executeFromPluginExternal,这个函数是一个插件执行器的接口,它允许插件调用其他合约的方法。
// 插件调用其他合约的方法时,需要通过插件执行器来进行,这样可以确保插件的调用是受控的和符合系统规则的。
IPluginExecutor(msg.sender).executeFromPluginExternal(
// account 是一个 UpgradeableModularAccount 合约实例,它实现了 IPluginExecutor 接口。
// 所以它可以调用 IPluginExecutor 的 executeFromPlugin 函数。
counter1, 0, abi.encodeWithSelector(Counter.setNumber.selector, number)
);
}

这部分代码是 setNumberCounter1 函数的定义,它是 EFPCallerPlugin 合约的一部分。

  1. setNumberCounter1 被外部调用时,它使用 msg.sender(调用者的地址)来调用 IPluginExecutor 接口的 executeFromPluginExternal 方法。
  2. executeFromPluginExternal 方法被设计为由插件执行器调用,它允许合约通过插件执行器来间接调用其他合约的方法。这样做的目的是确保插件的调用是受控的并且符合系统规则。
  3. 在调用 executeFromPluginExternal 时,它传递了几个参数:counter1(可能是一个状态变量或合约地址),0(可能是发送的以太数量),以及一个编码后的调用数据,该数据使用 Counter.setNumber.selectornumber 参数编码,表示它希望在 Counter 合约上调用 setNumber 方法,并传递 number 作为参数。

总的来说,这段代码展示了如何在 Solidity 测试中模拟合约间的互动,以及如何使用接口和委托调用来确保合约调用的合规性。

为什么调用接口的方法,调用接口的方法,就是调用调用者地址上已经实现的这个方法?

当 setNumberCounter1 被外部调用时,它使用 msg.sender(调用者的地址)来调用 IPluginExecutor 接口的 executeFromPluginExternal 方法

为什么调用接口的方法,调用接口的方法,就是调用调用者地址上已经实现的这个方法?

在 Solidity 中,当你在合约中调用一个接口的方法,你实际上是在告诉编译器:“我期望调用的地址实现了这个接口中定义的方法。”当你使用msg.sender作为接口的实例时,你是在声明:“我期望发送者(msg.sender)是一个合约,并且它实现了这个接口。”

这就是为什么在setNumberCounter1函数中,使用IPluginExecutor(msg.sender)是基于一个假设,即调用这个函数的实体(msg.sender)是一个合约,并且这个合约实现了IPluginExecutor接口。

function setNumberCounter1(uint256 number) external {
IPluginExecutor(msg.sender).executeFromPluginExternal(
counter1, 0, abi.encodeWithSelector(Counter.setNumber.selector, number)
);
}

在这个上下文中,executeFromPluginExternalIPluginExecutor接口的一部分。当外部实体调用setNumberCounter1时,这个实体必须是一个合约,而且这个合约必须实现了IPluginExecutor接口。换句话说,这个实体必须有一个executeFromPluginExternal方法。

这就是为什么当你调用一个接口的方法时,实际上你是在调用绑定到特定地址的合约的方法,前提是该合约实现了该接口。Solidity 不会在编译时检查这个,而是在运行时进行检查。如果msg.sender没有实现该接口,调用将失败并抛出异常。

因此,在设计智能合约时,你需要确保只有那些实现了正确接口的合约才能调用某些函数。这通常是通过要求调用者(msg.sender)满足某些条件来实现的,比如在合约的构造函数中预先设定的地址,或者通过角色基的访问控制等。在你的示例中,这个安全性是通过假设msg.sender是一个已知实现了IPluginExecutor接口的合约来保证的。

合约之间如何通过接口实现合约调用?

在 Solidity 中,接口是定义一组函数的方式,而不实现它们。它们是合约之间通信的规范,允许不同的合约彼此交互,而无需知道对方的内部实现细节。

假设你有两个合约,Caller合约需要调用Receiver合约的函数。首先,你会定义一个接口,其中包含了Receiver合约中你希望从Caller合约中调用的函数。

步骤 1: 定义接口

// IReceiver.sol

interface IReceiver {
function receiveData(uint data) external;
}

步骤 2: 实现接口

然后在Receiver合约中实现这个接口。

// Receiver.sol

contract Receiver is IReceiver {
uint public data;

function receiveData(uint _data) external override {
data = _data;
}
}

步骤 3: 在另一个合约中使用接口

Caller合约中,你可以使用IReceiver接口来调用Receiver合约。

// Caller.sol

import "./IReceiver.sol";

contract Caller {
function callReceiveData(address receiverAddress, uint data) public {
IReceiver receiver = IReceiver(receiverAddress);
receiver.receiveData(data);
}
}

在上面的例子中,Caller合约通过IReceiver接口与Receiver合约进行交互。Caller合约不需要知道Receiver合约的内部实现,它只需要知道Receiver合约的地址和接口。

Caller合约的callReceiveData函数被调用时,它会创建一个指向receiverAddressIReceiver类型的实例,并调用receiveData函数。如果receiverAddress指向的合约实现了IReceiver接口,那么调用就会成功执行。

注意事项

  • 确保Receiver合约的地址是正确的,否则调用将失败。
  • 调用外部合约的函数可能会失败,需要有适当的错误处理机制。
  • 如果接口函数需要消耗 gas,调用者合约需要发送足够的 gas 来执行调用。
  • 为了安全起见,通常会有权限检查,确保只有授权的合约或账户可以调用特定函数。

使用接口可以有效地实现合约之间的解耦,方便合约的维护和升级。

一个合约调用另一个合约有哪些方案?

在 Solidity 中,一个合约调用另一个合约的方法主要有以下几种方式:

1. 直接调用

如果你知道另一个合约的函数和布局,你可以直接在合约代码中调用这些函数。这通常是通过定义一个合约类型的变量并将其实例化为目标合约的地址来完成的。

contract OtherContract {
function someFunction(uint _x) public returns (uint) {
// ...
}
}

contract MyContract {
OtherContract otherContract;

constructor(address _otherContractAddress) {
otherContract = OtherContract(_otherContractAddress);
}

function callOtherContractFunction(uint _x) public returns (uint) {
return otherContract.someFunction(_x);
}
}

2. 使用接口

这是一种更灵活的方式,你可以定义一个接口来抽象目标合约的函数,然后使用该接口来进行调用。

interface IOtherContract {
function someFunction(uint _x) external returns (uint);
}

contract MyContract {
function callOtherContractFunction(address _otherContractAddress, uint _x) public returns (uint) {
IOtherContract otherContract = IOtherContract(_otherContractAddress);
return otherContract.someFunction(_x);
}
}

3. 使用call函数

call是一个底层函数,可以用来调用合约的函数。它不安全,因为它不进行类型检查,但它可以用来调用不在编译时已知的合约的函数。

contract MyContract {
function callOtherContractFunction(address _otherContractAddress, bytes4 _selector, uint _x) public returns (bool, bytes memory) {
(bool success, bytes memory data) = _otherContractAddress.call(abi.encodeWithSelector(_selector, _x));
return (success, data);
}
}

4. 使用delegatecall

delegatecall是一个底层函数,类似于call,但它在当前合约的上下文中执行代码,意味着msg.sender和存储将保持不变。这通常用于代理合约和库合约。

contract MyContract {
function delegateOtherContractFunction(address _otherContractAddress, bytes4 _selector) public returns (bool, bytes memory) {
(bool success, bytes memory data) = _otherContractAddress.delegatecall(abi.encodeWithSelector(_selector));
return (success, data);
}
}

5. 使用staticcall

staticcallcall类似,但它保证调用不会修改状态,即它是只读的。

contract MyContract {
function staticCallOtherContractFunction(address _otherContractAddress, bytes4 _selector) public view returns (bool, bytes memory) {
(bool success, bytes memory data) = _otherContractAddress.staticcall(abi.encodeWithSelector(_selector));
return (success, data);
}
}

注意事项

  • 直接调用和使用接口是类型安全的,而calldelegatecallstaticcall不是。
  • calldelegatecallstaticcall返回两个值:一个布尔值指示调用是否成功,以及一个字节序列包含任何返回的数据。
  • 使用calldelegatecallstaticcall需要你手动处理 ABI 编码和解码。
  • delegatecallstaticcall应该谨慎使用,因为它们有潜在的安全风险。

选择哪种方案取决于具体的应用场景和安全要求。在大多数情况下,建议使用接口进行合约间的调用,因为它提供了类型安全且易于理解的抽象层。