Solidity 6— Veri Yönetimi

Engin UNAL
8 min readOct 21, 2022

EVM veri saklama ve okuma konusu ile devam ediyoruz. Önceki yazıların linkleri: Temel Bilgiler, İlk Akıllı Kontrat ve Remix, Tipler, Contract ve Function, Interface ve Library.

Ethereum Virtual Machine (EVM), veri saklama ve yönetimini kullanım yerine göre farklı şekillerde sağlamaktadır. Bunlara başlıklar halinde değinmeye çalışacağım. Takip edeceğim sıralama Storage, Memory, Calldata, Code. Bu başlıklardan sonra bu konuları toparlayıp özet geçerek kullanım örneği ile açıklamaya çalışıp bitireceğim. İşin detayına girmek istemeyenler için özet başlığı altında yeterli açıklama bulunmaktadır.

Konuyu bir cümle ile özetlemeye çalışırsam şöyle bir tanım yapabilirim: EVM bir sanal makine ve akıllı kontratı bir bilgisayar programı olarak varsayalım bu durumda storage’ı veritabanı, memory’i RAM, logs yapısını sistem log dosyaları gibi düşünmek daha kolay anlamayı sağlayabilir. Şimdi bunları daha yakından inceleyelim.

Storage

Akıllı kontrat içinde tanımlanmış tüm state değişkenleri kalıcı saklama alanında yani blockchain üzerinde depolanır. Bu tip saklama tipine storage ismi verilmektedir. Her bir kontrat için ayrı olarak oluşturulan, state değişkenlerinin saklandığı, okuma/yazma yapılabilen bir veri saklama alanıdır. Bilgisayarlardaki sabit disk gibi düşünülebilir, bilgisayar kapatılıp açıldığında sabit diskteki kaydedilmiş verilerin kaybolmaması gibi EVM tarafından kontrat her çalıştırıldığında state verilerine son kaydedildiği değerlerden kayıpsız olarak erişilebilir. Biraz daha detayına bakalım.

Storage kalıcı bir saklama alanıdır tanımı yapmıştık, storage iç yapısı slot’lardan oluşmaktadır. Her slot 32-byte(256-bit) uzunluktadır. Maksimum slot sayısı ise 2²⁵⁶ olarak tanımlıdır.

Storage slot yapısı

State değişkenleri tanımlandığı sıraya göre slot’lara referans edilirler. Kontrat üzerinde inceleyelim. Aşağıdaki kod örneğinde Storage1 isimli bir kontratımız var ve içerisinde uint256 tipinde a,b,c,d,e,f,g,h,i,j,k state değişkenleri bulunmakta. uint256 veya uint, 256-bit yani 32-byte’tır.

contract Storage1 {
uint256 a;
uint256 b;
uint256 c;
uint256 d;
uint256 e;
uint256 f;
uint256 g;
uint256 h;
uint256 i;
uint256 j;
uint256 k;
constructor() {
f = 0xfafafa;
k = 0xababab;
}
}

Örnek kontrat içindeki değişkenlerin storage slotları sıfırdan başlayarak yerleşecektir. uint256 32-byte’lık bir veri tipi olduğundan her bir state değişkeni bir slot kaplar. Bu durumda slot görüntüsü:

Örnekteki state değişkenlerin slot yerleşimi.

Konunun daha detayını merak edenler için bu yerleşimin nasıl gerçekleştirildiğini ve değer ataması yapılırken nasıl bir mekanizma çalışyor? Gas tüketimi ile ilgisi nasıl? İnceleyelim. Yukarıdaki Storage1 kontratı derleyip opcode içindeki komutlara baktığımızda:

PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH3 0xFAFAFA PUSH1 0x5 SSTORE PUSH3 0xABABAB PUSH1 0xA SSTORE PUSH1 0x3F DUP1 PUSH1 0x2B PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 0xEE 0xCC SAR 0xE3 SDIV 0x1E SWAP8 0xC6 NUMBER PUSH5 0x1D00C2E1F7 CALLVALUE 0x4A 0xC7 CALLCODE 0xD3 0x2D TIMESTAMP 0x2B CODESIZE LOG1 0x26 DUP10 PUSH16 0x4B794CFB64736F6C6343000807003300

0xFAFAFA değeri 0x5 deki slot’a yazılacak, 0xABABAB değeri 0xA daki slot’a yazılacak. Slot yerleşimine bakarsak 0x5 no’lu slotta f değişkeni var, 0xA yani 10 no’lu slotta ise k değişkeni bulunmakta. SSTORE komutu ile storage kaydı işlemi gerçekleştirilmekte. Yani bu kontrat için EVM opcodes incelendiğinde çıkarılan sonuç: f ve k değişkenlerine değer atamaları yapılmış ve storage’a kaydedilmiş.

