Solidity是以太坊的主要程式語言,它是一種靜態型別的 JavaScript-esque 語言,是面向合約的、為實現智慧合約而建立的高階程式語言,設計的目的是能在以太坊虛擬機器(EVM)上執行。
本文基於CryptoZombies,教程地址為:https://cryptozombies.io/zh/lesson/2
地址(address)
以太坊區塊鏈由 account (賬戶)組成,你可以把它想象成銀行賬戶。一個帳戶的餘額是以太 (在以太坊區塊鏈上使用的幣種),你可以和其他帳戶之間支付和接受以太幣,就像你的銀行帳戶可以電匯資金到其他銀行帳戶一樣。
每個帳戶都有一個“地址”,你可以把它想象成銀行賬號。這是賬戶唯一的識別符號,它看起來長這樣:
0x0cE446255506E92DF41614C46F1d6df9Cc969183
這是 CryptoZombies 團隊的地址,為了表示支援CryptoZombies,可以讚賞一些以太幣!
address
:地址型別儲存一個 20 位元組的值(以太坊地址的大小)。 地址型別也有成員變數,並作為所有合約的基礎。
address
型別是一個160位的值,且不允許任何算數操作。這種型別適合儲存合約地址或外部人員的金鑰對。
對映(mapping)
Mappings 和雜湊表類似,它會執行虛擬初始化,以使所有可能存在的鍵都對映到一個位元組表示為全零的值。
對映是這樣定義的:
//對於金融應用程式,將使用者的餘額儲存在一個 uint型別的變數中:
mapping (address => uint) public accountBalance;
//或者可以用來通過userId 儲存/查詢的使用者名稱
mapping (uint => string) userIdToName;
對映本質上是儲存和查詢資料所用的鍵-值對。在第一個例子中,鍵是一個 address,值是一個 uint,在第二個例子中,鍵是一個uint,值是一個 string。
對映型別在宣告時的形式為 mapping(_KeyType => _ValueType)。 其中 _KeyType 可以是除了對映、變長陣列、合約、列舉以及結構體以外的幾乎所有型別。 _ValueType 可以是包括對映型別在內的任何型別。
對對映的取值操作如下:
userIdToName[12]
// 如果鍵12 不在 對映中,得到的結果是0
對映中,實際上並不儲存 key,而是儲存它的 keccak256 雜湊值,從而便於查詢實際的值。所以對映是沒有長度的,也沒有 key 的集合或 value 的集合的概念。,你不能像操作
python
字典那應該獲取到當前 Mappings 的所有鍵或者值。
特殊變數
在 Solidity 中,在全域性名稱空間中已經存在了(預設了)一些特殊的變數和函式,他們主要用來提供關於區塊鏈的資訊或一些通用的工具函式。
msg.sender
msg.sender指的是當前呼叫者(或智慧合約)的 address。
注意:在 Solidity 中,功能執行始終需要從外部呼叫者開始。 一個合約只會在區塊鏈上什麼也不做,除非有人呼叫其中的函式。所以對於每一個外部函式呼叫,包括 msg.sender 和 msg.value 在內所有 msg 成員的值都會變化。這裡包括對庫函式的呼叫。
以下是使用 msg.sender 來更新 mapping 的例子:
mapping (address => uint) favoriteNumber;
function setMyNumber(uint _myNumber) public {
// 更新我們的 `favoriteNumber` 對映來將 `_myNumber`儲存在 `msg.sender`名下
favoriteNumber[msg.sender] = _myNumber;
// 儲存資料至對映的方法和將資料儲存在陣列相似
}
function whatIsMyNumber() public view returns (uint) {
// 拿到儲存在呼叫者地址名下的值
// 若呼叫者還沒呼叫 setMyNumber, 則值為 `0`
return favoriteNumber[msg.sender];
}
在這個小小的例子中,任何人都可以呼叫 setMyNumber 在我們的合約中存下一個 uint 並且與他們的地址相繫結。 然後,他們呼叫 whatIsMyNumber 就會返回他們儲存的 uint。
使用 msg.sender 很安全,因為它具有以太坊區塊鏈的安全保障 —— 除非竊取與以太坊地址相關聯的私鑰,否則是沒有辦法修改其他人的資料的。
以下是其它的一些特殊變數。
區塊和交易屬性
- block.blockhash(uint blockNumber) returns (bytes32):指定區塊的區塊雜湊——僅可用於最新的 256 個區塊且不包括當前區塊;而 blocks 從 0.4.22 版本開始已經不推薦使用,由 blockhash(uint blockNumber) 代替
- block.coinbase (address): 挖出當前區塊的礦工地址
- block.difficulty (uint): 當前區塊難度
- block.gaslimit (uint): 當前區塊 gas 限額
- block.number (uint): 當前區塊號
- block.timestamp (uint): 自 unix epoch 起始當前區塊以秒計的時間戳
- gasleft() returns (uint256):剩餘的 gas
- msg.data (bytes): 完整的 calldata
- msg.gas (uint): 剩餘 gas – 自 0.4.21 版本開始已經不推薦使用,由 gesleft() 代替
- msg.sender (address): 訊息傳送者(當前呼叫)
- msg.sig (bytes4): calldata 的前 4 位元組(也就是函式識別符號)
- msg.value (uint): 隨訊息傳送的 wei 的數量
- now (uint): 目前區塊時間戳(block.timestamp)
- tx.gasprice (uint): 交易的 gas 價格
- tx.origin (address): 交易發起者(完全的呼叫鏈)
錯誤處理
Solidity 使用狀態恢復異常來處理錯誤。這種異常將撤消對當前呼叫(及其所有子呼叫)中的狀態所做的所有更改,並且還向呼叫者標記錯誤。
函式 assert
和 require
可用於檢查條件並在條件不滿足時丟擲異常。
- assert 函式只能用於測試內部錯誤,並檢查非變數。
- require 函式用於確認條件有效性,例如輸入變數,或合約狀態變數是否滿足條件,或驗證外部合約呼叫返回的值。
這裡主要介紹 require
require使得函式在執行過程中,當不滿足某些條件時丟擲錯誤,並停止執行:
function sayHiToVitalik(string _name) public returns (string) {
// 比較 _name 是否等於 "Vitalik". 如果不成立,丟擲異常並終止程式
// (敲黑板: Solidity 並不支援原生的字串比較, 我們只能通過比較
// 兩字串的 keccak256 雜湊值來進行判斷)
require(keccak256(_name) == keccak256("Vitalik"));
// 如果返回 true, 執行如下語句
return "Hi!";
}
如果你這樣呼叫函式 sayHiToVitalik("Vitalik")
,它會返回“Hi!”。而如果呼叫的時候使用了其他引數,它則會丟擲錯誤並停止執行。
因此,在呼叫一個函式之前,用 require 驗證前置條件是非常有必要的。
注意:在 Solidity 中,關鍵詞放置的順序並不重要
// 以下兩個語句等效
require(keccak256(_name) == keccak256("Vitalik"));
require(keccak256("Vitalik") == keccak256(_name));
外/內部函式
除 public 和 private 屬性之外,Solidity 還使用了另外兩個描述函式可見性的修飾詞:internal(內部) 和 external(外部)。
internal
和 private
類似,不過,如果某個合約繼承自其父合約,這個合約即可以訪問父合約中定義的“內部(internal)”函式
。
external
與public
類似,只不過external
函式只能在合約之外呼叫 – 它們不能被合約內的其他函式呼叫。
宣告函式 internal 或 external 型別的語法,與宣告 private 和 public類 型相同:
contract Sandwich {
uint private sandwichesEaten = 0;
function eat() internal {
sandwichesEaten++;
}
}
contract BLT is Sandwich {
uint private baconSandwichesEaten = 0;
function eatWithBacon() public returns (string) {
baconSandwichesEaten++;
// 因為eat() 是internal 的,所以我們能在這裡呼叫
eat();
}
}
Solidity 有兩種函式呼叫(內部呼叫不會產生實際的 EVM 呼叫或稱為訊息呼叫
,而外部呼叫則會產生一個 EVM 呼叫), 函式和狀態變數有四種可見性型別。 函式可以指定為 external ,public ,internal 或者 private,預設情況下函式型別為 public。 對於狀態變數,不能設定為 external ,預設是 internal 。
external :
外部函式作為合約介面的一部分,意味著我們可以從其他合約和交易中呼叫。 一個外部函式 f 不能從內部呼叫(即 f 不起作用,但 this.f() 可以)。 當收到大量資料的時候,外部函式有時候會更有效率。
public :
public 函式是合約介面的一部分,可以在內部或通過訊息呼叫。對於公共狀態變數, 會自動生成一個 getter 函式。
internal :
這些函式和狀態變數只能是內部訪問(即從當前合約內部或從它派生的合約訪問),不使用 this 呼叫。
private :
private 函式和狀態變數僅在當前定義它們的合約中使用,並且不能被派生合約使用。
合約中的所有內容對外部觀察者都是可見的。設定一些 private 型別只能阻止其他合約訪問和修改這些資訊, 但是對於區塊鏈外的整個世界它仍然是可見的。
可見性識別符號的定義位置,對於狀態變數來說是在型別後面,對於函式是在引數列表和返回關鍵字中間。
pragma solidity ^0.4.16;
contract C {
// 對於函式是在引數列表和返回關鍵字中間。
function f(uint a) private pure returns (uint b) { return a + 1; }
function setData(uint a) internal { data = a; }
uint public data; // 對於狀態變數來說是在型別後面
}
函式多值返回
和 python 類似,Solidity 函式支援多值返回,比如:
function multipleReturns() internal returns(uint a, uint b, uint c) {
return (1, 2, 3);
}
function processMultipleReturns() external {
uint a;
uint b;
uint c;
// 這樣來做批量賦值:
(a, b, c) = multipleReturns();
}
// 或者如果我們只想返回其中一個變數:
function getLastReturnValue() external {
uint c;
// 可以對其他欄位留空:
(,,c) = multipleReturns();
}
這裡留空欄位使用
,
的方式太不直觀了,還不如 python/go 使用下劃線_
代替無用欄位。
Storage與Memory
在 Solidity 中,有兩個地方可以儲存變數 —— storage 或 memory。
Storage 變數是指永久儲存在區塊鏈中的變數。 Memory 變數則是臨時的,當外部函式對某合約呼叫完成時,記憶體型變數即被移除。 你可以把它想象成儲存在你電腦的硬碟或是RAM中資料的關係。
storage 和 memory 放到狀態變數名前邊,在型別後邊,格式如下:
變數型別 <storage|memory> 變數名
大多數時候都用不到這些關鍵字,預設情況下 Solidity 會自動處理它們。 狀態變數(在函式之外宣告的變數)預設為“儲存”形式,並永久寫入區塊鏈;而在函式內部宣告的變數是“記憶體”型的,它們函式呼叫結束後消失。
然而也有一些情況下,你需要手動宣告儲存型別,主要用於處理函式內的 結構體
和 陣列
時:
contract SandwichFactory {
struct Sandwich {
string name;
string status;
}
Sandwich[] sandwiches;
function eatSandwich(uint _index) public {
// Sandwich mySandwich = sandwiches[_index];
// ^ 看上去很直接,不過 Solidity 將會給出警告
// 告訴你應該明確在這裡定義 `storage` 或者 `memory`。
// 所以你應該明確定義 `storage`:
Sandwich storage mySandwich = sandwiches[_index];
// ...這樣 `mySandwich` 是指向 `sandwiches[_index]`的指標
// 在儲存裡,另外...
mySandwich.status = "Eaten!";
// ...這將永久把 `sandwiches[_index]` 變為區塊鏈上的儲存
// 如果你只想要一個副本,可以使用`memory`:
Sandwich memory anotherSandwich = sandwiches[_index + 1];
// ...這樣 `anotherSandwich` 就僅僅是一個記憶體裡的副本了
// 另外
anotherSandwich.status = "Eaten!";
// ...將僅僅修改臨時變數,對 `sandwiches[_index + 1]` 沒有任何影響
// 不過你可以這樣做:
sandwiches[_index + 1] = anotherSandwich;
// ...如果你想把副本的改動儲存回區塊鏈儲存
}
}
如果你還沒有完全理解究竟應該使用哪一個,也不用擔心 —— 在本教程中,我們將告訴你何時使用 storage 或是 memory,並且當你不得不使用到這些關鍵字的時候,Solidity 編譯器也發警示提醒你的。
現在,只要知道在某些場合下也需要你顯式地宣告 storage 或 memory就夠了!
繼承
Solidity 的繼承和 Python 的繼承相似,支援多重繼承。
看下面這個例子:
contract Doge {
function catchphrase() public returns (string) {
return "So Wow CryptoDoge";
}
}
contract BabyDoge is Doge {
function anotherCatchphrase() public returns (string) {
return "Such Moon BabyDoge";
}
}
// 可以多重繼承。請注意,Doge 也是 BabyDoge 的基類,
// 但只有一個 Doge 例項(就像 C++ 中的虛擬繼承)。
contract BlackBabyDoge is Doge, BabyDoge {
function color() public returns (string) {
return "Black";
}
}
BabyDoge
從 Doge
那裡 inherits(繼承)
過來。 這意味著當編譯和部署了 BabyDoge
,它將可以訪問 catchphrase() 和 anotherCatchphrase()和其他我們在 Doge 中定義的其他公共函式(private 函式不可訪問)。
Solidity使用 is 從另一個合約派生。派生合約可以訪問所有非私有成員,包括內部函式和狀態變數,但無法通過 this
來外部訪問。
基類建構函式的引數
派生合約需要提供基類建構函式需要的所有引數。這可以通過兩種方式來完成:
pragma solidity ^0.4.0;
contract Base {
uint x;
// 這是註冊 Base 和設定名稱的建構函式。
function Base(uint _x) public { x = _x; }
}
contract Derived is Base(7) {
function Derived(uint _y) Base(_y * _y) public {
}
}
contract Derived1 is Base {
function Derived1(uint _y) Base(_y * _y) public {
}
}
一種方法直接在繼承列表中呼叫基類建構函式(is Base(7)
)。 另一種方法是像 修飾器 modifier
使用方法一樣, 作為派生合約建構函式定義頭的一部分,(Base(_y * _y)
)。 如果建構函式引數是常量並且定義或描述了合約的行為,使用第一種方法比較方便。 如果基類建構函式的引數依賴於派生合約,那麼必須使用第二種方法。 如果像這個簡單的例子一樣,兩個地方都用到了,優先使用 修飾器modifier 風格的引數。
抽象合約
合約函式可以缺少實現,如下例所示(請注意函式宣告頭由 ; 結尾):
pragma solidity ^0.4.0;
contract Feline {
function utterance() public returns (bytes32);
}
這些合約無法成功編譯(即使它們除了未實現的函式還包含其他已經實現了的函式),但他們可以用作基類合約:
pragma solidity ^0.4.0;
contract Feline {
function utterance() public returns (bytes32);
}
contract Cat is Feline {
function utterance() public returns (bytes32) { return "miaow"; }
}
如果合約繼承自抽象合約,並且沒有通過重寫來實現所有未實現的函式,那麼它本身就是抽象的。
介面(Interface)
介面類似於抽象合約,但是它們不能實現任何函式。還有進一步的限制:
- 無法繼承其他合約或介面。
- 無法定義建構函式。
- 無法定義變數。
- 無法定義結構體
- 無法定義列舉。
首先,看一下一個interface的例子:
contract NumberInterface {
function getNum(address _myAddress) public view returns (uint);
}
請注意,這個過程雖然看起來像在定義一個合約,但其實內裡不同:
- 首先,只宣告瞭要與之互動的函式 —— 在本例中為 getNum —— 在其中沒有使用到任何其他的函式或狀態變數。
- 其次,並沒有使用大括號({ 和 })定義函式體,單單用分號(
;
)結束了函式宣告。這使它看起來像一個合約框架。
編譯器就是靠這些特徵認出它是一個介面的。
就像繼承其他合約一樣,合約可以繼承介面。
可以在合約中這樣使用介面:
contract MyContract {
address NumberInterfaceAddress = 0xab38...;
// ^ 這是FavoriteNumber合約在以太坊上的地址
NumberInterface numberContract = NumberInterface(NumberInterfaceAddress);
// 現在變數 `numberContract` 指向另一個合約物件
function someFunction() public {
// 現在我們可以呼叫在那個合約中宣告的 `getNum`函式:
uint num = numberContract.getNum(msg.sender);
// ...在這兒使用 `num`變數做些什麼
}
}
通過這種方式,只要將合約的可見性設定為public
(公共)或external
(外部),它們就可以與以太坊區塊鏈上的任何其他合約進行互動。
與其他合約的互動
如果一個合約需要和區塊鏈上的其他的合約會話,則需先定義一個 interface (介面)。
先舉一個簡單的栗子。 假設在區塊鏈上有這麼一個合約:
contract LuckyNumber {
mapping(address => uint) numbers;
function setNum(uint _num) public {
numbers[msg.sender] = _num;
}
function getNum(address _myAddress) public view returns (uint) {
return numbers[_myAddress];
}
}
這是個很簡單的合約,可以用它儲存自己的幸運號碼,並將其與呼叫者的以太坊地址關聯。 這樣其他人就可以通過地址查詢幸運號碼了。
現在假設我們有一個外部合約,使用 getNum 函式可讀取其中的資料。
首先,我們定義 LuckyNumber 合約的 interface :
contract NumberInterface {
function getNum(address _myAddress) public view returns (uint);
}
使用這個介面,合約就知道其他合約的函式是怎樣的,應該如何呼叫,以及可期待什麼型別的返回值。
下面是一個示例程式碼,會用到上邊的知識點:
pragma solidity ^0.4.19;
contract ZombieFactory {
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
struct Zombie {
string name;
uint dna;
}
Zombie[] public zombies;
// 建立一個叫做 zombieToOwner 的對映。其鍵是一個uint,值為 address。對映屬性為public
mapping (uint => address) public zombieToOwner;
// 建立一個名為 ownerZombieCount 的對映,其中鍵是 address,值是 uint
mapping (address => uint) ownerZombieCount;
function _createZombie(string _name, uint _dna) private {
uint id = zombies.push(Zombie(_name, _dna)) - 1;
zombieToOwner[id] = msg.sender;
ownerZombieCount[msg.sender]++;
NewZombie(id, _name, _dna);
}
function _generateRandomDna(string _str) private view returns (uint) {
uint rand = uint(keccak256(_str));
return rand % dnaModulus;
}
function createRandomZombie(string _name) public {
// 我們使用了 require 來確保這個函式只有在每個使用者第一次呼叫它的時候執行,用以建立初始殭屍
require(ownerZombieCount[msg.sender] == 0);
uint randDna = _generateRandomDna(_name);
_createZombie(_name, randDna);
}
}
// CryptoKitties 合約提供了getKitty 函式,它返回所有的加密貓的資料,包括它的“基因”(殭屍遊戲要用它生成新的殭屍)。
// 一個獲取 kitty 的介面
contract KittyInterface {
// 在interface裡定義了 getKitty 函式 在 returns 語句之後用分號
function getKitty(uint256 _id) external view returns (
bool isGestating,
bool isReady,
uint256 cooldownIndex,
uint256 nextActionAt,
uint256 siringWithId,
uint256 birthTime,
uint256 matronId,
uint256 sireId,
uint256 generation,
uint256 genes
);
}
//ZombieFeeding繼承自 `ZombieFactory 合約
contract ZombieFeeding is ZombieFactory {
// CryptoKitties 合約的地址
address ckAddress = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d;
// 建立一個名為 kittyContract 的 KittyInterface,並用 ckAddress 為它初始化
KittyInterface kittyContract = KittyInterface(ckAddress);
function feedAndMultiply(uint _zombieId, uint _targetDna, string _species) public {
// 確保對自己殭屍的所有權
require(msg.sender == zombieToOwner[_zombieId]);
// 宣告一個名為 myZombie 資料型別為Zombie的 storage 型別本地變數
Zombie storage myZombie = zombies[_zombieId];
_targetDna = _targetDna % dnaModulus;
uint newDna = (myZombie.dna + _targetDna) / 2;
// Add an if statement here
if (keccak256(_species) == keccak256("kitty")){
newDna = newDna - newDna%100 + 99;
}
_createZombie("NoName", newDna);
}
function feedOnKitty(uint _zombieId, uint _kittyId) public {
uint kittyDna;
// 多值返回,這裡只需要最後一個值
(,,,,,,,,,kittyDna) = kittyContract.getKitty(_kittyId);
feedAndMultiply(_zombieId, kittyDna, "kitty");
}
}
這段程式碼看起來內容有點多,可以拆分一下,把
ZombieFactory
程式碼提取到一個新的檔案zombiefactory.sol
,現在就可以使用 import 語句來匯入另一個檔案的程式碼。
import
在 Solidity 中,當你有多個檔案並且想把一個檔案匯入另一個檔案時,可以使用 import 語句:
import "./someothercontract.sol";
contract newContract is SomeOtherContract {
}
這樣當我們在合約(contract)目錄下有一個名為 someothercontract.sol 的檔案( ./ 就是同一目錄的意思),它就會被編譯器匯入。
這一點和 go 類似,在同一目錄下檔案中的內容可以直接使用,而不用使用 xxx.name 的形式。
測試呼叫
編譯和部署 ZombieFeeding,就可以將這個合約部署到以太坊了。最終完成的這個合約繼承自 ZombieFactory,因此它可以訪問自己和父輩合約中的所有 public 方法。
下面是一個與ZombieFeeding合約進行互動的例子, 這個例子使用了 JavaScript 和 web3.js:
var abi = /* abi generated by the compiler */
var ZombieFeedingContract = web3.eth.contract(abi)
var contractAddress = /* our contract address on Ethereum after deploying */
var ZombieFeeding = ZombieFeedingContract.at(contractAddress)
// 假設我們有我們的殭屍ID和要攻擊的貓咪ID
let zombieId = 1;
let kittyId = 1;
// 要拿到貓咪的DNA,我們需要呼叫它的API。這些資料儲存在它們的伺服器上而不是區塊鏈上。
// 如果一切都在區塊鏈上,我們就不用擔心它們的伺服器掛了,或者它們修改了API,
// 或者因為不喜歡我們的殭屍遊戲而封殺了我們
let apiUrl = "https://api.cryptokitties.co/kitties/" + kittyId
$.get(apiUrl, function(data) {
let imgUrl = data.image_url
// 一些顯示圖片的程式碼
})
// 當使用者點選一隻貓咪的時候:
$(".kittyImage").click(function(e) {
// 呼叫我們合約的 `feedOnKitty` 函式
ZombieFeeding.feedOnKitty(zombieId, kittyId)
})
// 偵聽來自我們合約的新殭屍事件好來處理
ZombieFactory.NewZombie(function(error, result) {
if (error) return
// 這個函式用來顯示殭屍:
generateZombie(result.zombieId, result.name, result.dna)
})
參考連結
- Solidity 文件:https://solidity-cn.readthedocs.io/zh/develop/index.html
- cryptozombie-lessons2 殭屍攻擊人類:https://cryptozombies.io/zh/lesson/2
- Solidity 簡易教程
最後,感謝女朋友支援和包容,比❤️
也可以在公號輸入以下關鍵字獲取歷史文章:公號&小程式
| 設計模式
| 併發&協程