Cairo 并没有提供 Solidity 中包含的全部整数大小。Solidity 提供了最高可达 256 位的每一个 8 的倍数的整数类型,而 Cairo 仅支持以下整数类型:
u8u16u32u64u128u256
对于熟悉 Rust 的读者来说,usize 类型就是一个 u32。
下面是一个将两个 u256 数字相加的 Cairo 合约示例:
#[starknet::interface]
pub trait IAdd<TContractState> {
fn add(self: @TContractState, a: u256, b: u256) -> u256;
}
#[starknet::contract]
mod Add {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl AddImpl of super::IAdd<ContractState> {
fn add(self: @ContractState, a: u256, b: u256) -> u256 {
a + b
}
}
}
Cairo 还支持以下类型的有符号整数:
i8i16i32i64i128
请注意,不支持 i256。
在本文中,我们将介绍整数在 Cairo 中的工作原理,重点强调与 Solidity 的主要区别。我们将探讨溢出行为、整数大小之间的类型转换以及有符号和无符号值的处理等概念。我们还会涉及 felt252,这是 Cairo 的原生域元素(field element),也是所有数值运算的核心。
整数具有溢出和下溢保护
对于整数(有符号和无符号)类型,Cairo 默认启用了溢出保护。为了验证这一点,请创建一个新的 Scarb 项目 scarb new integers,然后删除 ./src/lib.cairo 中的默认合约并替换为以下代码:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn underflow(ref self: TContractState, x: u256, y: u256) -> u256;
}
#[starknet::contract]
mod HelloStarknet {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn underflow(ref self: ContractState, x: u256, y: u256) -> u256 {
x - y
}
}
}
移除自动生成的测试,并在下方添加以下代码。请注意,函数上方的 #[should_panic] 宏指定了如果执行出现 panic,则测试通过。
use snforge_std::{ContractClassTrait, DeclareResultTrait, declare};
use integers::{ IHelloStarknetDispatcher, IHelloStarknetDispatcherTrait};
use starknet::ContractAddress;
fn deploy_contract(name: ByteArray) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap();
contract_address
}
#[test]
#[should_panic]
fn test_flow_protection() {
let contract_address = deploy_contract("HelloStarknet");
let dispatcher = IHelloStarknetDispatcher { contract_address };
dispatcher.underflow(0, 1);
}
使用 scarb test 运行测试,可以注意到测试通过了。
没有浮点数
与 Solidity 一样,Cairo 不支持浮点数
整数类型转换
类型转换(Casting)分为两种:
- 保证能够成功的转换;以及
- 可能会失败的转换。
例如,将 u8 转换为 u16 总是会成功,因为 u16 可以容纳 u8 能够容纳的任何数字。然而,从 u16 转换到 u8 可能会失败,因为某些最高有效位可能会被截断。
如果 i16 包含负值,那么从 i16 转换到 u16 会失败。如果 u16 中包含的数字太大,从 u16 转换到 i16 也可能会失败——相同位数的无符号整数比有符号整数能容纳更大的正数。
总是成功的转换
向更大类型的转换总是会成功,因为目标类型可以容纳源类型的任何值。
总是成功的转换示例:
u8→u16,u32,u64,u128,u256u16→u32,u64,u128,u256i8→i16,i32,i64,i128i16→i32,i64,i128
要执行总是成功的类型转换,请使用 .into()
let small: u8 = 7;
let large: u16 = small.into(); // Always succeeds - u16 can hold any u8 value
可能会失败的转换
当较大数值转换为较小类型时,Solidity 会静默截断最高有效位,而 Cairo 与之不同,如果无法安全地执行转换,它将会引发 panic。
以下是可能会失败的类型转换代码片段:
// may fail if value is too large
let large: u16 = 300;
let small: u8 = large.try_into().unwrap(); // Panics! 300 > 255
上面的代码会引发 panic,因为 u8 只能存储不超过 255 的值,无法容纳 300 这个值。目前只需记住,unwrap() 方法可以从“包装”(wrapper)类型(将在下一小节讨论)中提取值。如果该包装器包含错误而不是有效值,unwrap() 就会引发 panic。
请注意,如果您试图在可能失败的转换场景(将大类型转换为小类型)中使用 into() 转换,代码将无法通过编译。
检测转换是否会失败
当使用 try_into() 在整数类型之间进行转换时,结果可能适合也可能不适合目标类型。因此,该转换操作会返回一个 Option(“包装”类型的一个例子),这实际上是 Cairo 在表达*“这可能会成功,也可能会失败。”*
- 如果转换成功,您会得到
Option::Some(value)。 - 如果容纳不下,您会得到
Option::None。
这就迫使我们在使用该值之前安全地处理可能的失败。两种常见的处理方法是:
- 使用
.is_some()方法,它返回一个 bool 值来指示该 Option 是否包含某个值。 - 使用
if let。
使用 .is_some() 方法:
// Value 300 cannot fit into u8 (max 255), so try_into returns None
let value: u16 = 300;
let result_option: Option<u8> = value.try_into();
if result_option.is_some() {
// cast succeeded
} else {
// cast failed
}
使用 if let:
if let Some(result) = result_option {
// cast succeeded, use `result`
} else {
// cast failed
}
常量
在 Cairo 中,常量(Constants)是在编译时已知的、且在运行时无法更改的值。它们使用 const 关键字在 mod 块内部声明,并且必须明确指定其类型,如下所示:
const <NAME>: <Type> = <value>;
下面展示了如何在 Cairo 中声明和使用常量:
#[starknet::contract]
mod HelloStarknet {
// DECLARE CONSTANTS
const num_one: u256 = 1;
const num_two: i8 = -2;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn get_one(self: @ContractState) -> u256 {
// USE CONSTANTS
num_one
}
fn get_two(self: @ContractState) -> i8 {
// USE CONSTANTS
num_two
}
}
}
常量 (Constants) 与不可变变量 (Immutables)
与 Solidity 不同,Cairo 没有单独的 immutable 关键字。需要在合约部署期间设置一次但在编译时未知的那些值,应当存储在合约存储(storage)中,并在构造函数中进行设置。
整数最大值
在 Solidity 中,通常使用 type(uint256).max 来获取整数的最大值。而在 Cairo 中,我们使用 let max_u256: u256 = Bounded::MAX,如下所示:
#[starknet::contract]
mod HelloStarknet {
use core::num::traits::{Bounded}; // Bounded is how we get the max
#[storage]
struct Storage {} // unusued
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn max_demo(ref self: ContractState) -> u256 {
let max_u256: u256 = Bounded::MAX;
max_u256
}
}
}
在上述代码中,合约首先导入了 Bounded trait,它提供了对数值常量 MIN 和 MAX 的访问。MAX 会返回该类型允许的最大值。在 max_demo() 函数内部,我们使用 Bounded::MAX 来获取 u256 类型的最大值。
在大多数情况下,Cairo 能够自行推断类型,但当使用 Bounded::MAX 时,编译器无法自动知道您需要求最大值的是哪种整数类型。因此,变量需要显式的类型注解,即 : 后面的 u256,例如 let max_u256: u256 = Bounded::MAX;。
整数最小值
正如我们能获取整数类型的最大值一样,Cairo 也通过 Bounded trait 的 Bounded::MIN 提供了访问它们最小值的方法,如下所示:
#[starknet::contract]
mod HelloStarknet {
use core::num::traits::{Bounded}; // Bounded provides both MIN and MAX
#[storage]
struct Storage {} // unused
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn min_demo(ref self: ContractState) -> (u256, i128) {
// This will be 0 for unsigned types
let min_u256: u256 = Bounded::MIN;
// This will be the most negative value
let min_i128: i128 = Bounded::MIN;
(min_u256, min_i128)
}
}
}
理解最小值
对于无符号整数类型(u8, u16, u32, u64, u128, u256),其最小值始终为 0:
let min_u8: u8 = Bounded::MIN; // 0
let min_u16: u16 = Bounded::MIN; // 0
let min_u32: u32 = Bounded::MIN; // 0
let min_u64: u64 = Bounded::MIN; // 0
let min_u128: u128 = Bounded::MIN; // 0
let min_u256: u256 = Bounded::MIN; // 0
对于有符号整数类型(i8, i16, i32, i64, i128),其最小值是所能表示的最负数:
let min_i8: i8 = Bounded::MIN; // -128
let min_i16: i16 = Bounded::MIN; // -32,768
let min_i32: i32 = Bounded::MIN; // -2,147,483,648
let min_i64: i64 = Bounded::MIN; // -9,223,372,036,854,775,808
let min_i128: i128 = Bounded::MIN; // a very large negative value
类型注解要求
与 Bounded::MAX 一样,编译器在使用 Bounded::MIN 时也无法自动猜测类型,因此需要显式的类型注解:
// This won't compile - ambiguous type ❌
let min_val = Bounded::MIN;
// This will compile - explicit type annotation ✅
let min_val: u64 = Bounded::MIN;
整数类型指定的简写形式
如果我们将一个固定值赋给整数,我们可以显式指定整数的类型,也可以让编译器推断其类型。
显式指定类型:
// first way
let x: i32 = 10;
// second way
let y = 10_i32;
让编译器推断类型:
如果我们不指定类型,编译器将尝试根据上下文进行推断。例如,以下函数返回 u32,因此 10 的类型会被推断为 u32:
fn hello_world() -> u32 {
let x = 10;
x
}
有符号整数除法溢出
在 Solidity 中,有符号整数除法存在一个特定的边缘情况,可能会引发意想不到的行为。请看以下 Solidity 合约:
contract D {
function div(int8 a, int8 b) public pure returns (int8 c) {
c = a / b;
}
}
当您将最负的值除以 -1 时就会发生该问题。对于 int8,其范围是 -128 到 127。当您执行 -128 / -1 时,从数学上讲,结果应该是 128,但是 128 无法放入 int8 中(其最大值为 127)。这就导致了溢出。
在 Solidity 中,此操作要么:
- 回绕(Wrap around)成一个意料之外的值
- 发生 Revert(在具有溢出保护的较新版本中)
Cairo 如何处理整数除法溢出
与 Solidity ≥ 0.8 的版本一样,Cairo 提供了内置的溢出保护。如果操作会导致溢出,程序将在运行时引发 panic,从而防止产生非预期的行为。
#[starknet::contract]
mod Div {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl DivImpl of super::IDiv<ContractState> {
fn div(self: @ContractState, a: i8, b: i8) -> i8 {
// This will panic if `a` is -128 and `b` is -1
a / b
}
}
}
为了防止有符号除法溢出引发的 panic,我们需要在执行操作之前手动检查条件,如下所示:
#[starknet::contract]
mod Div {
use core::num::traits::Bounded;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl DivImpl of super::IDiv<ContractState> {
fn div(self: @ContractState, a: i8, b: i8) -> i8 {
if b == 0 {
// Division by zero
} else if a == Bounded::<i8>::MIN && b == -1 {
// Overflow case
} else {
a / b
}
}
}
}
向上转换失败
这个 Solidity 函数看起来很安全,但可能会产生意想不到的结果:
function mul(uint8 a, uint8 b) public pure returns (uint256 c) {
c = a * b;
}
问题在于,乘法 a * b 会首先在 uint8 算术中执行,然后再将结果转换为 uint256。如果 a * b 超出了 uint8 的范围(0-255),乘法结果在向上转换之前就已经发生了回绕。
例如:
- 从数学上讲,
mul(200, 200)应当返回40000 - 但是
200 * 200 = 40000会让uint8溢出(最大值 255) - 在
uint8中的回绕结果将是40000 % 256 = 64 - 随后
64被转换为uint256,最终返回64而不是40000。当然,这是在低于 0.8 的 Solidity 版本中才会发生的情况
Cairo 中的溢出
Cairo 通过其内置的溢出保护来处理向上转换中的溢出问题。也就是说,如果操作产生的值超出了允许的范围,Cairo 会抛出错误并停止执行,而不是允许非预期行为发生。
例如,如果 a * b > 255,下面的代码将会引发 panic:
// This will panic if the multiplication overflows u8
fn mul(self: @ContractState, a: u8, b: u8) -> u256 {
let result_u8 = a * b; // Panic if a * b > 255
result_u8.into() // This line never executes if overflow occurs
}
安全的向上转换
为了避免 Cairo 合约因溢出而引发 panic,一种安全的做法是:当我们需要一个更大类型的结果时,在进行算术运算之前先进行向上类型转换。例如,我们可以先将 u8 转换为 u256:
// cast up before multiplication
fn safe_mul(self: @ContractState, a: u8, b: u8) -> u256 {
let a_wide: u256 = a.into();
let b_wide: u256 = b.into();
a_wide * b_wide // No overflow possible
}
指数
在 Solidity 中,指数的语法是 b ** e,其中 b 是底数,e 是指数。
在 Cairo 中,您必须使用 use core::num::traits::Pow; 导入 Pow。然后,您就可以使用 b.pow(e) 将整数提升到相应的幂。
#[starknet::contract]
mod HelloStarknet {
use core::num::traits::Pow; // THIS IMPORT IS REQUIRED
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn upcast_demo(ref self: ContractState, x: u256, y: u32) -> u256 {
x.pow(y) // compute exponent
}
}
}
.pow() 方法会返回一个与底数类型相同的值,因此此处的 x.pow(y) 会生成一个类型为 (u256) 的值。
重要提示: 指数的类型必须是 u32(或者是 usize,在 Cairo 底层它也是一个 u32)。如果使用其他整数类型,代码将无法通过编译。
字面量中的下划线
与 Solidity 类似,Cairo 中的大数字可以用下划线分隔,以提高可读性:
// valid Cairo
let basis_points = 10_000;
科学计数法简写
在 Solidity 中,您可以使用科学计数法书写数字,例如 10e18,它表示 。Cairo 不支持这种写法。要在 Cairo 中写入 10e18,请使用 Pow trait,如下所示:
use core::num::traits::Pow;
// ...
let num = 10_u256.pow(18_u32);
请记住,指数的类型必须是 u32。
位运算、移位运算和比较运算
位运算
Cairo 支持对整数类型进行标准的位运算:
按位与(&):
let a: u8 = 0b1100; // 12 in decimal
let b: u8 = 0b1010; // 10 in decimal
let result = a & b; // 0b1100 & 0b1010 = 0b1000 => 8
按位或(|):
let a: u8 = 0b1100; // 12 in decimal
let b: u8 = 0b1010; // 10 in decimal
let result = a | b; // 0b1110 = 14
按位异或(^):
let a: u8 = 0b1100; // 12 in decimal
let b: u8 = 0b1010; // 10 in decimal
let result = a ^ b; // 0b0110 = 6
按位非(~):
let a: u8 = 0b1100; // 12 in decimal
let result = ~a; // 0b11110011 = 243 (inverts all bits)
移位运算
Cairo 提供了左移和右移位操作:
左移(<<)
将位向左移动,并用零填充:
let a: u8 = 0b0001; // 1 in decimal
let result = a << 3; // 0b1000 = 8 (multiplies by 2**3)
右移(>>)
将位向右移动:
let a: u8 = 0b1100; // 12 in decimal
let result = a >> 2; // 0b0011 = 3 (divides by 2**2)
比较运算
Cairo 支持所有标准比较运算符:
相等性(== 和 !=)
let a: u32 = 10;
let b: u32 = 20;
let equal = a == b; // false
let not_equal = a != b; // true
排序(<, <=, >, >=)
let a: u32 = 10;
let b: u32 = 20;
let less_than = a < b; // true
let less_or_equal = a <= b; // true
let greater_than = a > b; // false
let greater_or_equal = a >= b; // false
关于 felt252 的注意事项
如果您阅读较早的 Cairo 生产级代码,会频繁看到被使用的 felt252 数据类型。正如 EVM 的默认字长为 256 位一样,CairoVM 的默认字长略小于 252 位,确切地说是:3618502788666131213697322783095070105623107215331596699973092056135872020481 或 2²⁵¹+17⋅2¹⁹²+1。这个数值略小于 2²⁵²。
Cairo 将取值范围在 [0…2²⁵¹+17⋅2¹⁹²+1] 内的数字类型称为 felt252。
这个大数字是一个质数,针对 Cairo 虚拟机上的零知识证明数学运算进行了优化。
felt252 这个名称来源于“适合 252 位的域元素(field element that fits in 252 bits)”。“域元素”存在于这样一个数字系统中:所有的加法和乘法都是在这个域内对某个质数取模完成的。
不建议在 Cairo 代码中使用 felt252,因为在日后,CairoVM 可能会将其默认字长更改为一个更小的值,以提高证明交易的速度。
Cairo 编译器会在后台自动无缝地帮您处理将整数(u8… u256)转换为 felt252 的操作。值得注意的是,u256 容纳不下 252 位,因此在底层,一个 u256 实际上是由两个 felt252 元素组成的。因此,出于节省 Gas 的考虑,最好尽可能使用 u128 或更小的整数。除此以外,只有在需要进行极限优化时,使用 felt252 才是有意义的。 我们将在以后的教程中重新探讨 Starknet 上的 Gas 成本。目前,我们建议您不要使用 felt252 类型,只需使用普通整数即可。
然而,由于您在代码中会频繁遇到 felt252,因此有必要解释一下它是如何工作的。
felt252 没有溢出和下溢保护
与 Solidity 0.8.0 或更高版本不同,Cairo 没有为 felt252 内置溢出和下溢保护。为了证明这一点,请创建一个新项目 scarb new numbers。然后用以下代码替换 lib.cairo 中生成的代码:
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn math_demo(self: @TContractState, x: felt252, y: felt252) -> felt252;
}
#[starknet::contract]
mod HelloStarknet {
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn math_demo(self: @ContractState, x: felt252, y: felt252) -> felt252 {
x - y
}
}
}
将测试代码替换如下:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use numbers::IHelloStarknetDispatcher;
use numbers::IHelloStarknetDispatcherTrait;
fn deploy_contract(name: ByteArray) -> ContractAddress {
let contract = declare(name).unwrap().contract_class();
let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap();
contract_address
}
#[test]
fn test_math_demo() {
let contract_address = deploy_contract("HelloStarknet");
let dispatcher = IHelloStarknetDispatcher { contract_address };
let result = dispatcher.math_demo(0, 1); // 0 - 1
println!("result: {}", result);
}
控制台将会输出:
result: 3618502788666131213697322783095070105623107215331596699973092056135872020480
在低于 0.8.0 的 Solidity 中,如果进行无符号算术运算,0 - 1 会导致下溢。随后该值会回绕为可能的最大 uint256 值。由于 Cairo 中没有溢出或下溢保护,类似的事情也会发生在 felt252 上。所有的算术运算都是在对域的质数(2²⁵¹ + 17 × 2¹⁹² + 1)取模后进行的,因此从 0 中减去 1 将返回最大的有效 felt252 值,看起来就是一个巨大的数字。
felt252_div
如果您尝试用一个 felt252 除以另一个 felt252,就会收到编译错误。以下代码将无法通过编译:
fn math_demo(self: @ContractState, x: felt252, y: felt252) -> felt252 {
x / y
}
要完全理解为什么 Cairo 不允许这样的除法,请观看我们关于模算术(modular arithmetic)的视频。
felt252 的正确除法运算
要在 felt252 值之间执行除法,我们必须使用 Cairo 核心库中的内置函数 felt252_div:
#[starknet::contract]
mod HelloStarknet {
// THIS IS NEW
use core::felt252_div;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
fn math_demo(self: @ContractState) -> felt252 {
felt252_div(4, 2)
}
}
}
felt252_div 函数执行的并不是常规的除法操作。相反,它会:
- 求出除数
y关于域质数的模逆元(modular inverse) - 将
x乘以这个逆元 - 返回对域质数取模的结果
在数学上表达为:
felt252_div(x, y) = x * y^(-1) mod P
其中 y^(-1) 是有限域中 y 的模逆元。
除以零
试图执行 felt252_div(x, 0) 将导致运行时 panic:
*// This will panic!*
let result = felt252_div(42, 0);
在执行除法之前,务必验证您的除数。felt252_div 确保 felt252 值不为零的一种方法是使用 NonZero<felt252>。
NonZero
felt252_div 函数要求它的第二个参数(除数)是 NonZero<felt252> 类型,而不是普通的 felt252。这可以在编译时防止除零错误。
// BE SURE TO CHANGE THE TRAIT DEFINITION ALSO
#[starknet::contract]
mod HelloStarknet {
use core::felt252_div;
#[storage]
struct Storage {}
#[abi(embed_v0)]
impl HelloStarknetImpl of super::IHelloStarknet<ContractState> {
// NOTE THE TYPE OF `y`
fn underflow_demo(self: @ContractState, x: felt252, y: NonZero<felt252>) -> felt252 {
felt252_div(x, y)
}
}
}
总结
Cairo 中的整数是安全的。所有 u* 和 i* 类型都具有溢出保护,并且会在执行无效操作时发生 panic。
类型转换非常严格:.into() 是安全的(转换为更大类型时总是成功),.try_into() 则会检查错误(如果目标类型无法容纳该值,则可能发生 panic)。
求幂运算通过导入 trait 来使用 .pow()。
位运算符和比较运算符在所有整数类型中都可以正常使用。
felt252 是 Cairo 的原生域元素,它没有像常规整数那样的溢出检查。对 felt 进行除法运算需要使用带零检查功能的函数(felt252_div)。
本文是 Starknet 上的 Cairo 编程 教程系列的一部分