mirror of
https://github.com/rust-lang-cn/book-cn.git
synced 2025-01-23 15:40:27 +08:00
Move (pun intended) closure env capture/borrowing/etc to concurrency
This commit is contained in:
parent
40dfd1eb11
commit
5fbd2fe758
@ -202,235 +202,10 @@ closure form instead
|
|||||||
|
|
||||||
The compiler even reminds us that this only works with closures!
|
The compiler even reminds us that this only works with closures!
|
||||||
|
|
||||||
### Closures, Ownership, and Borrowing
|
Creating closures that capture values from their environment is mostly used in
|
||||||
|
the context of starting new threads. We'll show some more examples and explain
|
||||||
The property of being allowed to use variables from the surrounding scope is
|
more detail about this feature of closures in Chapter 16 when we talk about
|
||||||
also subject to all of the usual rules around ownership and borrowing. Since
|
concurrency.
|
||||||
closures attempt to infer the types of their parameters, they also infer how
|
|
||||||
those parameters are borrowed. Closures make that inference by looking at how
|
|
||||||
they are used. Consider the example in Listing 13-5 that has functions that
|
|
||||||
borrow immutably, borrow mutably, and move their parameters, then closures that
|
|
||||||
reference values from their environment and call each of the functions. We'll
|
|
||||||
see how this affects inference of when a value is borrowed:
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
<span class="filename">Filename: src/main.rs</span>
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Foo;
|
|
||||||
|
|
||||||
fn borrows(f: &Foo) {
|
|
||||||
println!("Took {:?} by reference.", f);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn borrows_mut(f: &mut Foo) {
|
|
||||||
println!("Took {:?} by mutable reference.", f);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn moves(f: Foo) {
|
|
||||||
println!("Took ownership of {:?}.", f);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let f1 = Foo;
|
|
||||||
let closure_that_borrows = |x| borrows(x);
|
|
||||||
closure_that_borrows(&f1);
|
|
||||||
|
|
||||||
let mut f2 = Foo;
|
|
||||||
let closure_that_borrows_mut = |y| borrows_mut(y);
|
|
||||||
closure_that_borrows_mut(&mut f2);
|
|
||||||
|
|
||||||
let f3 = Foo;
|
|
||||||
let closure_that_moves = |z| moves(z);
|
|
||||||
closure_that_moves(f3);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<figcaption>
|
|
||||||
|
|
||||||
Listing 13-5: Closures that borrow, borrow mutably, and take ownership of their
|
|
||||||
parameters, which is inferred from how the closure body uses the parameters
|
|
||||||
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
Here, Rust is able to look at how we use the parameters of each closure inside
|
|
||||||
their bodies. If the closure passes its parameter it to a function that takes
|
|
||||||
`&Foo`, then the type of the parameter must be `&Foo`. If it passes the
|
|
||||||
parameter to a function that takes `&mut Foo`, then the type of parameter must
|
|
||||||
be `&mut Foo`, and so on. If we try to use `f3` after the call to
|
|
||||||
`closure_that_moves` in the last line of `main`, we'll get a compiler error
|
|
||||||
since ownership of `f3` was transferred to `closure_that_moves`, which
|
|
||||||
transferred ownership to the function `moves`.
|
|
||||||
|
|
||||||
### Overriding Inferred Borrowing with the `move` Keyword
|
|
||||||
|
|
||||||
Rust will allow you to override the borrowing inference by using the `move`
|
|
||||||
keyword. This will cause all of the closure's parameters to be taken by
|
|
||||||
ownership, instead of whatever they were inferred as. Consider this example:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let mut num = 4;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut add_num = |x| num += x;
|
|
||||||
|
|
||||||
add_num(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(10, num);
|
|
||||||
```
|
|
||||||
|
|
||||||
In this case, the `add_num` closure took a mutable reference to `num`, then
|
|
||||||
when we called `add_num`, it mutated the underlying value. In the last line,
|
|
||||||
`num` contains 10, as we'd expect. We also needed to declare `add_num` itself
|
|
||||||
as `mut` too, because we're mutating its environment.
|
|
||||||
|
|
||||||
If we change the definition of `add_num` to a `move` closure, the behavior is
|
|
||||||
different:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let mut num = 4;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut add_num = move |x| num += x;
|
|
||||||
|
|
||||||
add_num(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(4, num);
|
|
||||||
```
|
|
||||||
|
|
||||||
In the last line, `num` now contains 4: `add_num` took ownership of a copy of
|
|
||||||
`num`, rather than mutably borrowing `num`.
|
|
||||||
|
|
||||||
One of the most common places you'll see the `move` keyword used is with
|
|
||||||
threads, since it's important that one thread is no longer allowed to use a
|
|
||||||
value once the value has been transferred to another thread through a closure
|
|
||||||
in order to prevent data races. We'll talk more about that in Chapter XX.
|
|
||||||
|
|
||||||
### Closures and Lifetimes
|
|
||||||
|
|
||||||
Remember Listing 10-8 from the Lifetime Syntax section of Chapter 10? It looked
|
|
||||||
like this:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
{
|
|
||||||
let r;
|
|
||||||
|
|
||||||
{
|
|
||||||
let x = 5;
|
|
||||||
r = &x;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("r: {}", r);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This example doesn't compile since `x` doesn't have a long enough lifetime.
|
|
||||||
Because closures may borrow variables from their enclosing scope, we can
|
|
||||||
construct a similar example with a closure that borrows `x` and tries to return
|
|
||||||
that borrowed value. The code in Listing 13-6 also won't compile:
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
{
|
|
||||||
let closure;
|
|
||||||
|
|
||||||
{
|
|
||||||
let x = 4;
|
|
||||||
|
|
||||||
closure = || x ; // A closure that takes no arguments and returns x.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<figcaption>
|
|
||||||
|
|
||||||
Listing 13-6: A closure that tries to return a borrowed value that does not live
|
|
||||||
long enough
|
|
||||||
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
We get an error because `x` does not live long enough:
|
|
||||||
|
|
||||||
```text
|
|
||||||
error: `x` does not live long enough
|
|
||||||
-->
|
|
||||||
|
|
|
||||||
8 | closure = || x ; // A closure that takes no arguments and returns x.
|
|
||||||
| -- ^ does not live long enough
|
|
||||||
| |
|
|
||||||
| capture occurs here
|
|
||||||
9 | }
|
|
||||||
| - borrowed value only lives until here
|
|
||||||
10 | }
|
|
||||||
| - borrowed value needs to live until here
|
|
||||||
```
|
|
||||||
|
|
||||||
To fix the error in the code in Listing 13-6, we can use the `move` keyword
|
|
||||||
from the last section to make the closure take ownership of `x`. Because `x` is
|
|
||||||
a number, it is a `Copy` type and therefore will be copied into the closure.
|
|
||||||
The code in Listing 13-7 will compile:
|
|
||||||
|
|
||||||
<figure>
|
|
||||||
|
|
||||||
```rust
|
|
||||||
{
|
|
||||||
let closure;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut x = 4;
|
|
||||||
|
|
||||||
closure = move || x ; // A closure that takes no arguments and returns x.
|
|
||||||
|
|
||||||
x = 5;
|
|
||||||
|
|
||||||
assert_eq!(closure(), 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<figcaption>
|
|
||||||
|
|
||||||
Listing 13-7: Moving a value into the closure to fix the lifetime error
|
|
||||||
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
Even though we modified `x` between the closure definition and `assert_eq!`,
|
|
||||||
since `closure` now has its own version, the changes to `x` won't change the
|
|
||||||
version of `x` that's in the closure.
|
|
||||||
|
|
||||||
Rust doesn't provide a way to say that some values a closure uses should be
|
|
||||||
borrowed and some should be moved; it's either all by inference or all moved by
|
|
||||||
adding the `move` keyword. However, we can accomplish the goal of borrowing
|
|
||||||
some values and taking ownership of others by combining `move` with some extra
|
|
||||||
bindings. Consider this example where we want to borrow `s1` but take ownership
|
|
||||||
of `s2`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let s1 = String::from("hello");
|
|
||||||
let s2 = String::from("goodbye");
|
|
||||||
|
|
||||||
let r = &s1;
|
|
||||||
|
|
||||||
let calculation = move || {
|
|
||||||
r;
|
|
||||||
s2;
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("Can still use s1 here but not s2: {}", s1);
|
|
||||||
```
|
|
||||||
|
|
||||||
We've declared `calculation` to `move` all the values it references. Before
|
|
||||||
defining `calculation`, we declare a new variable `r` that borrows `s1`. Then
|
|
||||||
in the body of the `calculation` closure, we use `r` instead of using `s1`
|
|
||||||
directly. The closure takes ownership of `r`, but `r` is a reference, so the
|
|
||||||
closure hasn't taken ownership of `s1` even though `calculation` uses `move`.
|
|
||||||
|
|
||||||
### Closures as Function Parameters Using the `Fn` Traits
|
### Closures as Function Parameters Using the `Fn` Traits
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
## Iterators
|
## Iterators
|
||||||
|
|
||||||
Iterators are a pattern in Rust that allows you to do some processing on a
|
Iterators are a pattern in Rust that allows you to do some processing on a
|
||||||
sequence of items. For example, the code in Listing 13-8 adds one to each
|
sequence of items. For example, the code in Listing 13-5 adds one to each
|
||||||
number in a vector:
|
number in a vector:
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
@ -16,7 +16,7 @@ assert_eq!(v2, [2, 3, 4]);
|
|||||||
|
|
||||||
<figcaption>
|
<figcaption>
|
||||||
|
|
||||||
Listing 13-8: Using an iterator, `map`, and `collect` to add one to each number
|
Listing 13-5: Using an iterator, `map`, and `collect` to add one to each number
|
||||||
in a vector
|
in a vector
|
||||||
|
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@ -53,7 +53,7 @@ behavior of an iterator adaptor like `map`.
|
|||||||
In the previous section, you may have noticed a subtle difference in wording:
|
In the previous section, you may have noticed a subtle difference in wording:
|
||||||
we said that `map` *adapts* an iterator, but `collect` *consumes* one. That was
|
we said that `map` *adapts* an iterator, but `collect` *consumes* one. That was
|
||||||
intentional. By themselves, iterators won't do anything; they're lazy. That is,
|
intentional. By themselves, iterators won't do anything; they're lazy. That is,
|
||||||
if we write code like Listing 13-8 except we don't call `collect`:
|
if we write code like Listing 13-5 except we don't call `collect`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let v1: Vec<i32> = vec![1, 2, 3];
|
let v1: Vec<i32> = vec![1, 2, 3];
|
||||||
@ -125,7 +125,7 @@ defining the body of the `next` method. The way we want our iterator to work
|
|||||||
is to add one to the state (which is why we initialized `count` to 0, since we
|
is to add one to the state (which is why we initialized `count` to 0, since we
|
||||||
want our iterator to return one first). If `count` is still less than six, we'll
|
want our iterator to return one first). If `count` is still less than six, we'll
|
||||||
return the current value, but if `count` is six or higher, our iterator will
|
return the current value, but if `count` is six or higher, our iterator will
|
||||||
return `None`:
|
return `None`, as shown in Listing 13-6:
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
|
|
||||||
@ -154,7 +154,7 @@ impl Iterator for Counter {
|
|||||||
|
|
||||||
<figcaption>
|
<figcaption>
|
||||||
|
|
||||||
Listing 13-9: Implementing the `Iterator` trait on our `Counter` struct
|
Listing 13-6: Implementing the `Iterator` trait on our `Counter` struct
|
||||||
|
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
@ -218,8 +218,8 @@ line.
|
|||||||
|
|
||||||
### All Sorts of `Iterator` Adaptors
|
### All Sorts of `Iterator` Adaptors
|
||||||
|
|
||||||
In Listing 13-8, we had iterators and we called methods like `map` and
|
In Listing 13-5, we had iterators and we called methods like `map` and
|
||||||
`collect` on them. In Listing 13-9, however, we only implemented the `next`
|
`collect` on them. In Listing 13-6, however, we only implemented the `next`
|
||||||
method on our `Counter`. How do we get methods like `map` and `collect` on our
|
method on our `Counter`. How do we get methods like `map` and `collect` on our
|
||||||
`Counter`?
|
`Counter`?
|
||||||
|
|
||||||
|
@ -41,6 +41,238 @@ Code examples - just print stuff, no data sharing
|
|||||||
|
|
||||||
## Communicating between threads
|
## Communicating between threads
|
||||||
|
|
||||||
|
|
||||||
|
### Closures, Ownership, and Borrowing
|
||||||
|
|
||||||
|
The property of being allowed to use variables from the surrounding scope is
|
||||||
|
also subject to all of the usual rules around ownership and borrowing. Since
|
||||||
|
closures attempt to infer the types of their parameters, they also infer how
|
||||||
|
those parameters are borrowed. Closures make that inference by looking at how
|
||||||
|
they are used. Consider the example in Listing 13-5 that has functions that
|
||||||
|
borrow immutably, borrow mutably, and move their parameters, then closures that
|
||||||
|
reference values from their environment and call each of the functions. We'll
|
||||||
|
see how this affects inference of when a value is borrowed:
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<span class="filename">Filename: src/main.rs</span>
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Foo;
|
||||||
|
|
||||||
|
fn borrows(f: &Foo) {
|
||||||
|
println!("Took {:?} by reference.", f);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn borrows_mut(f: &mut Foo) {
|
||||||
|
println!("Took {:?} by mutable reference.", f);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn moves(f: Foo) {
|
||||||
|
println!("Took ownership of {:?}.", f);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let f1 = Foo;
|
||||||
|
let closure_that_borrows = |x| borrows(x);
|
||||||
|
closure_that_borrows(&f1);
|
||||||
|
|
||||||
|
let mut f2 = Foo;
|
||||||
|
let closure_that_borrows_mut = |y| borrows_mut(y);
|
||||||
|
closure_that_borrows_mut(&mut f2);
|
||||||
|
|
||||||
|
let f3 = Foo;
|
||||||
|
let closure_that_moves = |z| moves(z);
|
||||||
|
closure_that_moves(f3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<figcaption>
|
||||||
|
|
||||||
|
Listing 16-something: Closures that borrow, borrow mutably, and take ownership
|
||||||
|
of their parameters, which is inferred from how the closure body uses the
|
||||||
|
parameters
|
||||||
|
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
Here, Rust is able to look at how we use the parameters of each closure inside
|
||||||
|
their bodies. If the closure passes its parameter it to a function that takes
|
||||||
|
`&Foo`, then the type of the parameter must be `&Foo`. If it passes the
|
||||||
|
parameter to a function that takes `&mut Foo`, then the type of parameter must
|
||||||
|
be `&mut Foo`, and so on. If we try to use `f3` after the call to
|
||||||
|
`closure_that_moves` in the last line of `main`, we'll get a compiler error
|
||||||
|
since ownership of `f3` was transferred to `closure_that_moves`, which
|
||||||
|
transferred ownership to the function `moves`.
|
||||||
|
|
||||||
|
### Overriding Inferred Borrowing with the `move` Keyword
|
||||||
|
|
||||||
|
Rust will allow you to override the borrowing inference by using the `move`
|
||||||
|
keyword. This will cause all of the closure's parameters to be taken by
|
||||||
|
ownership, instead of whatever they were inferred as. Consider this example:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut num = 4;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut add_num = |x| num += x;
|
||||||
|
|
||||||
|
add_num(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(10, num);
|
||||||
|
```
|
||||||
|
|
||||||
|
In this case, the `add_num` closure took a mutable reference to `num`, then
|
||||||
|
when we called `add_num`, it mutated the underlying value. In the last line,
|
||||||
|
`num` contains 10, as we'd expect. We also needed to declare `add_num` itself
|
||||||
|
as `mut` too, because we're mutating its environment.
|
||||||
|
|
||||||
|
If we change the definition of `add_num` to a `move` closure, the behavior is
|
||||||
|
different:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut num = 4;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut add_num = move |x| num += x;
|
||||||
|
|
||||||
|
add_num(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(4, num);
|
||||||
|
```
|
||||||
|
|
||||||
|
In the last line, `num` now contains 4: `add_num` took ownership of a copy of
|
||||||
|
`num`, rather than mutably borrowing `num`.
|
||||||
|
|
||||||
|
One of the most common places you'll see the `move` keyword used is with
|
||||||
|
threads, since it's important that one thread is no longer allowed to use a
|
||||||
|
value once the value has been transferred to another thread through a closure
|
||||||
|
in order to prevent data races. We'll talk more about that in Chapter XX.
|
||||||
|
|
||||||
|
### Closures and Lifetimes
|
||||||
|
|
||||||
|
Remember Listing 10-8 from the Lifetime Syntax section of Chapter 10? It looked
|
||||||
|
like this:
|
||||||
|
|
||||||
|
```rust,ignore
|
||||||
|
{
|
||||||
|
let r;
|
||||||
|
|
||||||
|
{
|
||||||
|
let x = 5;
|
||||||
|
r = &x;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("r: {}", r);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This example doesn't compile since `x` doesn't have a long enough lifetime.
|
||||||
|
Because closures may borrow variables from their enclosing scope, we can
|
||||||
|
construct a similar example with a closure that borrows `x` and tries to return
|
||||||
|
that borrowed value. The code in Listing 13-6 also won't compile:
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
|
||||||
|
```rust,ignore
|
||||||
|
{
|
||||||
|
let closure;
|
||||||
|
|
||||||
|
{
|
||||||
|
let x = 4;
|
||||||
|
|
||||||
|
closure = || x ; // A closure that takes no arguments and returns x.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<figcaption>
|
||||||
|
|
||||||
|
Listing 16-something: A closure that tries to return a borrowed value that does
|
||||||
|
not live long enough
|
||||||
|
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
We get an error because `x` does not live long enough:
|
||||||
|
|
||||||
|
```text
|
||||||
|
error: `x` does not live long enough
|
||||||
|
-->
|
||||||
|
|
|
||||||
|
8 | closure = || x ; // A closure that takes no arguments and returns x.
|
||||||
|
| -- ^ does not live long enough
|
||||||
|
| |
|
||||||
|
| capture occurs here
|
||||||
|
9 | }
|
||||||
|
| - borrowed value only lives until here
|
||||||
|
10 | }
|
||||||
|
| - borrowed value needs to live until here
|
||||||
|
```
|
||||||
|
|
||||||
|
To fix the error in the code in Listing 13-6, we can use the `move` keyword
|
||||||
|
from the last section to make the closure take ownership of `x`. Because `x` is
|
||||||
|
a number, it is a `Copy` type and therefore will be copied into the closure.
|
||||||
|
The code in Listing 13-7 will compile:
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
|
||||||
|
```rust
|
||||||
|
{
|
||||||
|
let closure;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut x = 4;
|
||||||
|
|
||||||
|
closure = move || x ; // A closure that takes no arguments and returns x.
|
||||||
|
|
||||||
|
x = 5;
|
||||||
|
|
||||||
|
assert_eq!(closure(), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<figcaption>
|
||||||
|
|
||||||
|
Listing 16-something: Moving a value into the closure to fix the lifetime error
|
||||||
|
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
Even though we modified `x` between the closure definition and `assert_eq!`,
|
||||||
|
since `closure` now has its own version, the changes to `x` won't change the
|
||||||
|
version of `x` that's in the closure.
|
||||||
|
|
||||||
|
Rust doesn't provide a way to say that some values a closure uses should be
|
||||||
|
borrowed and some should be moved; it's either all by inference or all moved by
|
||||||
|
adding the `move` keyword. However, we can accomplish the goal of borrowing
|
||||||
|
some values and taking ownership of others by combining `move` with some extra
|
||||||
|
bindings. Consider this example where we want to borrow `s1` but take ownership
|
||||||
|
of `s2`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let s1 = String::from("hello");
|
||||||
|
let s2 = String::from("goodbye");
|
||||||
|
|
||||||
|
let r = &s1;
|
||||||
|
|
||||||
|
let calculation = move || {
|
||||||
|
r;
|
||||||
|
s2;
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Can still use s1 here but not s2: {}", s1);
|
||||||
|
```
|
||||||
|
|
||||||
|
We've declared `calculation` to `move` all the values it references. Before
|
||||||
|
defining `calculation`, we declare a new variable `r` that borrows `s1`. Then
|
||||||
|
in the body of the `calculation` closure, we use `r` instead of using `s1`
|
||||||
|
directly. The closure takes ownership of `r`, but `r` is a reference, so the
|
||||||
|
closure hasn't taken ownership of `s1` even though `calculation` uses `move`.
|
||||||
|
|
||||||
### `Channels`
|
### `Channels`
|
||||||
|
|
||||||
Look up examples of cases where channels are useful
|
Look up examples of cases where channels are useful
|
||||||
|
Loading…
Reference in New Issue
Block a user