rust (formally known as rust_core) is a pure Dart implementation of patterns found in the Rust programming language, bringing powerful tools previously only available to Rust developers to Dart developers!
New types include Result, Option, Cell, Slice, Array, Iterator, Channels, Mutex, Path and more.
See the Documentation Book 📖 for a deeper dive!
Example: Rust Language vs rust Package
Goal: Get the index of every "!" in a string not followed by a "?"
Rust:
use std::iter::Peekable; fn main() { let string = "kl!sd!?!"; let mut answer: Vec<usize> = Vec::new(); let mut iter: Peekable<_> = string .chars() .map_windows(|w: &[char; 2]| *w) .enumerate() .peekable(); while let Some((index, window)) = iter.next() { match window { ['!', '?'] => continue, ['!', _] => answer.push(index), [_, '!'] if iter.peek().is_none() => answer.push(index + 1), _ => continue, } } println!("{:?}", answer); // [2, 7] }
Dart:
import 'package:rust/rust.dart';
void main() {
final string = "kl!sd!?!";
Vec<int> answer = [];
Peekable<(int, Arr<String>)> iter = string
.chars()
.mapWindows(2, identity)
.enumerate()
.peekable();
while (iter.moveNext()) {
final (index, window) = iter.current;
switch (window) {
case ["!", "?"]:
break;
case ["!", _]:
answer.push(index);
case [_, "!"] when iter.peek() == null: // or `iter.peekOpt().isNone()`
answer.push(index + 1);
}
}
print(answer); // [2, 7]
}
Project Goals
rust's primary goal is to give Dart developers access to powerful tools previously only available to Rust developers.
To accomplish this, Rust's functionalities are carefully adapted to Dart's paradigms, focusing on a smooth idiomatic language-compatible integration. The result is developers now have a whole new toolset to tackle problems in Dart.
True to the Rust philosophy, rust strives to bring reliability and performance in every feature. Every feature is robustly tested. Over 500 meaningful test suites and counting.
Quickstart
Setup
Install
rust can be installed like any other Dart package.
Dart:
dart pub add rust
Flutter:
flutter pub add rust
or add directly to your pubspec.yaml
:
dependencies:
rust: <version>
General Notes
All of rust's classes and methods are well documented in the docs, but being an implementation of Rust, you can also refer to Rust docs if anything is unclear. The functionally is the same.
The Basics
Result and Option
Result<T, E>
is a sum type used for returning and propagating errors - Ok
and Err
.
Option<T>
represents a value that can be either some value of type T
(Some<T>
) or None
.
It is used "in place" of T?
(implemented as a zero cost extension type of T?
).
These types can be easily chained with other operations or pattern matched.
The Rust ?
Operator and Early Return Key Notion
Result<T,E>
and Option<T>
both support early return key notation, which has
the same function as the rust ?
operator.
It returns from the scope if an Err
or None
is encountered, otherwise it retrieves the inner value.
Result example:
Result<double, String> divideBy(int num, int divisor) => divisor == 0 ? Err("Divide by zero error") : Ok(num / divisor);
Result<double, String> func(int x) => Result(($) { // Early Return Key
// The function will return here
int val = divideBy(x, 0)[$] + 10;
return Ok(val);
});
void main(){
assert(func(5) == Err("Divide by zero error"));
}
List
and Arr
Arr
(array) is a compliment to List
, representing a fixed sized List
. Having a separate Arr
type fixes runtime exceptions for trying to grow
a non-growable List
. It also has zero runtime cost, as it is an extension type of List
and is more efficient than a growable List
. With Arr
, type intent is clear for maintainers and developers are able think about code performance more critically.
var array = Arr(null, 10);
array = Arr.constant(const [1,2,3,4,5]);
array = Arr.generate(10, (i) => i);
for(final entry in array){
// do something
}
var (slice1, slice2) = array.splitSlice(3);
Iter
rust implements the entirety of Rust's stable and unstable Iterator methods. There are a lot of methods here that many Dart developers may not be familiar with. Definitely worth a look - docs
List<int> list = [1, 2, 3, 4, 5];
Iter<int> filtered = list.iter().filterMap((e) {
if (e % 2 == 0) {
return Some(e * 2);
}
return None;
});
expect(filtered, [4, 8]);
Iter
functions the same as a Rust Iterator
. For Dart developers, you can think of it as the union of Dart's Iterator
and Iterable
.
check here for more info.
Slice
A Slice
is a contiguous sequence of elements in a List
or Arr
. Slices are a view into a list without allocating and copying to a new list,
thus slices are more efficient than creating a new List
with .sublist()
e.g. list.sublist(x,y)
.
var list = [1, 2, 3, 4, 5];
var slice = Slice(list, 1, 4); // or `list.slice(1,4)`
expect(slice, [2, 3, 4]);
var taken = slice.takeLast();
expect(taken, 4);
expect(slice, [2, 3]);
slice[1] = 10;
expect(list, [1, 2, 10, 4, 5]);
Slice
also has a lot of efficient methods for in-place mutation within and between slices - docs
Whats Next?
Checkout any of the other sections in the book for more details and enjoy!
New To Rust
Welcome to Rust!
Maybe you have heard of Rust and want to see what all the hype is about, maybe you know a little Rust and want to improve your Rust while writing Dart, for whatever the reason, rust is here to help. Rust has a solid reputation for writing safe, maintainable, and performant code. rust brings the same tools and philosophy to Dart! rust is also a great start to learn and improve your Rust semantics/knowledge. You will write Dart and learn Rust along the way. With rust you can expect all the usual types you see in Rust. Here is a quick matrix comparing Rust, Dart, and Dart with rust:
Rust Type | Dart Equivalent | rust | Description |
---|---|---|---|
[T; N] | const [...] /List<T>(growable: false) | Arr<T> | Fixed size array or list |
Iterator<T> | Iterator<T> /Iterable<T> | Iter<T> | Consumable iteration |
Option<T> | T? | Option<T> | A type that may hold a value or none |
Result<T, E> | - | Result<T, E> | Type used for returning and propagating errors |
[T] | - | Slice<T> | View into an array or list |
Cell<T> | - | Cell<T> | Value wrapper, useful for primitives |
channel<T> | - | channel<T> | Communication between produces and consumers |
Mutex<T> | - | Mutex | Exclusion primitive useful for protecting critical sections |
RwLock<T> | - | RwLock | Exclusion primitive allowing multiple read operations and exclusive write operations |
Path | - | Path | Type for file system path manipulation and interaction |
Vec<T> | List<T> | Vec<T> | Dynamic/Growable array |
To learn more about the Rust programming language, checkout the Rust Book!
New To Dart
Welcome to Dart!
Dart is a great language choice for fast cross platform development and scripting. You'll find that rust is great start to learn Dart's semantics as you will feel like you are writing native rust. rust will introduce you to a few new types you may find useful as a Dart developer:
Rust Type | Dart Equivalent | rust | Description |
---|---|---|---|
[T; N] | const [...] /List<T>(growable: false) | Arr<T> | Fixed size array or list |
Iterator<T> | Iterator<T> /Iterable<T> | Iter<T> | Consumable iteration |
Option<T> | T? | Option<T> | A type that may hold a value or none |
Result<T, E> | - | Result<T, E> | Type used for returning and propagating errors |
[T] | - | Slice<T> | View into an array or list |
Cell<T> | - | Cell<T> | Value wrapper, useful for primitives |
channel<T> | - | channel<T> | Communication between produces and consumers |
Mutex<T> | - | Mutex | Exclusion primitive useful for protecting critical sections |
RwLock<T> | - | RwLock | Exclusion primitive allowing multiple read operations and exclusive write operations |
Path | - | Path | Type for file system path manipulation and interaction |
Vec<T> | List<T> | Vec<T> | Dynamic/Growable array |
To learn more about the Dart programming language, checkout dart.dev!
FAQ
Why Use The Rust Package Even If I Don't Know Rust Language?
From a language perspective we believe Dart is sadly lacking in a few areas, of which this package solves:
- Dart utilizes unchecked try/catch exceptions. Handling errors as values is preferred for maintainability, thus the
Result
type. - Dart has nullable types but you cannot do null or non-null specific operations without a bunch of
if
statements and bugs may be introduced due toT??
not being possible.Option<T>
fixes this with no runtime cost and you can easily switch back and forth. - Dart is missing the functionality of Rust's early return operator (
?
) operator, so we implemented it in Dart. - Dart is missing a built in
Cell
type or equivalent (andOnceCell
/LazyCell
). - Dart's
List
type is an array/vector union (it's growable or non-growable). This is not viewable at the type layer, which may lead to runtime exceptions and encourages using growableList
s everywhere even when you do not need to, which is less performant. So we addedArr
(array). - Dart has no concept of a slice type, so allocating sub-lists is the only method, which is not that efficient. So we added
Slice<T>
. - Dart's between isolate communication is by ports (
ReceivePort
/SendPort
), which is untyped and horrible, we standardized this with introducingchannel
for typed bi-directional isolate communication. - Dart's iteration methods are lacking for
Iterable
andIterator
(there are none! justmoveNext()
andcurrent
), while Rust has an abundance of useful methods. So we introduced Rust'sIterator
asIter
. - Dart does not have built in path manipulation. So, we added
Path
, a zero cost extension type ofString
for path manipulation. - Dart's
File
/Directory
/Link
abstraction was the wrong choice and prone to exceptions. We believeFs
andPath
are a stronger and safer alternative. - No built in cross-platform environment introspect -
Platform
does not work on web. So we addedEnv
which is cross-platform.
I Know Rust, Can This Package Benefit My Team and I?
Absolutely! In fact, our team both programs in and love Dart and Rust. From a team and user perspective, having one common api across two different languages greatly increases our development velocity in a few ways:
- Context switching is minimized, the api's across the two languages are the same.
- Shrinking the knowledge gap between Rust and Dart developers.
Array
Arr (Array) is a zero cost extension type of List
, where the List
is treated as non-growable. This is useful for correctly handling lists where growable is false and const lists - as these types of lists are treated the same in the regular Dart type system, which may lead to errors. With Arr
, type intent is clear for maintainers and developers are able think about code performance more critically.
Arr<int?> array = Arr(null, 10);
Arr
's allocation will be more efficient than compared to a List
since it does not reserve additional capacity and allocates the full amount eagerly. Which is important since allocations account for most of the cost of the runtime costs of a List.
Examples
Creating Arrays
You can create Arr
instances in several ways:
From a Default Value
var array = Arr(null, 10); // Creates an array of 10 null values
From a Constant List
const array = Arr.constant(const [1, 2, 3, 4, 5]); // Creates an array from a constant list
Using a Generator Function
var array = Arr.generate(10, (i) => i); // Generates an array with values from 0 to 9
Creating a Range
var array = Arr.range(0, 10, step: 2); // Creates an array with values [0, 2, 4, 6, 8]
Accessing Elements
You can access and modify elements just like a normal list:
var entry = array[2]; // Accesses the element at index 2
array[2] = 10; // Sets the element at index 2 to 10
Iterating Over Elements
You can use a for-in loop to iterate over the elements:
for (final entry in array) {
print(entry);
}
Converting to List
You can convert Arr
back to a regular list (this will be erased at compile time so there is no cost):
var list = array.asList();
Splitting Arrays
You can split an array into two slices:
var (slice1, slice2) = array.splitSlice(3);
Cell
Cell is library of useful wrappers types (cells).
Cell - A wrapper with interior mutability.
OnceCell - A cell which can be written to only once.
LazyCell - A value which is initialized on the first access.
LazyCellAsync - A value which is asynchronously initialized on the first access.
Cell
A wrapper with interior mutability. Useful for primitives and an escape hatch for working with immutable data patterns.
Cell<T>
can be thought of as a List<T>
with a single object.
void main() {
Cell<int> cell = Cell(1);
mutate(cell);
}
void mutate(Cell<int> cell){
cell.set(2);
}
In a reactive context, such as with flutter, the conceptual equivalent is ValueNotifier
.
Extensions exist for primitives. e.g. Cell<int>
can be used similar to a normal int
.
Cell<int> cell = Cell(10);
expect(cell.get(), 10);
cell.add(2);
expect(cell.get(), 12);
Cell<int> anotherCell = Cell(10);
Cell<int> newCell = cell + anotherCell;
expect(newCell, 22);
expect(cell, 12);
expect(antherCell, 10);
The base type for all Cell
s is ConstCell
.
OnceCell
A cell which can be written to only once. Similar to late final <variable>
, but will never throw an error.
OnceCell<int> cell = OnceCell();
var result = cell.set(10);
expect(result, const Ok(()));
result = cell.set(20);
expect(result, const Err(20));
The base type for all OnceCell
s is OnceCellNullable
.
LazyCell
A value which is initialized on the first access.
int callCount = 0;
LazyCell<int> lazyCell = LazyCell(() {
callCount++;
return 20;
});
int firstCall = lazyCell();
expect(callCount, equals(1));
expect(firstCall, equals(20));
int secondCall = lazyCell();
expect(callCount, equals(1));
expect(secondCall, equals(20));
The base type for all LazyCell
s is LazyCellNullable
.
LazyCellAsync
A value which is asynchronously initialized on the first access.
int callCount = 0;
LazyCellAsync<int> lazyCell = LazyCellAsync(() async {
callCount++;
return 20;
});
int firstCall = await lazyCell.force();
expect(callCount, equals(1));
expect(firstCall, equals(20));
int secondCall = lazyCell(); // Could also call `await lazyCell.force()` again.
expect(callCount, equals(1));
expect(secondCall, equals(20));
The base type for all LazyCellAsync
s is LazyCellNullableAsync
.
Env
Env introduces Env for handling the environment. It works like Platform
,
except it is cross-platform (also works on web), since it is independent of dart:io
, and has additional methods.
void main(){
if(Env.isWeb) {
print("On web, doing nothing.");
}
else if(Env.isLinux || Env.isMacOs) {
Env.currentDirectory = "/";
print("Moved current directory to root");
}
...
}
Fs
Fs introduces Fs and
OpenOptions.
Fs
is a container of static methods for working with the file system in a safe manner.
Fs
combines many of the functionalities in File
/Directory
/Link
/FileStat
/FileSystemEntity
into one location and will never throw an exception. Instead of using instances of the previous
entities, Fs
works only on paths.
Result<(), IoError> = await Fs.createDir("path/to/dir".asPath());
// handle
rather than
try {
await Directory("path/to/dir").create();
}
catch (e) {
// handle
}
// handle
OpenOptions
is a more powerful builder pattern for opening files in place of File(..).open(mode)
OpenOptions options = OpenOptions()
..append(true)
..create(true)
..createNew(true)
..read(true)
..truncate(true)
..write(true)
RandomAccessFile randomAccessFile = options.openSync("path/to/file".asPath()).unwrap();
Iter
A Rust Iterator
is analogous to the union of a Dart Iterable
and Iterator
. Since Dart already has an Iterator
class, to avoid confusion,
the Dart implementation of the Rust iterator is Iter. Iter
makes working with collections of rust
types and regular Dart types a breeze. e.g.
List<int> list = [1, 2, 3, 4, 5];
Iter<int> filtered = list.iter().filterMap((e) {
if (e % 2 == 0) {
return e * 2;
}
return null;
});
expect(filtered, [4, 8]);
// or
filtered = list.iter().filterMapOpt((e) {
if (e % 2 == 0) {
return Some(e * 2);
}
return None;
});
expect(filtered, [4, 8]);
Iter
can be retrieved by calling iter()
on an Iterable
or an Iterator
. Iter
can be iterated
like an Iterable
or Iterator
, and is consumed like an Iterator
.
List<int> list = [1, 2, 3, 4, 5, 6, 7, 8, 9];
Iter<int> iter = list.iter();
List<int> collect = [];
for (final e in iter.take(5).map((e) => e * e)) {
if (e.isEven) {
collect.add(e);
}
}
expect(collect, [4, 16]);
int? next = iter.next();
expect(next, 6);
collect.add(next.unwrap());
next = iter.next();
collect.add(next.unwrap());
expect(next, 7);
while(iter.moveNext()){
collect.add(iter.current * iter.current);
}
expect(collect, [4, 16, 6, 7, 64, 81]);
expect(iter,[]);
Iter
contains many more useful methods than the base Dart Iterable
class and works in all places you
would reach for an Iterator
- pub.dev
Dart vs Rust Example
Goal: Get the index of every "!" in a string not followed by a "?"
import 'package:rust/rust.dart';
void main() {
List<int> answer = [];
String string = "kl!sd!?!";
Peekable<(int index, Arr<String> window)> iter = string
.chars()
.mapWindows(2, identity)
.enumerate()
.peekable();
while (iter.moveNext()) {
final (int index, Arr<String> window) = iter.current;
switch (window) {
case ["!", "?"]:
break;
case ["!", _]:
answer.add(iter.current.$1);
case [_, "!"] when iter.peek().isNone():
answer.add(index + 1);
}
}
expect(answer, [2, 7]);
}
Rust equivalent
use std::iter::Peekable; fn main() { let mut answer: Vec<usize> = Vec::new(); let string = "kl!sd!?!"; let mut iter: Peekable<_> = string .chars() .map_windows(|w: &[char; 2]| *w) .enumerate() .peekable(); while let Some((index, window)) = iter.next() { match window { ['!', '?'] => continue, ['!', _] => answer.push(index), [_, '!'] if iter.peek().is_none() => answer.push(index + 1), _ => continue, } } assert_eq!(answer, [2, 7]); }
Additional Examples
/// Extract strings that are 3 long inside brackets '{' '}' and are not apart of other strings
String string = "jfsdjf{abcdefgh}sda;fj";
Iter<String> strings = string.runes
.iter()
.skipWhile((e) => e != "{".codeUnitAt(0))
.skip(1)
.arrayChunks(3)
.takeWhile((e) => e[2] != "}".codeUnitAt(0))
.map((e) => String.fromCharCodes(e));
expect(strings, ["abc", "def"]);
Misc
Clone
Another a big advantage of Iter
over Iterable<T>
and Iterator<T>
is that Iter<T>
is clonable.
This means the iterator can be cloned without cloning the underlying data.
var list = [1, 2, 3, 4, 5];
var iter1 = list.iter();
iter1.moveNext();
var iter2 = iter1.clone();
iter2.moveNext();
var iter3 = iter2.clone();
iter3.moveNext();
var iter4 = iter1.clone();
expect(iter1.collectList(), [2, 3, 4, 5]);
expect(iter2.collectList(), [3, 4, 5]);
expect(iter3.collectList(), [4, 5]);
expect(iter4.collectList(), [2, 3, 4, 5]);
This allows for situations where you want to work ahead and not lose your iterator position, or pass the Iter
to another function without needing to call e.g. collectList()
, collectArr()
, etc.
Ops
Range
RangeBounds
RangeBounds works the same as Rust's RangeBounds
(usually seen as syntactic sugar e.g. 1..=3
)
They have two uses:
RangeBounds
can be used to get aSlice
of anArr
,Slice
, orList
.
void func(RangeBounds bounds) {
List<int> list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
Slice<int> slice = list(bounds);
expect(slice, equals([4, 5, 6, 7, 8, 9]));
}
func(RangeFrom(4));
The variants are Range
, RangeFrom
, RangeFull
, RangeInclusive
, RangeTo
, and RangeToInclusive
.
RangeBounds
that also implementIterableRangeBounds
can be iterated over as a generator.
for(int x in Range(5, 10)){ // 5, 6, 7, 8, 9
// code
}
All RangeBounds
can be const
.
range
Function
range
is a convenience function that works the same as the python range
function. As opposed to the RangeBounds
classes, the range function can have negative ranges and varying step sizes.
range(end); // == range(0,end);
range(start, end);
range(start, end, step);
for(final x in range(0, 10, 1)){}
// equivalent to
for(final x in range(10)){}
Note Arr.range(..)
also exists as a more efficient method for when it is known collecting the range is needed.
Option
Option represents the union of two types - Some<T>
and None
.
Option
is easy to declare and translate back and forth between nullable types.
Option<int> option = None;
Option<int> option = Some(1);
int? nullable = option.toNullable();
option = Option.of(nullable);
Usage
The Option
type and features work very similar to Result. We are able to chain operations in a safe way without
needing a bunch of if
statements to check if a value is null.
Option<int> intOptionFunc() => None;
double halfVal(int val) => val/2;
Option<double> val = intOptionFunc()
.map(halfVal);
expect(val.unwrapOr(2), 2);
See the docs for all methods and extensions.
You can also use Option in pattern matching
switch(Some(2)){
case Some(:final v):
// do something
case _:
// do something
}
or
final x = switch(Some(2)){
Some(:final v) => "some"
_ => "none"
}
Early Return Key Notation
Option also supports "Early Return Key Notation" (ERKN), which is a derivative of "Do Notation". It allows a
function to return early if the value is None
, and otherwise safely access the inner value directly without needing to unwrap or type check.
Option<int> intNone() => None;
Option<double> earlyReturn(int val) => Option(($) { // Early Return Key
// Returns here
double x = intNone()[$].toDouble();
return Some(val + x);
});
expect(earlyReturn(2), None);
This is a powerful concept and make you code much more concise without losing any safety.
For async, use Option.async
e.g.
FutureOption<double> earlyReturn() => Option.async(($) async {
...
});
Discussion
If Dart Has Nullable Types Why Ever Use Option
?
Option
is wrapper around a value that may or may be set. A nullable type is a type that may or may not be set.
This small distinction leads to some useful differences:
-
Any extension method on
T?
also exists forT
. So null specific extensions cannot be added. Also sinceT
is all types, there would be a lot of clashes with existing types if you tried to do so - e.g.map
onIterable
. WhileOption
plays well for a pipeline style of programming. -
T??
is not possible, while Option<Option> or Option<T?> is. This may be useful, e.g. No value at all (
None
): The configuration value isn't defined at all.A known absence of a value (
Some(None)
): The configuration value is explicitly disabled.A present value (
Some(Some(value))
): The configuration value is explicitly set to value.With nullable types, a separate field or enum/sealed class would be needed to keep track of this.
-
Correctness of code and reducing bugs. As to why, e.g. consider
nth
which returns the nth index of an iterable or null if the iterable does not have an nth index. If the iterable isIterable<T?>
, then a null value from callingnth
means the nth element is either null or the iterable does not have n elements. While ifnth
rather returnedOption
, if the nth index is null it returnsSome(null)
and if it does not have n elements it returnsNone
. One might accidentally mishandle the nullable case and assume thenth
index does not actually exist, when it is rather just null. While the second case withOption
one is force to handle both cases. This holds true for a lot of operations that might have unintended effects e.g.filterMap
- since null can be a valid state that should not be filtered.
These issues are not insurmountable, and in fact, most of the time nullable types are probably more concise
and easier to deal with. Therefore, for every method in this library that uses T?
there is also an Option
version, usually suffixed with ..Opt
.
In some languages (like Rust, not Dart)
Option
can be passed around like a reference and values can be taken in and out of (transmutation). Thus visible to all with reference to theOption
, unlike null. Implementing such an equivalence in Dart would remove pattern matching and const-ness.
Why Not To Use Option
-
Null chaining operations with
?
is not possible withOption
-
Currently in Dart, one cannot rebind variables and
Option
does not support type promotion like nullable types. This makes usingOption
less ergonomic in some scenarios.
Option<int> xOpt = optionFunc();
int x;
switch(xOpt) {
case Some(:final v):
x = v;
case _:
return;
}
// use `int` x
vs
int? x = nullableFunc();
if(x == null){
return;
}
// use `int` x
Fortunately, it can be converted back and forth.
int? x = optionFunc().toNullable();
if(x == null){
return;
}
// use `int` x
Conclusion
The choice to use Option
is up to the developer. You can easily use this package and never use Option
.
Path
This library introduces path types for working with file paths in a structured and type-safe manner, supporting Unix (POSIX) and Windows file systems. All Path types are extension types of string, so they are zero runtime cost.
Types
Path - A platform
dependent path type - uses WindowPath
on windows and UnixPath
on all other platforms.
UnixPath - A Unix path type.
WindowsPath - A Windows path type.
Basic Operations
Create a path and perform basic operations:
import 'package:rust/rust.dart';
void main() {
var path = UnixPath('/foo/bar/baz.txt');
print('File name: ${path.fileName()}'); // Output: baz.txt
print('Extension: ${path.extension()}'); // Output: txt
print('Is absolute: ${path.isAbsolute()}'); // Output: true
var parent = path.parent();
if (parent != null) {
print('Parent: $parent'); // Output: /foo/bar
}
var newPath = path.withExtension('md');
print('New path with extension: $newPath'); // Output: /foo/bar/baz.md
}
Extracting Components
Get the components of a path:
void main() {
var path = UnixPath('/foo/bar/baz.txt');
var components = path.components().toList();
for (var component in components) {
print(component); // Output: /, foo, bar, baz.txt
}
}
Ancestors
Retrieve all ancestors of a path:
void main() {
var path = UnixPath('/foo/bar/baz.txt');
for (var ancestor in path.ancestors()) {
print(ancestor);
// Output:
// /foo/bar/baz.txt
// /foo/bar
// /foo
// /
}
}
File System Interaction
Check if a path exists and get metadata:
void main() {
var path = UnixPath('/foo/bar/baz.txt');
if (path.existsSync()) {
var metadata = path.metadataSync();
print('File size: ${metadata.size}');
print('Last modified: ${metadata.modified}');
} else {
print('Path does not exist.');
}
}
Panic
As with Error
in Dart Core, Panic represents a state that should never happen and thus should never be caught.
Rust vs Dart Error handling terminology:
Dart Exception Type | Equivalent in Rust |
---|---|
Exception | Error |
Error | Panic |
Result x = Err(1);
if (x.isErr()) {
return x.unwrap(); // this will throw a Panic (should be "unwrapErr()")
}
panic can also be called directly with
throw Panic("Panic message here.");
// or
panic("Panic message here.");
rust was designed with safety in mind. The only time rust will ever throw is if you unwrap
incorrectly (as
above), in
this case a Panic
's can be thrown. But the good news is you can usually avoid using these
methods. See How to Never Unwrap Incorrectly section to avoid ever using unwrap
.
Unreachable
Unreachable
is shorthand for a Panic
where the compiler can’t determine that some code is unreachable.
throw Unreachable();
// or
unreachable();
Handling Panic
For the most part, a panic is meant to abort your program. Thus one should only try to handle panics
sparingly, likely only at the root. Use panicHandler
or panicHandlerAsync
if this is desired.
Result
Result<T, E> is the type used for returning and propagating errors. It is an alternative to throwing exceptions. It is a sealed type with the variants, Ok(T)
, representing success and containing a value, and Err(E)
, representing error and containing an error value.
To better understand the motivation around the Result
type refer to this article.
Example
By using the Result
type, there is no web of try
/catch
statements to maintain and hidden control flow bugs, all control flow is defined.
import 'package:rust/result.dart';
void main() {
final result = processOrder("Bob", 2);
switch(result) {
Ok(v:var okay) => print("Success: $okay"),
Err(v:var error) => print("Error: $error"),
};
}
Result<String, String> processOrder(String user, int orderNumber) {
final result = makeFood(orderNumber);
if(result case Ok(v:final order)) {
final paymentResult = processPayment(user);
if(paymentResult case Ok(v:final payment)) {
return Ok("Order of $order is complete for $user. Payment: $payment");
}
return paymentResult;
}
return result;
}
Result<String, String> makeFood(int orderNumber) {
if (orderNumber == 1) {
return makeHamburger();
} else if (orderNumber == 2) {
return makePizza();
}
return Err("Invalid order number.");
}
Result<String,String> makeHamburger() {
return Err("Failed to make the hamburger.");
}
Result<String,String> makePizza() {
return Ok("pizza");
}
Result<String,String> processPayment(String user) {
if (user == "Bob") {
return Ok("Payment successful.");
}
return Err("Payment failed.");
}
Chaining
Effects on a Result
can be chained in a safe way without
needing to inspect the type.
Result<int,String> result = Ok(4);
Result<String, String> finalResult = initialResult
.map((okValue) => okValue * 2) // Doubling the `Ok` value if present.
.andThen((okValue) => okValue != 0 ? Ok(10 ~/ okValue) : Err('Division by zero')) // Potentially failing operation.
.map((okValue) => 'Result is $okValue') // Transforming the successful result into a string.
.mapErr((errValue) => 'Error: $errValue'); // Transforming any potential error.
// Handling the final `Result`:
finalResult.match(
ok: (value) => print('Success: $value'),
err: (error) => print('Failure: $error'),
);
See the docs for all methods and extensions.
Adding Predictable Control Flow To Legacy Dart Code
At times, you may need to integrate with legacy code that may throw or code outside your project. To handle these situations you can just wrap the code in a helper function like guard
void main() {
Result<int,Object> result = guard(functionWillThrow).mapErr((e) => "$e but was guarded");
print(result);
}
int functionWillThrow() {
throw "this message was thrown";
}
Output:
this message was thrown but was guarded
Dart Equivalent To The Rust "?" Early Return Operator
In Dart, the Rust "?" operator (Early Return Operator) functionality in x?
, where x
is a Result
, can be
accomplished in two ways
into()
if (x case Err()) {
return x.into(); // may not need "into()"
}
into
may be needed to change the S
type of Result<S,F>
for x
to that of the functions return type if
they are different.
into
only exits if after the type check, so you will never mishandle a type change since the compiler will stop you.
Note: There also exists
intoUnchecked
that does not require implicit cast of a Result
Type.
Early Return Key Notation
"Early Return Key Notation" is a take on "Do Notation" and the "Early Return Key"
functions the same way as the "Early Return Operator". The
Early Return Key is typically denoted with $
and when
passed to a Result it unlocks the inner value, or returns to the surrounding context. e.g.
Result<int, String> innerErrFn() => Err("message");
Result<int, String> earlyReturn() => Result(($) { // Early Return Key
int y = 2;
// The function will return here
int x = innerErrFn()[$];
return Ok(x + y);
});
expect(earlyReturn().unwrapErr(), "message");
Using the Early Return Key Notation reduces the need for pattern matching or checking, in a safe way. This is quite a powerful tool. See here for another example.
For async, use the Result.async
e.g.
FutureResult<int, String> earlyReturn() => Result.async(($) async {
...
});
Tips and Best Practices
How to Never Unwrap Incorrectly
In Rust, as here, it is possible to unwrap values that should not be unwrapped:
if (x.isErr()) {
return x.unwrap(); // this will panic (should be "unwrapErr()")
}
There are four ways to never unwrap incorrectly:
Pattern Matching
Simple do a type check with is
or case
instead of isErr()
.
if (x case Err(v:final error)){
return error;
}
and vice versa
if (x case Ok(v:final okay)){
return okay;
}
The type check does an implicit cast, and we now have access to the immutable error and ok value respectively.
Switch
Similarly, we can mimic Rust's match
keyword, with Dart's switch
switch(x){
case Ok(:final v):
print(v);
case Err(:final v):
print(v);
}
final y = switch(x){
Ok(:final v) => v,
Err(:final v) => v,
};
Declaratively
Or declaratively with match
or mapOrElse
x.match(
ok: (ok) => ok,
err: (err) => err
);
Early Return Key Notation
We can also use the Early Return Key Notation, which is a very powerful idiomatic approach.
Working with Futures
When working with Future
s it is easy to make a mistake like this
Future.delayed(Duration(seconds: 1)); // Future not awaited
Where the future is not awaited. With Result's (Or any wrapped type) it is possible to make this mistake
await Ok(1).map((n) async => await Future.delayed(Duration(seconds: n))); // Outer "await" has no effect
The outer "await" has no effect since the value's type is Result<Future<void>>
not Future<Result<void>>
.
To address this use toFutureResult()
await Ok(1).map((n) async => await Future.delayed(Duration(seconds: n))).toFutureResult(); // Works as expected
To avoid these issues all together in regular Dart and with wrapped types like Result
, it is recommended to enable
these Future
linting rules in analysis_options.yaml
linter:
rules:
unawaited_futures: true # Future results in async function bodies must be awaited or marked unawaited using dart:async
await_only_futures: true # "await" should only be used on Futures
avoid_void_async: true # Avoid async functions that return void. (they should return Future<void>)
analyzer:
errors:
unawaited_futures: error
await_only_futures: error
avoid_void_async: error
ToResult and ToResultEager
In various circumstances you may have multiple Result
s and just want a single Result
.
For these times, think toResult()
or in some cases
toResultEager()
. These extension methods have been added to make life easier.
Iterable Result
One of these situations is when you have a Iterable<Result<S,F>>
, which can turn into a
Result<List<S>,List<F>>
. Also, there is .toResultEager()
which can turn into a single Result<List<S>,F>
.
var result = [Ok(1), Ok(2), Ok(3)].toResult();
expect(result.unwrap(), [1, 2, 3]);
result = [Ok<int,int>(1), Err<int,int>(2), Ok<int,int>(3)].toResultEager();
expect(result.unwrapErr(), 2);
Multiple Results of Different Success Types
Sometimes you need to call multiple functions that return Result
s of different types. You could write something
like this:
final a, b, c;
final boolResult = boolOk();
switch(boolResult){
case Ok(:final v):
a = v;
case Err():
return boolResult;
}
final intResult = intOk();
switch(intResult){
case Ok(:final v):
b = v;
case Err():
return intResult;
}
final doubleResult = doubleOk();
switch(doubleResult){
case Ok(:final v):
c = v;
case Err():
return doubleResult;
}
/// ... Use a,b,c
That is a little verbose. Fortunately, extensions to the recuse, instead do:
final a, b, c;
final result = (boolOk, intOk, doubleOk).toResultEager();
switch(result){
case Ok(:final v):
(a, b, c) = v;
case Err():
return result;
}
/// ... Use a,b,c
This also has a toResult()
method.
Pattern Matching vs Early Return Key
void main(){
usingTheEarlyReturnKey();
usingRegularPatternMatching();
}
Result<int,String> usingTheEarlyReturnKey() => Result(($){ // Early Return Key
// Will return here with 'Err("error")'
int x = willAlwaysReturnErr()[$].toInt();
return Ok(x);
});
Result<int,String> usingRegularPatternMatching(){
int x;
switch(willAlwaysReturnErr()){
case Err(:final v):
return Err(v);
case Ok(:final v):
x = v.toInt();
}
return Ok(x);
}
Result<double,String> willAlwaysReturnErr() => Err("error");
Slice
A Slice is a contiguous sequence of elements in a List
. Slices are a view into a list without allocating and copying to a new list,
thus slices are more efficient than creating a sub-list, but they do not own their own data. That means shrinking the original list can cause the slices range to become invalid, which may cause an exception.
Slice
also has a lot of efficient methods for in-place mutation within and between slices. e.g.
var list = [1, 2, 3, 4, 5];
var slice = Slice(list, 1, 4);
slice = list.slice(1,4); // alternative
expect(slice, [2, 3, 4]);
var taken = slice.takeLast();
expect(taken, 4);
expect(slice, [2, 3]);
slice[1] = 10;
expect(list, [1, 2, 10, 4, 5]);
Examples
Array Windows
Create overlapping windows of a specified size from a list:
import 'package:rust/slice.dart';
void main() {
var list = [1, 2, 3, 4, 5];
var slice = Slice(list, 0, 5);
var windows = slice.arrayWindows(2);
print(windows); // [[1, 2], [2, 3], [3, 4], [4, 5]]
}
Chunking
Split a list into chunks of a specified size:
import 'package:rust/slice.dart';
void main() {
var list = [1, 2, 3, 4, 5];
var slice = list.slice();
var (chunks, remainder) = slice.asChunks(2);
print(chunks); // [[1, 2], [3, 4]]
print(remainder); // [5]
}
Binary Search
Perform binary search on a sorted list:
import 'package:rust/slice.dart';
void main() {
Slice<num> s = [0, 1, 1, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55].slice();
var result = s.binarySearch(13);
print(result); // Ok(9)
}
Rotations
Rotate elements to the left or right:
import 'package:rust/slice.dart';
void main() {
var list = ['a', 'b', 'c', 'd', 'e', 'f'];
var slice = list.asSlice();
slice.rotateLeft(2);
print(slice); // ['c', 'd', 'e', 'f', 'a', 'b']
slice.rotateRight(2);
print(slice); // ['e', 'f', 'a', 'b', 'c', 'd']
}
Deduplication
Remove duplicate elements based on a custom equality function:
import 'package:rust/slice.dart';
void main() {
var list = ["foo", "Foo", "BAZ", "Bar", "bar", "baz", "BAZ"];
var slice = list.asSlice();
var (dedup, duplicates) = slice.partitionDedupBy((a, b) => a.toLowerCase() == b.toLowerCase());
print(dedup); // ["foo", "BAZ", "Bar"]
print(duplicates); // ["Foo", "bar", "baz"]
}
Sorting
Sort elements in-place:
import 'package:rust/slice.dart';
void main() {
var list = [5, 4, 3, 2, 1];
var slice = list.asSlice();
slice.sortUnstable();
print(slice); // [1, 2, 3, 4, 5]
}
Reversing
Reverse the elements in a list:
import 'package:rust/slice.dart';
void main() {
var list = [1, 2, 3, 4, 5];
var slice = list.asSlice();
slice.reverse();
print(slice); // [5, 4, 3, 2, 1]
}
Partitioning
Partition elements into those that satisfy a predicate and those that don't:
import 'package:rust/slice.dart';
void main() {
var list = [1, 2, 2, 3, 3, 2, 1, 1];
var slice = list.asSlice();
var (dedup, duplicates) = slice.partitionDedup();
print(dedup); // [1, 2, 3, 2, 1]
print(duplicates); // [2, 3, 1]
}
Copying and Moving
Copy elements within a list or to another list:
import 'package:rust/slice.dart';
void main() {
var srcList = [1, 2, 3, 4, 5];
var dstList = [6, 7, 8, 9, 10];
var src = Slice(srcList, 0, 5);
var dst = Slice(dstList, 0, 5);
dst.copyFromSlice(src);
print(dstList); // [1, 2, 3, 4, 5]
}
Sync
sync contains useful synchronization utilities.
Dart being single threaded, means it is less common to need a synchronization construct like Mutex
, but they are
still certain scenarios where they may be needed.
channel
rust supports two types of channels, "local" channels (same isolate) and "isolate" channels (different isolates).
Local Channels
localChannel
is used for communication between produces and consumers on the same isolate. localChannel
is
similar to StreamController
except it buffers data until read and will never throw.
In more detail, localChannel
returns a Sender
and Receiver
. Each item T
sent by the Sender
will only be seen once by the Receiver
. Even if the Sender
calls close
while the Receiver
's buffer
is not empty, the Receiver
will still yield the remaining items in the buffer until empty.
Examples
Single Sender, Single Receiver
import 'package:rust/sync.dart';
void main() async {
final (tx, rx) = localChannel<int>();
// Sender sends data
tx.send(1);
tx.send(2);
tx.send(3);
// Receiver retrieves data
for (int i = 0; i < 3; i++) {
print(await rx.recv()); // Outputs: 1, 2, 3
}
}
Receiver with Timeout
import 'package:rust/sync.dart';
void main() async {
final (tx, rx) = localChannel<int>();
// Sender sends data
tx.send(1);
// Receiver retrieves data with a timeout
final result = await rx.recvTimeout(Duration(milliseconds: 100));
if (result.isOk()) {
print(result.unwrap()); // Outputs: 1
} else {
print("Timeout"); // If timeout occurs
}
}
Receiver with Error Handling
import 'package:rust/sync.dart';
void main() async {
final (tx, rx) = localChannel<int>();
// Sender sends data and then an error
tx.send(1);
tx.send(2);
tx.sendError(Exception("Test error"));
// Receiver retrieves data and handles errors
for (int i = 0; i < 3; i++) {
final result = await rx.recv();
if (result.isOk()) {
print(result.unwrap()); // Outputs: 1, 2
} else {
print("Error: ${result.unwrapErr()}"); // Handles error
}
}
}
Iterating Over Received Data
import 'package:rust/sync.dart';
void main() async {
final (tx, rx) = localChannel<int>();
// Sender sends data
tx.send(1);
tx.send(2);
tx.send(3);
// Receiver iterates over the data
final iterator = rx.iter();
for (final value in iterator) {
print(value); // Outputs: 1, 2, 3
}
}
Using Receiver as a Stream
import 'package:rust/sync.dart';
void main() async {
final (tx, rx) = localChannel<int>();
// Sender sends data
tx.send(1);
tx.send(2);
tx.send(3);
// Close the sender after some delay
() async {
await Future.delayed(Duration(seconds: 1));
tx.close();
}();
// Receiver processes the stream of data
await for (final value in rx.stream()) {
print(value); // Outputs: 1, 2, 3
}
}
Isolate Channels
isolateChannel
is used for bi-directional isolate communication. The returned
Sender
and Receiver
can communicate with the spawned isolate and
the spawned isolate is passed a Sender
and Receiver
to communicate with the original isolate.
Each item T
sent by the Sender
will only be seen once by the Receiver
. Even if the Sender
calls close
while the Receiver
's buffer
is not empty, the Receiver
will still yield the remaining items in the buffer until empty.
Types that can be sent over a SendPort
, as defined here,
are allow to be sent between isolates. Otherwise a toSpawnedCodec
and/or a fromSpawnedCodec
can be passed
to encode and decode the messages.
Note: Dart does not support isolates on web. Therefore, if your compilation target is web, you cannot use
isolateChannel
.
Examples
Simple String Communication
void main() async {
final (tx, rx) = await isolateChannel<String, String>((tx, rx) async {
assert((await rx.recv()).unwrap() == "hello");
tx.send("hi");
});
tx.send("hello");
expect((await rx.recv()).unwrap(), "hi");
}
Explicitly Defined Codecs for Communication
void main() async {
final (tx, rx) = await isolateChannel<String, int>((tx, rx) async {
assert((await rx.recv()).unwrap() == "hello");
tx.send(1);
}, toSpawnedCodec: const StringCodec(), fromSpawnedCodec: const IntCodec());
tx.send("hello");
expect((await rx.recv()).unwrap(), 1);
}
RwLock
RwLock
is used in critical sections to allow multiple concurrent read operations while ensuring
that write operations are exclusive. RwLock
uses a fifo model to prevent starvation.
e.g. Reading and writing to data sources or transactions -
import 'dart:async';
class DataSource {
final String name;
DataSource(this.name);
Future<String> read() async {
print('Reading from $name');
await Future.delayed(Duration(milliseconds: 100)); // Simulate delay
return 'Data from $name';
}
Future<void> write(String data) async {
print('Writing to $name: $data');
await Future.delayed(Duration(milliseconds: 100)); // Simulate delay
}
}
class DataManager {
final List<DataSource> _dataSources;
final RwLock _rwLock = RwLock();
DataManager(this._dataSources);
Future<List<String>> readFromAll() async {
return await _rwLock.withReadLock(() async {
final results = <String>[];
for (var dataSource in _dataSources) {
results.add(await dataSource.read());
}
return results;
});
}
Future<void> writeToAll(String data) async {
await _rwLock.withWriteLock(() async {
for (var dataSource in _dataSources) {
await dataSource.write(data);
}
});
}
}
void main() async {
final dataSources = [DataSource('DB1'), DataSource('DB2'), DataSource('DB3')];
final manager = DataManager(dataSources);
await Future.wait([
manager.readFromAll().then((data) => print('Read: $data')),
manager.readFromAll().then((data) => print('Read: $data')),
manager.writeToAll('New Data'),
]);
print('All operations completed');
}
Mutex
Mutex
is used to ensure that only one task can perform a critical section of code at a time.
Mutex
uses a fifo model to prevent starvation.
e.g. Transactions -
class Account {
int balance;
Account(this.balance);
}
final mutex = Mutex();
Future<Result<(),Exception>> transfer(Account from, Account to, int amount) async {
await mutex.withLock(() async {
final fromBalance = from.balance;
final toBalance = to.balance;
if (fromBalance < amount) {
return Err(Exception('Insufficient funds for transfer.'));
}
// Simulate some processing delay
await Future.delayed(Duration(milliseconds: 100));
from.balance = fromBalance - amount;
to.balance = toBalance + amount;
return Ok(());
});
}
void main() async {
final account1 = Account(100);
final account2 = Account(50);
await Future.wait([
transfer(account1, account2, 30),
transfer(account2, account1, 20),
]);
}
KeyedMutex
KeyedMutex
is used to ensure that only one task can perform a critical section of code at a time,
where the critical section of code is associated with an key. KeyedMutex
uses a fifo model to prevent starvation.
e.g. Async IO operations -
final keyedMutex = KeyedMutex();
Future<void> deleteFileIfExists(File file) async {
return keyedMutex.withLock(file.absolute, () async {
if (await file.exists()) {
await file.delete();
}
});
}
The alternative to not using KeyedMutex
in the above code would be to use the synchronous
version of th IO api's or risk a scenario like below which would otherwise likely cause an exception.
deleteFileIfExists(File("path/to/file"));
deleteFileIfExists(File("path/to/file"));
Vec
Vec adds additional extension methods for List
and adds a Vec
type - a typedef of List
.
Vec
is used to specifically denote a contiguous growable array,
unlike List
which is growable or non-growable, which may cause runtime exceptions. Vec
being a typedef of
List
means it can be used completely interchangeably.
Vec<int> vec = [1, 2, 3, 4];
// easily translate back and forth
List<int> list = vec;
vec = list;
Vec
is a nice compliment to Arr (array) type.
Usage
import 'package:rust/rust.dart';
void main() {
Vec<int> vec = [1, 2, 3, 4];
Vec<int> replaced = vec.splice(1, 3, [5, 6]);
print(replaced); // [2, 3]
print(vec); // [1, 5, 6, 4]
vec.push(5);
vec.insert(2, 99);
int removed = vec.removeAt(1);
print(removed); // 5
print(vec); // [1, 99, 6, 4, 5]
vec.resize(10, 0);
print(vec); // [1, 99, 6, 4, 5, 0, 0, 0, 0, 0]
Iter<int> iter = vec.extractIf((element) => element % 2 == 0);
Vec<int> extracted = iter.collectVec();
print(extracted); // [6, 4, 0, 0, 0, 0, 0]
print(vec); // [1, 99, 5]
Slice<int> slice = vec.asSlice();
}
Packages Built On Rust
Official
Library | Description |
---|---|
anyhow | Idiomatic error handling capabilities to make your code safer, more maintainable, and errors easier to debug. |
tapper | Extension methods on all types that allow transparent, temporary, inspection/mutation (tapping), transformation (piping), or type conversion. |
Community
Library | Description |
---|---|
rewind | A logging utility that allows you completely customize what is captured and how your logs look at each logging level. |
Tips
Null and Unit
In Dart, void
is used to indicate that a function doesn't return anything or a type should not be used, as such:
Result<void, void> x = Ok(1); // valid
Result<void, void> y = Err(1); // valid
int z = x.unwrap(); // not valid
Since stricter types are preferred and Err
cannot be null, use ()
aka "Unit":