Diğer state değişkenleri için bir sstore komutu bulunmamakta. Bu da demektir ki bir değer atanmadığı sürece storage üzerinde bir işlem yapılmamakta yani maliyeti olmamaktadır. Storage üzerinde yazma ve yazılmış bir state değişkeninin okuma işlemlerinin maliyeti bulunur. SSTORE ve SLOAD komutları storage’a yazan ve oradan okuma yapan komutlardır.

Her bir slot için ön tanımlı değer sıfır olarak atanmaktadır. Yani değişkene ilk değer ataması yaparken sıfır değer verilmesine gerek yoktur. Eğer sıfırdan farklı bir değer atanmamış ise state değişkeninin maliyeti yoktur yani gas tüketimi yoktur.

Başka bir örnek üzerinden gidelim.

contract Storage2 {
uint256 a;
uint256[3] b;
struct StructTest {
uint256 x;
uint256 y;
uint256 z;
}
StructTest c; constructor() {
a = 0xAAAAAA;
b[0] = 0xBBBBBB;
c.x = 0xC0C0C0;
c.y = 0xC1C1C1;
c.z = 0xC2C2C2;
}
}

opcode çıktısı:

PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH3 0xAAAAAA PUSH1 0x0 SSTORE PUSH3 0xBBBBBB PUSH1 0x1 SSTORE PUSH3 0xC0C0C0 PUSH1 0x4 SSTORE PUSH3 0xC1C1C1 PUSH1 0x5 SSTORE PUSH3 0xC2C2C2 PUSH1 0x6 SSTORE PUSH1 0x3F DUP1 PUSH1 0x40 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT INVALID LOG2 PUSH5 0x6970667358 0x22 SLT KECCAK256 BALANCE BLOCKHASH SWAP13 PUSH22 0x3DDF8CF6E74D025ADD302076288B6F9DB1E99BBBD758 0x25 0x2A 0x1F CODESIZE STATICCALL 0xD2 PUSH5 0x736F6C6343 STOP ADDMOD SMOD STOP CALLER

Slot yerleşimi:

Dikkat edilirse b[1] ve b[2] değişkenlerinin değerlerinde bir değişiklik yapılmadığı için storage üzerinde bir tüketimleri yok. Peki değişkenleri storage bilgimizi kullanarak daha verimli nasıl tanımlayabilirdik? Örnek üzerinden görelim.

contract Storage3 {
uint64 a;
uint64 b;
uint64 c;
uint64 d;
constructor() {
a = 0xA0A0A;
b = 0xB0B0B0;
c = 0xC0C0C0;
d = 0xD0D0D0;
}
}

Yukarıdaki şekildeki tanımlamada sadece bir slot kullanılır. Slot uzunluğunun 32-byte olduğunu düşündüğümüzde eğer değişkenlerimiz slot içerisine sığabilecek boyutlara indirilebiliyorsa fazladan slot rezerve etmeden kullanmak mümkündür. Derleme sonrasında opcode çıktısındaki komut listesi incelenirse bir adet SSTORE görülecektir.

Kendi örneklerinizi de yazıp slot kullanımını inceleyebilirsiniz. Burada küçük bir detay bulunur, statik bir array tanımlandığında önceki slot içinde boş yer olmasına rağmen ve array buna sığabilecek görünmesine rağmen yeni slot açılıyor. Veya array sonrasında yapılan bir tip değişkeni tanımı array’in bittiği slotta kalan yere sığabiliyor görünmesine rağmen yeni slot açılmakta. Örneğin

uint32 a;
uint32[2] b;

veya

uint32[2] b;
uint32 a;

Gibi tanımlamada iki slot kullanıldığı görülecektir. Oysa toplam 32-byte etmeyen bir alan ayırmak istiyoruz. a : 4-byte, b : 8-byte toplamda 12-byte için iki slot rezerve ediliyor. Burada garip bir durum var neden bu şekilde? Sorunun cevabı slot rezervasyonundaki algoritmanın bazı kurallar uygulamasıdır.

Dikkat edilmesi gereken bazı slot yerleşim kuralları :

  • Struct ve array yapıları her zaman yeni bir slot ayırarak başlar.
  • Struct veya array sonrasındaki tipler her zaman yeni bir slot’tan başlar.

Statik olarak tanımlanan değişkenlerin storage slotlarındaki yerleşimlerini gördük, şimdi dinamik tanımlanan değişkenleri inceleyelim. Statik değişkenlerde slot yerleşimlerinin hesaplanması çok daha kolay algoritmalarla sağlanabilmekte fakat iş dinamik değişkenlere geldiğinde karmaşıklaşabiliyor. Bunun nedeni de rezerve edilecek alanın önceden bilinmemesi ve dinamik olarak değişebilmesidir. Konunun çok fazla uzaması nedeniyle dinamik array ve mapping yapılarındaki slot rezervasyon işlemine iki cümle ile değinip geçeceğim.

