8. Structs, enums, and variants
Learn about fields, structure initialization, and the roles of enum and variant.
Wave Foundation
Expressing relationships between data as types
If the price and number of products are each passed as variables, it is difficult to tell by just looking at the code whether the two values belong to the same product. A structure groups related fields. enum represents a named state, and variant stores different data together for each case.
The three functions are not syntaxes that replace each other. Choose depending on what you want to express.
| something to express | select | yes |
|---|---|---|
| Multiple fields existing simultaneously | struct | Unit price and quantity of product |
| Named integer state | enum | Wait/Proceed/Complete |
| Different data in each case | variant | success value or error |
Structure declaration and value creation
struct Product {
price: i32;
quantity: i32;
}
fun main() {
var item: Product = Product {
price: 1500,
quantity: 2
};
println("{} {}", item.price, item.quantity);
}
Execution result:
1500 2
Fields in declarations end with a semicolon, and when creating values, fields and values are connected with colons and separated by commas. Defining the type and creating the actual value are two different steps. Declaring the type Product does not automatically create storage space for one product.
The field is accessed as item.price. If you create multiple item of the same type, you can store different values in each.
Passing a structure to a function
struct Product {
price: i32;
quantity: i32;
}
fun subtotal(item: Product) -> i32 {
return item.price * item.quantity;
}
fun main() {
var item: Product = Product {
price: 1500,
quantity: 2
};
println("{}", subtotal(item));
item.quantity = 3;
println("{}", subtotal(item));
}
Execution result:
3000
4500
The function receives as a type the relationship that the unit price and quantity belong to the same product. This is a function that reads the integer field structure passed as a value. Any function that wants to modify the caller's storage can be designed to accept a pointer.
If the structure has pointer fields, copying value also copies the address. This is not a function for deep copying to separate allocations. Types containing resources such as file handles or Buffer must specify copy and release rules together.
Method and proto
Related functions can be grouped in method form. proto is a method of writing the method of a structure as a separate block.
struct Counter {
value: i32;
}
proto Counter {
fun current(self: Counter) -> i32 {
return self.value;
}
}
fun main() {
var counter: Counter = Counter {
value: 3
};
println("{}", counter.current());
}
Execution result:
3
self: Counter is a parameter that receives a value. Using a method call notation does not automatically make it a method that modifies the original. Please read together the type of self and what it does in the text.
You can't expect a field to have only a valid state just because you attached a method to it. If there are invalid combinations that the user can create with public fields, your function should check for them or provide a creation rule.
Name the state with enum
enum State -> i32 {
Ready = 0,
Running,
Finished,
}
fun main() {
var state: State = State::Ready;
if (state == State::Ready) {
println("ready");
}
state = State::Running;
if (state == State::Running) {
println("running");
}
}
Execution result:
ready
running
-> i32 is an integer type used in expressions. The first value is set to 0, and subsequent omitted values are 1 greater than the previous value. Rather than simply comparing 0 and 1 in your code, using State::Ready and State::Running will reveal the meaning.
enum Having a name does not automatically restrict state transitions. Rules such as whether it is possible to return from Finished to Running must be implemented as functions.
Connecting cases and data with variant
If there is a value only in case of success and error information is needed in case of failure, it can be expressed as variant.
variant Result {
Value(i32),
Error(i32)
}
fun divide(left: i32, right: i32) -> Result {
if (left < 0 || right <= 0) {
return Result::Error(1);
}
return Result::Value(left / right);
}
fun show(result: Result) {
match (result) {
Result::Value(value) => {
println("value={}", value);
}
Result::Error(code) => {
println("error={}", code);
}
}
}
fun main() {
show(divide(12, 3));
show(divide(12, 0));
}
Execution result:
value=4
error=1
Result::Value and Result::Error each contain payload. Even if they contain the same integer type, certain cases are distinguished. The caller checks the case with match and uses payload within that arm.
divide above is a small example that does not support negative operands. Because the input range is specified, it should not be confused with a function that covers all boundaries of the regular signed division.
If payload is not present
Data is not required in all cases. The state of no value can be expressed as a separate case.
variant Lookup {
Found(i32),
Missing
}
fun main() {
var result: Lookup = Lookup::Missing;
match (result) {
Lookup::Found(value) => {
println("found={}", value);
}
Lookup::Missing => {
println("missing");
}
}
}
Execution result:
missing
Instead of reserving one arbitrary integer as “none”, we used the case Missing. No matter what the success value is, the meaning does not overlap.
match through _ handle the remaining cases. If you want each caller to be reviewed again when a new case is added, it is better to separate all cases explicitly. Whichever method you choose, make sure there is no unprocessed input.
Using structure and variant together
Selecting different data can be expressed as variant, and grouping multiple fields belonging to one case can be expressed as a structure. For example, if the order processing result is success, it can be designed to contain a receipt structure, and if it is a failure, it can be designed to contain an error number.
The memory lifetime rule does not disappear even if the value contains other values. If a pointer is stored in variant, whether the pointer is valid and who will release it are determined separately. Even when saving in an external file format, you must specify encoding for each field rather than dumping the structure memory as is.
Exercise and complete solution
Create a verdict that only accepts scores between 0 and 100. Valid scores are expressed as Grade(score), the rest are expressed as Invalid.
variant CheckedScore {
Grade(i32),
Invalid
}
fun check_score(score: i32) -> CheckedScore {
if (score < 0 || score > 100) {
return CheckedScore::Invalid;
}
return CheckedScore::Grade(score);
}
fun main() {
var result: CheckedScore = check_score(87);
match (result) {
CheckedScore::Grade(score) => {
println("accepted={}", score);
}
CheckedScore::Invalid => {
println("invalid score");
}
}
}
Execution result:
accepted=87
Change the input to -1, 0, 100, 101 to check the boundaries. Since success and failure do not share the same integer space, the caller reduces the risk of accidentally adding error values to the average calculation.
Which expression should I choose?
| shape of data | suitable expression | yes |
|---|---|---|
| Multiple values of the same type | Array | 10 points |
| Multiple fields related to each other | struct | name and score |
| Named state value | enum | Ready, Running, Stopped |
| Additional data that varies by state | variant | Value(i32), Error(str) |
| Contextual name for an existing type | type alias | UserId = u64 |
When choosing a data structure, consider not only the values you want to store, but also what false states you can represent. A structure with both success/failure and value/error fields can create an incorrect combination, but variant can be differentiated into payload for each case.
Memory placement and external data
The structure's memory may contain empty space to ensure alignment between fields. Simply adding the field sizes does not always equal the total size of the structure. When you need to know the size and alignment, use mem Layout function.
For files or network messages, the byte order and length can be clearly determined by encoding the fields in order with the bytes function. When passing structures to other languages, align the external declaration of FFI with the target ABI.