diff --git a/package.json b/package.json index 90989567..69e89225 100644 --- a/package.json +++ b/package.json @@ -23,5 +23,6 @@ "@types/react": "18.2.61", "typescript": "^4.9.5" }, - "license": "CC-BY-4.0" -} \ No newline at end of file + "license": "CC-BY-4.0", + "packageManager": "yarn@1.22.22" +} diff --git a/pages/book/contracts.mdx b/pages/book/contracts.mdx index 847d4a0a..b4395d08 100644 --- a/pages/book/contracts.mdx +++ b/pages/book/contracts.mdx @@ -46,7 +46,7 @@ contract Example { } ``` -State variables must have a default value or initialized in [`init(){:tact}`](#init-function) function, that runs on deployment of the contract. +State variables must have a default value or initialized in [`init(){:tact}`](#init-function) function, that runs on deployment of the contract. The only exception is persistent state variables of type [`map{:tact}`](/book/maps) since they are initialized empty by default. diff --git a/pages/book/maps.mdx b/pages/book/maps.mdx index 7b018564..2f32e3cd 100644 --- a/pages/book/maps.mdx +++ b/pages/book/maps.mdx @@ -2,42 +2,290 @@ import { Callout } from 'nextra/components' -The type `map{:tact}` is used as a way to associate keys of type `k` with corresponding values of type `v`. +The [composite type](/book/types#composite-types) `map{:tact}` is used as a way to associate keys of type `k` with corresponding values of type `v`. -Possible key types: +For example, `map{:tact}` uses [`Int{:tact}`][int] type for its keys and values: -* [`Int{:tact}`][ints] -* `Address{:tact}` +```tact +struct IntToInt { + counters: map; +} +``` + +## Allowed types -Possible value types: +Allowed key types: -* [`Int{:tact}`][ints] +* [`Int{:tact}`][int] +* [`Address{:tact}`][p] + +Allowed value types: + +* [`Int{:tact}`][int] * [`Bool{:tact}`](/book/types#booleans) -* `Cell{:tact}` -* `Address{:tact}` +* [`Cell{:tact}`][p] +* [`Address{:tact}`][p] * [Struct](/book/structs-and-messages#structs) * [Message](/book/structs-and-messages#messages) -For example, `map{:tact}` uses [`Int{:tact}`][ints] type for its keys and values: +## Operations + +### Declare + +As a [local variable](/book/statements#let), using `emptyMap(){:tact}` function of standard library: ```tact -struct IntToInt { - counters: map; +let fizz: map = emptyMap(); +let fizz: map = null; // identical to the previous line, but less descriptive +``` + +As a [persistent state variables](/book/contracts#variables): + +```tact +contract Example { + fizz: map; // Int keys to Int values + init() { + self.fizz = emptyMap(); // redundant and can be removed! + } +} +``` + +Note, that [persistent state variables](/book/contracts#variables) of type `map{:tact}` are initialized empty by default and don't need default values or an initialization in the [`init(){:tact}` function](/book/contracts#init-function). + +### Set values, `.set()` [#set] + +To set or replace the value under a key call the `.set(){:tact}` [method](/book/functions#extension-function), which is accessible for all maps. + +```tact +// Empty map +let fizz: map = emptyMap(); + +// Setting a couple of values under different keys +fizz.set(7, 7); +fizz.set(42, 42); + +// Overriding one of the existing key-value pairs +fizz.set(7, 68); // key 7 now points to value 68 +``` + +### Get values, `.get()` [#get] + +To check if a key is found in the map by calling the `.get(){:tact}` [method](/book/functions#extension-function), which is accessible for all maps. This will return `null{:tact}` if the key is missing, or the value if the key is found. + +```tact +// Empty map +let fizz: map = emptyMap(); + +// Setting a value +fizz.set(68, 0); + +// Getting the value by its key +let gotButUnsure: String? = fizz.get(68); // returns String or null, therefore the type is String? +let mustHaveGotOrErrored: String = fizz.get(68)!!; // explicitly asserting that the value must not be null, + // which may crush at runtime if the value is, in fact, null + +// Alternatively, we can check for the key in the if statement +if (gotButUnsure != null) { + // Hooray, let's use !! without fear now and cast String? to String + let definitelyGotIt: String = fizz.get(68)!!; +} else { + // Do something else... +} +``` + +### Delete entries [#del] + +To delete a single key-value pair (single entry), simply assign the `null{:tact}` value to the key when using the [`.set(){:tact}`](#set) [method](/book/functions#extension-function). + +```tact +// Empty map +let fizz: map = emptyMap(); + +// Setting a couple of values under different keys +fizz.set(7, 123); +fizz.set(42, 321); + +// Deleting one of the keys +fizz.set(7, null); // the entry under key 7 is now deleted +``` + +To delete all the entries from the map, re-assign the map using the `emptyMap(){:tact}` function: + +```tact +// Empty map +let fizz: map = emptyMap(); + +// Setting a couple of values under different keys +fizz.set(7, 123); +fizz.set(42, 321); + +// Deleting all of the entries at once +fizz = emptyMap(); +fizz = null; // identical to the previous line, but less descriptive +``` + +With this approach all previous entries of the map are completely discarded from the contract even if the map was declared as its persistent state variable. As a result, assigning maps to `emptyMap(){:tact}` **does not** inflict any hidden or sudden [storage fees](https://docs.ton.org/develop/smart-contracts/fees#storage-fee). + +### Convert to a `Cell`, `.asCell()` [#convert] + +Use `.asCell(){:tact}` [method](/book/functions#extension-function) on maps to convert all their values to a [`Cell{:tact}`][p] type. Be mindful, that [`Cell{:tact}`][p] type is able to store up to 1023 bits, so converting larger maps to the Cell will result in error. + +As an example, this method is useful for sending small maps directly in the body of the reply: + +```tact +contract Example { + // Persistent state variables + fizz: map; // our map + + // Constructor (initialization) function of the contract + init() { + // Setting a bunch of values + self.fizz.set(0, 3); + self.fizz.set(1, 14); + self.fizz.set(2, 15); + self.fizz.set(3, 926); + self.fizz.set(4, 5_358_979_323_846); + } + + // Internal message receiver, which responds to empty messages + receive() { + // Here we're converting the map to a Cell and making a reply with it + self.reply(self.fizz.asCell()); + } } ``` -Additionally, maps allow [integer serialization](/book/integers#serialization-types) of their keys, values or both to [preserve space and reduce storage costs](/book/integers#serialization): +### Traverse over entries [#traverse] + +At the moment Tact doesn't have a special syntax for iterating over maps. However, it's possible to use maps as a simple arrays if you define a `map{:tact}` with an [`Int{:tact}`][int] type for the keys and keep track of the number of items in the separate variable: + +```tact +contract Iteration { + // Persistent state variables + counter: Int as uint32; // counter of map entries, serialized as a 32-bit unsigned + record: map; // Int to Address map + + // Constructor (initialization) function of the contract + init() { + self.counter = 0; // Setting the self.counter to 0 + } + + // Internal message receiver, which responds to a String message "Add" + receive("Add") { + // Get the Context Struct + let ctx: Context = context(); + // Set the entry: counter Int as a key, ctx.sender Address as a value + self.record.set(self.counter, ctx.sender); + // Increase the counter + self.counter += 1; + } + + // Internal message receiver, which responds to a String message "Send" + receive("Send") { + // Loop until the value of self.counter (over all the self.record entries) + let i: Int = 0; // declare usual i for loop iterations + while (i < self.counter) { + send(SendParameters{ + bounce: false, // do not bounce back this message + to: self.record.get(i)!!, // set the sender address, knowing that key i exists in the map + value: ton("0.0000001"), // 100 nanoToncoins (nano-tons) + mode: SendIgnoreErrors, // send ignoring errors in transaction, if any + body: "SENDING".asComment() // String "SENDING" converted to a Cell as a message body + }); + i += 1; // don't forget to increase the i + } + } + + // Getter function for obtaining the value of self.record + get fun map(): map { + return self.record; + } + + // Getter function for obtaining the value of self.counter + get fun counter(): Int { + return self.counter; + } +} +``` + +It's often useful to set an upper-bound restriction on such maps, so that you [don't hit the limits](#limits-and-drawbacks). + + + + Note, that manually keeping track of number of items or checking the length of such map is very error-prone and generally discouraged. Instead, try to wrap your map into the [Struct](/book/structs-and-messages#structs) and define [extension functions](/book/functions#extension-function) on it. See example in the Cookbook: [How to emulate an array using a map wrapped in a Struct](/cookbook#how-to-emulate-an-array-using-a-map-wrapped-in-a-struct). + + + + + + This example was adapted from [howardpen9/while-example-tact](https://github.com/howardpen9/while-example-tact/blob/de5807fcd20dba5f6a3748d112511477fb22bfcc/contracts/awesome.tact#L19C10-L19C10). + + See other examples of map usage in the Cookbook:\ + [How to emulate a stack using a map wrapped in a Struct](/cookbook#how-to-emulate-a-circular-buffer-using-a-map-wrapped-in-a-struct)\ + [How to emulate a circular buffer using a map wrapped in a Struct](/cookbook#how-to-emulate-a-circular-buffer-using-a-map-wrapped-in-a-struct) + + + +## Serialization + +It's possible to do [integer serialization](/book/integers#serialization-types) of map keys, values or both to [preserve space and reduce storage costs](/book/integers#serialization): ```tact struct SerializedMapInside { + // Both keys and values here would be serialized as 8-bit unsigned integers, + // thus preserving the space and reducing storage costs: countersButCompact: map; } ``` - Read about other serialization options: [Compatibility with FunC](/book/func#convert-serialization) + Read about other serialization options: [Compatibility with FunC](/book/func#convert-serialization). -[ints]: /book/integers +## Limits and drawbacks + +While maps can be convenient to work with on a small scale, they cause a number of issues if the number of items is unbounded and map can significantly grow in size: + +* As the upper bound of the smart contract state size is around $65\,000$ items of type [`Cell{:tact}`][p], it constrains the storage limit of maps to be about $30\,000$ key-value pairs for the whole contract. + +* The more entries you have in a map, the bigger [compute fees](https://docs.ton.org/develop/howto/fees-low-level#computation-fees) you'll get. Thus, working with large maps makes compute fees tough to predict and manage. + +* Using a large map in a single contract doesn't allow to distribute its workload. Hence, it can make the overall performance much worse compared to using a smaller map and a bunch of interacting smart contracts. + +To resolve such issues you can set an upper-bound restriction on a map as a constant and check against it every time you're setting a new value to the map: + +```tact +contract Example { + // Declare a compile-time constant upper-bound for our map + const MaxMapSize: Int = 42; + + // Persistent state variables + arr: map; // "array" of String values as a map + arrLength: Int = 0; // length of the "array", defaults to 0 + + // Constructor (initialization) function of the contract + init() {} + + // Internal function for pushing an item to the end of the "array" + fun arrPush(item: String) { + if (self.arrLength >= self.MaxMapSize) { + // Do something, stop the operation, for example + } else { + // Proceed with adding new item + self.arr.set(self.arrLength, item); + self.arrLength += 1; + } + } +} +``` + +If you still need a large map or an unbound (infinitely large) map, it's better to architect your smart contracts according to the [asynchronous and actor-based model of TON blockchain](https://docs.ton.org/learn/overviews/ton-blockchain). That is, to use contract sharding and essentially make the whole blockchain a part of your map(s). + +{/* + TODO: Add reference to sharding page as per: https://github.com/tact-lang/tact-docs/issues/155 +*/} + +[int]: /book/integers +[p]: /book/types#primitive-types diff --git a/pages/cookbook/index.mdx b/pages/cookbook/index.mdx index 6780320c..205dfda5 100644 --- a/pages/cookbook/index.mdx +++ b/pages/cookbook/index.mdx @@ -114,30 +114,349 @@ dump("The loop is over!"); ## Map +For description and basic usage see [`map{:tact}` type in the Book](/book/maps). + +### How to emulate an array using a map wrapped in a Struct + ```tact -// Create an empty map -let m: map = emptyMap(); +import "@stdlib/deploy"; // for Deployable trait -// Add the key/value pair to the map -m.set(1, "a"); +struct Array { + map: map; // array of Int values as a map of Ints to Ints + length: Int = 0; // length of the array, defaults to 0 +} -// Get the value by the key from the map -let first: String? = m.get(1); +// Compile-time constant upper bound for our map representing an array. +const MaxArraySize: Int = 5_000; // 5,000 entries max, to stay reasonably far from limits -// Check if the key exists -if (first == null) { - // do something... -} else { - // Cast value if the key exists - let firstStr: String = first!!; - // do something... +// Extension mutation function for adding new entries to the end of the array +extends mutates fun append(self: Array, item: Int) { + require(self.length + 1 < MaxArraySize, "No space in the array left for new items!"); + + self.map.set(self.length, item); // set the entry (key-value pair) + self.length += 1; // increase the length field +} + +// Extension mutation function for inserting new entries at the given index +extends mutates fun insert(self: Array, item: Int, idx: Int) { + require(self.length + 1 < MaxArraySize, "No space in the array left for new items!"); + require(idx >= 0, "Index of the item cannot be negative!"); + require(idx < self.length, "Index is out of array bounds!"); + + // Move all items from idx to the right + let i: Int = self.length; // not a typo, as we need to start from the non-existing place + while (i > idx) { + // Note, that we use !! operator as we know for sure that the value would be there + self.map.set(i, self.map.get(i - 1)!!); + i -= 1; + } + + // And put the new item in + self.map.set(idx, item); // set the entry (key-value pair) + self.length += 1; // increase the length field +} + +// Extension function for getting the value at the given index +extends fun getIdx(self: Array, idx: Int): Int { + require(self.length > 0, "No items in the array!"); + require(idx >= 0, "Index of the item cannot be negative!"); + require(idx < self.length, "Index is out of array bounds!"); + + // Note, that we use !! operator as we know for sure that the value would be there + return self.map.get(idx)!!; +} + +// Extension function for returning the last value +extends fun getLast(self: Array): Int { + require(self.length > 0, "No items in the array!"); + + // Note, that we use !! operator as we know for sure that the value would be there + return self.map.get(self.length - 1)!!; +} + +// Extension mutation function for deleting and entry at the given index and returning its value +extends mutates fun deleteIdx(self: Array, idx: Int): Int { + require(self.length > 0, "No items in the array to delete!"); + require(idx >= 0, "Index of the item cannot be negative!"); + require(idx < self.length, "Index is out of array bounds!"); + + // Remember the value, which is going to be deleted + let memorized: Int = self.map.get(idx)!!; + + // Move all items from idx and including to the left + let i: Int = idx; + while (i + 1 < self.length) { + // Note, that we use !! operator as we know for sure that the value would be there + self.map.set(i, self.map.get(i + 1)!!); + i += 1; + } + + self.map.set(self.length - 1, null); // delete the last entry + self.length -= 1; // decrease the length field + + return memorized; +} + +// Extension mutation function for deleting the last entry and returning its value +extends fun deleteLast(self: Array): Int { + require(self.length > 0, "No items in the array!"); + + // Note, that we use !! operator as we know for sure that the value would be there + let lastItem: Int = self.map.get(self.length - 1)!!; + self.map.set(self.length - 1, null); // delete the entry + self.length -= 1; // decrease the length field + + return lastItem; +} + +// Extension function for deleting all items in the Array +extends mutates fun deleteAll(self: Array) { + self.map = emptyMap(); + self.length = 0; +} + +// Global static function for creating an empty Array +fun emptyArray(): Array { + return Array{map: emptyMap()}; // length defaults to 0 +} + +// Contract, with emulating an Array using the map +contract MapAsArray with Deployable { + // Persistent state variables + array: Array; + + // Constructor (initialization) function of the contract + init() { + self.array = emptyArray(); + } + + // Internal message receiver, which responds to a String message "append" + receive("append") { + // Add a new item + self.array.append(42); + } + + // Internal message receiver, which responds to a String message "delete_5h" + receive("delete_5th") { + // Remove the 5th item if it exists and reply back with its value, or raise an erroor + self.reply(self.array.deleteIdx(4).toCoinsString().asComment()); // index offset 0 + 4 gives the 5th item + } + + // Internal message receiver, which responds to a String message "del_last" + receive("del_last") { + // Remove the last item and reply back with its value, or raise an error + self.reply(self.array.deleteLast().toCoinsString().asComment()); + } + + // Internal message receiver, which responds to a String message "get_last" + receive("get_last") { + // Reply back with the latest item in the array if it exists, or raise an error + self.reply(self.array.getLast().toCoinsString().asComment()); + } + + // Internal message receiver, which responds to a String message "delete_all" + receive("delete_all") { + self.array.deleteAll(); + } +} +``` + +### How to emulate a stack using a map wrapped in a Struct + +A [stack](https://en.wikipedia.org/wiki/Stack_(abstract_data_type)) is a collection of elements with two main operations: + +* push, which adds an element to the end of the collection +* pop, which removes the most recently added element + +```tact +import "@stdlib/deploy"; // for Deployable trait + +struct Stack { + map: map; // stack of Int values as a map of Ints to Ints + length: Int = 0; // length of the stack, defaults to 0 +} + +// Extension mutation function for adding new entries to the end of the stack +extends mutates fun push(self: Stack, item: Int) { + self.map.set(self.length, item); // set the entry (key-value pair) + self.length += 1; // increase the length field +} + +// Extension mutation function for deleting the last entry and returning its value +extends mutates fun pop(self: Stack): Int { + require(self.length > 0, "No items in the stack to delete!"); + + // Note, that we use !! operator as we know for sure that the value would be there + let lastItem: Int = self.map.get(self.length - 1)!!; + self.map.set(self.length - 1, null); // delete the entry + self.length -= 1; // decrease the length field + + return lastItem; +} + +// Extension function for returning the last value +extends fun getLast(self: Stack): Int { + require(self.length > 0, "No items in the stack!"); + + // Note, that we use !! operator as we know for sure that the value would be there + return self.map.get(self.length - 1)!!; +} + +// Extension function for deleting all items in the Stack +extends mutates fun deleteAll(self: Stack) { + self.map = emptyMap(); + self.length = 0; +} + +// Global static function for creating an empty Stack +fun emptyStack(): Stack { + return Stack{map: emptyMap()}; // length defaults to 0 +} + +contract MapAsStack with Deployable { + // Persistent state variables + stack: Stack; // our stack, which uses the map + + // Constructor (initialization) function of the contract + init() { + self.stack = emptyStack(); + } + + // Internal message receiver, which responds to a String message "push" + receive("push") { + // Add a new item + self.stack.push(42); + } + + // Internal message receiver, which responds to a String message "pop" + receive("pop") { + // Remove the last item and reply with it + self.reply(self.stack.pop().toCoinsString().asComment()); + } + + // Internal message receiver, which responds to a String message "get_last" + receive("get_last") { + // Reply back with the latest item in the map if it exists, or raise an error + self.reply(self.stack.getLast().toCoinsString().asComment()); + } + + // Internal message receiver, which responds to a String message "delete_all" + receive("delete_all") { + self.stack.deleteAll(); + } + + // Getter function for obtaining the stack + get fun map(): map { + return self.stack.map; + } + + // Getter function for obtaining the current length of the stack + get fun length(): Int { + return self.stack.length; + } +} +``` + +### How to emulate a circular buffer using a map wrapped in a Struct + +A [circular buffer](https://en.wikipedia.org/wiki/Circular_buffer) (circular queue, cyclic buffer or ring buffer) is a data structure, which uses a single, fixed-size [buffer](https://en.wikipedia.org/wiki/Data_buffer) as it were connected end-to-end. + +```tact +import "@stdlib/deploy"; // for Deployable trait + +struct CircularBuffer { + map: map; // circular buffer of Int values as a map of Ints to Ints + length: Int = 0; // length of the circular buffer, defaults to 0 + start: Int = 0; // current index into the circular buffer, defaults to 0 +} + +// Compile-time constant upper bound for our map representing a circular buffer. +const MaxCircularBufferSize: Int = 5; + +// Extension mutation function for putting new items to the circular buffer +extends mutates fun put(self: CircularBuffer, item: Int) { + if (self.length < MaxCircularBufferSize) { + self.map.set(self.length, item); // store the item + self.length += 1; // increase the length field + } else { + self.map.set(self.start, item); // store the item, overriding previous entry + self.start = (self.start + 1) % MaxCircularBufferSize; // update starting position + } +} + +// Extension mutation function for getting an item from the circular buffer +extends mutates fun getIdx(self: CircularBuffer, idx: Int): Int { + require(self.length > 0, "No items in the circular buffer!"); + require(idx >= 0, "Index of the item cannot be negative!"); + + if (self.length < MaxCircularBufferSize) { + // Note, that we use !! operator as we know for sure that the value would be there + return self.map.get(idx % self.length)!!; + } + + // Return the value rotating around the circular buffer, also guaranteed to be there + return self.map.get((self.start + idx) % MaxCircularBufferSize)!!; +} + +// Extension function for iterating over all items in the circular buffer and dumping them to the console +extends fun printAll(self: CircularBuffer) { + let i: Int = self.start; + repeat (self.length) { + dump(self.map.get(i)!!); // !! tells the compiler this can't be null + i = (i + 1) % MaxCircularBufferSize; + } +} + +// Extension function for deleting all items in the CircularBuffer +extends mutates fun deleteAll(self: CircularBuffer) { + self.map = emptyMap(); + self.length = 0; + self.start = 0; +} + +// Global static function for creating an empty CircularBuffer +fun emptyCircularBuffer(): CircularBuffer { + return CircularBuffer{map: emptyMap()}; // length and start default to 0 +} + +// This contract records the last 5 timestamps of when "timer" message was received +contract MapAsCircularBuffer with Deployable { + // Persistent state variables + cBuf: CircularBuffer; // our circular buffer, which uses a map + + // Constructor (initialization) function of the contract + init() { + self.cBuf = emptyCircularBuffer(); + } + + // Internal message receiver, which responds to a String message "timer" + // and records the timestamp when it receives such message + receive("timer") { + let timestamp: Int = now(); + self.cBuf.put(timestamp); + } + + // Internal message receiver, which responds to a String message "get_first" + // and replies with the first item of the circular buffer + receive("get_first") { + self.reply(self.cBuf.getIdx(0).toCoinsString().asComment()); + } + + // Internal message receiver, which responds to a String message "print_all" + receive("print_all") { + self.cBuf.printAll(); + } + + // Internal message receiver, which responds to a String message "delete_all" + receive("delete_all") { + self.cBuf.deleteAll(); + } } ``` - **Useful links:**\ - [`map{:tact}` type in the Book](/book/maps) + This example is adapted from [Arrays page in Tact-By-Example](https://tact-by-example.org/04-arrays).