Dinamik array yapılarında array’in başladığı slot numarasında array uzunluğu tutulur. Array elemanlarına ise hash(slot numarası) + (indeks * tip uzunluğu) formulüyle hesaplanarak erişilebilir. Mapping için de buna benzer bir algoritma ile slot yerleşimleri hesaplanmaktadır. Farkı ise mapping için bir indeks olmadığı için mapping ilk slot adresinde length tutulmaz. İlk slot numarası ve key hash bilgisi hesaplanarak okuma /yazma yapılacak slot numarası hesaplanır.

Storage mekanizmanın nasıl çalıştığını genel olarak anlamak daha verimli akıllı kontrat yazmayı kolaylaştıracağından önemlidir. Bir örnekle konuyu bitirelim. Diyelim ki kontrat içinde üç adet state değişkenimiz olsun.

uint128 a;
uint256 b;
uint128 c;

Herhangi bir kodlama düzenlemesi yapmadan ve sadece değişkenlerin kontrat içindeki yerleşimiyle oynayarak bile daha az gas tüketen kontratlar geliştirilebilir. Yukarıdaki örnekte 3 slot rezerve edilecektir.

uint128 a;
uint128 c;
uint256 b;

Oysa bunu yukarıdaki gibi değiştirdiğimizde 2 slot ile aynı işi gerçekleştirebiliriz. Benzer şekilde struct kullanımları ile değişkenlerin paketlenerek daha az slot rezerve etmesi ile daha az gas tüketen kontrat kodları üretilebilir.

Storage pahalı bir yapıdır, blockchain gibi dağıtık sistemler üzerinde veri saklamak, merkezi yapılarda veri saklamaya göre oldukça maliyetlidir. Bu nedenle gereksiz veya fazla verilerin storage’da saklanması önerilmez.

Memory

Memory, verinin kontrat çalışma zamanında saklandığı ve sonrasında silindiği yani veriyi geçici süreyle tutan yapılardır. RAM gibi de düşünülebilir. Akıllı kontrat, çalıştığı sürece memory içinde tutulan veriye erişerek her türlü işlemi gerçekleştirebilir, işlem sonlandığında rezerve edilen memory serbest bırakılır ve içindeki veriler silinir. Kontrat tekrar çalıştığında memory sıfırdan tekrar rezerve edilecek ve çalışma anı için oluşturulacaktır. Bu nedenle memory kullanımında bu alanın geçici olarak oluşturulduğunu ve içindeki verinin saklanmayacağını bilerek kodlama yapılmalıdır.

Aşağıdaki örnek ile devam edelim. s_ ile başlayan değişkenler storage’da saklanırken m_ ile başlayanlar memory’de saklanmaktadır.

Solidity compiler, özel tipler için storage, memory veya calldata tanımının yapılmasını ister. Bunlar struct, array, string ve mapping tanımlarıdır. Mapping memory ile kullanılamaz. Bu tiplerin yüksek boyutlara ulaşabilecek yer ihtiyaçları olabilir veya dinamik tanımlandıklarında çalışma sırasında değişken boyutlarda olabilirler. Solidity bunların veri yönetimlerini hangi veri ortamında(storage, memory) yapacağını bilmek ister. Fakat bundan önemlisi doğrudan storage değişkenleri üzerinde işlem yapmak maliyetlidir. Memory değişkenleri ile çalışmak gas tüketimini düşürecektir.

Şöyle de düşünebiliriz storage’da tanımlı 1000 elemanlı bir array olsun. Bunun elemanlarını gezmek yüksek maliyetler çıkarabilir. Oysa bunu bir memory’e atıp memory’deki array üzerinde çalışmak çok daha verimli olacaktır.

Memory konusunu anlayabilmek için şimdi yukarıdaki örnek üzerinden gidelim. User isimli struct tanımladık ve user1 isimli state değişkeni storage’da saklanır, AssignTest1 çağırıldığında storage üzerinde güncelleme işlemi yapar ve state değişkeni değişir.

AssignTest2 içinde user2 değişkeni tanımladık ve memory anahtar kelimesini kullandık. Bu şekildeki kullanımda user2 memory’de tanımlanmış olur sadece bu fonksiyon içinde geçerlidir ve fonksiyon çalışmasını bitirdiğinde erişilemez.

