往期回顧:
1. 浮點數運算的精度問題
不同於常見的智能合約編程語言Solidity
, Rust
語言原生支持浮點數運算。然而,浮點數運算存在著無法避免的計算精度問題。因此,我們在編寫智能合約時,並不推薦使用浮點數運算(尤其是在處理涉及到重要經濟/金融決策的比率或利率時)。
目前主流計算機語言表示浮點數大多遵循了IEEE 754標準,Rust語言也不例外。如下是Rust語言中有關雙精度浮點類型f64
的說明與計算機內部二進制數據保存形式:
浮點數採用了底數為2的科學計數法來表達。例如可以用有限位數的二進制數0.1101 來表示小數0.8125,具體的轉化方式如下:
0.8125 * 2 = 1 .625 // 0.1 获得第1位二进制小数为1
0.625 * 2 = 1 .25 // 0.11 获得第2位二进制小数为1
0.25 * 2 = 0 .5 // 0.110 获得第3位二进制小数为0
0.5 * 2 = 1 .0 // 0.1101 获得第4位二进制小数为1
即0.8125 = 0.5 * 1 + 0.25 * 1 + 0.125 * 0 + 0.0625 * 1
然而對於另一個小數0.7
來說,其實際轉化為浮點數的過程中將存在如下問題:
0.7 x 2 = 1. 4 // 0.1
0.4 x 2 = 0. 8 // 0.10
0.8 x 2 = 1. 6 // 0.101
0.6 x 2 = 1. 2 // 0.1011
0.2 x 2 = 0. 4 // 0.10110
0.4 x 2 = 0. 8 // 0.101100
0.8 x 2 = 1. 6 // 0.1011001
....
即小數0.7 將表示為0.101100110011001100.....(無限循環),無法用有限位長的浮點數來準確表示,並存在“舍入(Rounding)”現象。
假設在NEAR公鏈上,需要分發0.7個NEAR代幣給十位用戶,具體每位用戶分得的NEAR代幣數量將計算保存於result_0
變量中。
fn precision_test_float ( ) {
// 浮点数无法准确的表示整数
let amount: f64 = 0.7 ; // 次变量amount表示0.7个NEAR代币
let divisor: f64 = 10.0 ; // 定义除数
let result_0 = a / b; // 执行浮点数的除法运算
println!( 'The value of a: {:.20}' , a);
assert_eq!(result_0, 0.07 , '' );
}
執行該測試用例的輸出結果如下:
running 1 test
The value of a: 0.69999999999999995559
thread 'tests::precision_test_float' panicked at 'assertion failed: `(left == right)`
left : `0.06999999999999999`,
right : `0.07`: ', src/lib.rs:185:9
可見在上述浮點運算中, amount
的值並非準確地表示了0.7
,而是一個極為近似的值0.69999999999999995559
。進一步的,對於諸如amount/divisor
的單一除法運算,其運算結果也將變為不精確的0.06999999999999999
,並非預期的0.07
。由此可見浮點數運算的不確定性。
對此,我們不得不考慮在智能合約中使用其它類型的數值表示方法,如定点数
。
根據定點數小數點固定的位置不同,定點數有定點(純)整數和定點(純)小數兩種。
小數點固定在數的最低位之後,則稱其為定點整數。
在實際的智能合約編寫中,通常會使用一個具有固定分母的分數來表示某一數值,例如分數' x/N ',其中' N '是常數,' x '可以變化。
若“N”取值為“1,000,000,000,000,000,000”,也就是: ' 10^18 ',此時小數可被表示為整數,像這樣:
1 .0 - > 1_000_000_000_000_000_000
0 .7 - > 700_000_000_000_000_000
3 .14 - > 3_140_000_000_000_000_000
在NEAR Protocol中,該N常見的取值為' 10^24 ',即10^24 個yoctoNEAR
等價於1個NEAR
代幣。
基於此,我們可以將本小節的單元測試修改為如下方式進行計算:
fn precision_test_integer ( ) {
// 首先定义常数N,表示精度。
let N: u128 = 1 _000_000_000_000_000_000_000_000; // 即定义1 NEAR = 10^24 yoctoNEAR
// 初始化amount,实际此时amount所表示的值为700_000_000_000_000_000 / N = 0.7 NEAR;
let amount: u128 = 700 _000_000_000_000_000_000_000; // yoctoNEAR
// 初始化除数divisor
let divisor: u128 = 10 ;
// 计算可得:result_0 = 70_000_000_000_000_000_000_000 // yoctoNEAR
// 实际表示700_000_000_000_000_000_000_000 / N = 0.07 NEAR;
let result_0 = amount / divisor;
assert_eq!(result_0, 70 _000_000_000_000_000_000_000, '' );
}
以此可獲得數值精算的運算結果: 0.7 NEAR / 10 = 0.07 NEAR
running 1 test
test tests::precision_test_integer ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s
2. Rust整數計算精度的問題
從上文第1小節的描述中可以發現,使用整數運算可解決某些運算場景中浮點數運算精度丟失問題。
但這並非意味著使用整數計算的結果完全是準確可靠的。本小節將介紹影響整數計算精度的部分原因。
2.1 運算順序
同一算數優先級的乘法與除法,其前後順序的變化可能直接影響到計算結果,導致整數計算精度的問題。
例如存在如下運算:
#[test]
fn precision_test_div_before_mul() {
let a: u128 = 1_0000 ;
let b: u128 = 10_0000 ;
let c : u128 = 20 ;
// result_0 = a * c / b
let result_0 = a
.checked_mul( c )
.expect( 'ERR_MUL' )
.checked_div(b)
.expect( 'ERR_DIV' );
// result_0 = a / b * c
let result_1 = a
.checked_div(b)
.expect( 'ERR_DIV' )
.checked_mul( c )
.expect( 'ERR_MUL' );
assert_eq!(result_0,result_1, '' );
}
執行單元測試的結果如下:
running 1 test
thread 'tests::precision_test_0' panicked at 'assertion failed: `(left == right)`
left : `2`,
right : `0`: ', src/lib.rs:175:9
我們可以發現result_0 = a * c / b
及result_1 = (a / b)* c
儘管它們的計算公式相同,但是運算結果卻不同。
分析具體的原因為:對於整數除法而言,小於除數的精度會被捨棄。因此在計算result_1
的過程中,首先計算的(a / b) 會率先失去計算精度,變為0;而在計算result_0
時,會首先算得a * c
的結果20_0000
,該結果將大於除數b,因此避免了精度丟失的問題,可得到正確的計算結果。
2.2 過小的數量級
fn precision_test_decimals ( ) {
let a: u128 = 10 ;
let b: u128 = 3 ;
let c: u128 = 4 ;
let decimal : u128 = 100 _0000;
// result_0 = (a / b) * c
let result_0 = a
.checked_div(b)
.expect( 'ERR_DIV' )
.checked_mul(c)
.expect( 'ERR_MUL' );
// result_1 = (a * decimal / b) * c / decimal;
let result_1 = a
.checked_mul( decimal ) // mul decimal
.expect( 'ERR_MUL' )
.checked_div(b)
.expect( 'ERR_DIV' )
.checked_mul(c)
.expect( 'ERR_MUL' )
.checked_div( decimal ) // div decimal
.expect( 'ERR_DIV' );
println!( '{}:{}' , result_0, result_1);
assert_eq!(result_0, result_1, '' );
}
該單元測試的具體結果如下:
running 1 test
12 : 13
thread 'tests::precision_test_decimals' panicked at 'assertion failed: `(left == right)`
left : `12`,
right : `13`: ', src/lib.rs:214:9
可見運算過程等價的result_0
和result_1
運算結果並不相同,且result_1 = 13
更加地接近於實際預期的計算值:13.3333....
3. 如何編寫數值精算的Rust智能合約
保證正確的精度在智能合約中十分重要。儘管Rust語言中也存在整數運算結果精度丟失的問題,但我們可以採取如下一些防護手段來提高精度,達到令人滿意的效果。
3.1 調整運算的操作順序
令整數乘法優先於整數的除法。
3.2 增加整數的數量級
整數使用更大的數量級,創造更大的分子。
比如對於一個NEAR token來說,如果定義其上文所描述的N = 10,則意味著:若需要表示5.123的NEAR價值,則實際運算所採用的整數數值將表示為5.123* 10^10 = 51_230_000_000 。該值繼續參與後續的整數運算,可提高運算精度。
3.3 積累運算精度的損失
對於確實無法避免的整數計算精度問題,項目方可以考慮記錄累計的運算精度的損失。
假設如下使用fn distribute(amount: u128, offset: u128) -> u128
為USER_NUM
位用戶分發代幣的場景。
const USER_NUM: u128 = 3 ;
fn distribute ( amount: u128, offset: u128 ) -> u128 {
let token_to_distribute = offset + amount;
let per_user_share = token_to_distribute / USER_NUM;
println!( 'per_user_share {}' ,per_user_share);
let recorded_offset = token_to_distribute - per_user_share * USER_NUM;
recorded_offset
}
fn record_offset_test ( ) {
let mut offset: u128 = 0 ;
for i in 1. .7 {
println!( 'Round {}' ,i);
offset = distribute(to_yocto( '10' ), offset);
println!( 'Offset {}\n' ,offset);
}
}
在該測試用例中,系統每次將給3位用戶分發10個Token。但是,由於整數運算精度的問題,第一輪中計算per_user_share
時,獲得的整數運算結果為10 / 3 = 3
,即第一輪distribute
用戶將平均獲得3個token,總計9個token被分發。
此時可以發現,系統中還剩下1個token未能分發給用戶。為此可以考慮將該剩餘的token臨時保存在系統全局的變量offset
中。等待下次系統再次調用distribute
給用戶分發token時,該值將被取出,並嘗試和本輪分發的token金額一起分發給用戶。
如下為模擬的代幣分發過程:
running 1 test
Round 1
per_user_share 3
Offset1
Round 2
per_user_share 3
Offset 2
Round 3
per_user_share 4
Offset 0
Round 4
per_user_share 3
Offset 1
Round 5
per_user_share 3
Offset 2
Round 6
per_user_share 4
Offset 0
test tests::record_offset_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.00s
可見當系統開始第3輪地分發代幣時,此時系統積累的offset
值已達到2,該值將再次與本輪所要分發的10個token累加在一起,發放給用戶。 (本次計算per_user_share = token_to_distribute / USER_NUM = 12 / 3 = 4
將不存在精度損失。)
從整體上來看,在前3輪中,系統一共發放了30個Token。每個用戶在每一輪中分別獲得了3、3、4個token,此時用戶也總計獲得30個token,達到了系統足額發放獎金目的。
3.4 使用Rust Crate庫rust-decimal
該Rust庫適用於需要有效精度計算和沒有捨入誤差的小數金融計算。
3.5 考慮舍入機制
在設計智能合約時,在舍入問題上,往往都採用“我要佔便宜,他人不得薅我羊毛”的原則。根據這個原則,如果向下取整對我有利,則向下;如果向上取整對我有利,則向上;四捨五入不能確定是對誰有利,因此極少被採用。