AssignTest3 içinde user3 değişkenini tanımlarken storage anahtar kelimesi kullanıldı ve user1'den atama yapıldı, böylece user2 üzerinde yapılan her değişiklik aynı zamanda user1 üzerinde yapılmış olacaktır. Yani user3 değişkeni user1 state değişkeninin storage adres referansını alacaktır.

AssignTest4 içinde user4 değişkenini tanımlarken memory anahtar kelimesi kullanıldı ve user1'den atama yapıldı, bu şekilde user4 üzerinde yapılan değişiklikler user1 state değişkenini etkilemez. Yani user4 değişkeni user1 state değişkeninin içeriğinin kopyalanmış halini alacaktır. Memory ile tanımlı değişkenler için yeni memory alanı ayrılarak değişken içeriği buraya kopyalanır. Yapılan güncelleme işlemleri sadece ilgili memory alanını etkiler.

Memory tanımları state değişkenleri gibi kontrat seviyesinde yapılamaz, fonksiyon parametreleri ve fonksiyon içerisinde kullanılabilir.

Calldata

Calldata, memory yapılarına benzer ancak değiştirilemezdir. Yani salt okunurdur. Transaction aracılığı ile yapılan fonksiyon çağrılarındaki verileri içerir. Adı üstünde çağrı verisini ifade eder yani msg.data içeriğidir.

Yukarıdaki örnek üzerinden gidelim. CallDataSample1 fonksiyonunun strCallData parametresi msg.data’dan gelen değiştirilemez calldata verisidir. Bunu sadece okuyabiliriz fakat yazamayız. Aynı fonksiyon içinde yeni bir string değişken olan newData değişkeni oluşturuluyor ve içerisine diğer fonksiyonların da ürettiği string değerler eklenip dönüş yapılıyor.

Örnekteki CallDataSample2 fonksiyonu aynı şekilde calldata özelliğinde parametre alarak buna ekler yapar ve yeni bir string üreterek döner.

CallDataSample3 ise memory string alır. Çağırıldığı yer incelendiğinde parametre olarak calldata geçildiği görülecektir. Bu bir yanlışlık gibi görünse de değildir. strMemData parametresi memory olarak tanımlandığı için calldata olarak tanımlanan strCallData parametresini alır ve yeni bir string üreterek içeriğini kopyalar. Böylece elimizde değiştirilebilir strMemData değişkenimiz olur. Solidity’in alt tarafta yaptığı işlem aşağıdaki mantıkta olacaktır.

string memory strMemData = strCallData;

Tekrar belirtmek gerekirse calldata sadece fonksiyon parametreleri için kullanılabilir, değiştirilemez sadece okuma yapılabilir.

Code

Akıllı kontrat içinde tanımlanan state değişkenlerinin storage’a kaydedildiğini, fonksiyon içindeki veya parametrelerinde memory tanımı yapılan değişkenlerin memory’e kaydedildiğini gördük. Calldata ise msg.data’dan gelen değişmez çağrı verisiydi. Bunların dışında code alanında saklanan veri de mevcuttur. Code isminden kasıt aslında akıllı kontrat kodlarıdır. Örnekle açıklarsak.

Akıllı kontrat içerisindeki constant ve immutable olarak tanımlanan veriler code içerisinde yani kontrat bytecode’unda saklanır. Bunlar aslında değişken gibi davranmaz. Solidity compiler, constant kullanılan yerlerde ilgili constant adı karşılığına onun değerini koyar.

Özet

Storage, blockchain’e kaydedilen verilerdir, hard disk gibi düşünülebilir. Memory, akıllı kontrat çalışırken fonksiyonlar tarafından kullanılan verilerdir, RAM gibi düşünülebilir. Calldata, transaction ile bir fonksiyon çağırılırken transaction.data veya msg.data içerisinde gelen fonksiyon parametre verileridir, değiştirilemez.

Storage ve memory kullanımı ile ilgili aşağıdaki kodlama örneği incelenebilir. Kod içerisinde açıklamalar eklenmiştir.

Calldata kullanım örneği:

function CalldataTest(string calldata cdString) external pure {
//calldata degeri a degiskenine kopyalanır.
string memory a = cdString;
//a degiskeni memory tanımlandigindan degistirilebilir,
//bu degisiklik cdString degiskenini etkilemez.
a = "123abc";
//calldata tanımı yapılırsa icerik degistirilemez.
string calldata b = cdString;
//derleme hatası verir --> b = "aaa";
}

Yazının sonuna geldik. Umarım yeteri kadar açıklayıcı olmuştur. Okuduğunuz için teşekkürler.

Serideki diğer yazılar:

Temel Bilgiler

İlk Akıllı Kontrat ve Remix

Tipler

Contract ve Function

Interface ve Library

Engin Ünal

--

--