user-defined iteration using range over func values #56413
Replies: 53 comments 330 replies
-
At first, I'd like to note that a general idea of something iterator-related is a welcome addition to the language. When I started with Go (coming from a C# background), the differences between the ways of iteration were quite confusing. And they still are! C# has this notion of: everything that is an However, LINQ is a beast itself and introducing something like that is definitely not suitable for the goals of the Go programming language. And I would even argue that it's not needed. The concept of pull and push functions is clear. Incorporating this even further into the language, e.g. by defining a As for the range over ints proposal: Python has something similar, therefore this new pattern could improve the adoptability across Python developers. I, for one, don't have a strong opinion for or against it, as I'm already too used to the C-way. Edit: fixed some minor grammar corrections, simply because English is not my first language. |
Beta Was this translation helpful? Give feedback.
-
I'm really interested in what the transform for push functions will be to allow flow control statements would be. This would effectively add a form of non-local return to Go, which other languages use to make these sorts of internal iterators feel nice. |
Beta Was this translation helpful? Give feedback.
-
A few assorted thoughts.
|
Beta Was this translation helpful? Give feedback.
-
The push function case has a weird quality that I think is novel. The I don't think we could entirely dispose of the three-clause loop, but I do agree that I'd be fine with not needing it in the "count to n" case. Anyway, as a person who's repeatedly wanted to request iterator support in the language, I will say that I like this a lot, and at least so far, this feels like something that I'd use and not hate, which is pretty high praise for programming languages. |
Beta Was this translation helpful? Give feedback.
-
Thanks for writing this up @rsc, and for providing a clear mental model around push and pull based iteration! I really like the direction this is going. One thing that seemed a bit nuanced in the description was the push pull distinction apparently requiring different parenthesis.
This I likely an artifact of your example having Iter() return a function instead of an intermediate value (as in the discussion), because I think that this clears up the nuance (despite being more verbose):
I also think the decision to pass a function to The biggest concern I can think of is not really a concern with the proposed changes to This could be solved with some syntax in interface definitions (for example):
This is similar to the operator based approach that was decided against for type parameters (in favor of the named types approach) so there may be issues there that led to that decision that I don't know about. It also has the downside that you lose information (once you have an Alternatively it could be solved by heavily restricting the proposal so that range only accepts functions with one signature (probably
A third option is to split the difference, allow some number of types (more than one and fewer than six) so that if you want to write code that takes something that can be passed to range, you only need a couple of different copies. In any case it would be nice to be able to write code like this and pass anything that could be passed to range to a function (but probably not a deal breaker if you can't):
|
Beta Was this translation helpful? Give feedback.
-
What is the intent for using these as iterators? I know that the discussion here splits, but to me, if these are not usable to write an iterator library, I don't really see the point for a relatively invasive language change. From what I can tell, there is no realistic way to write a function which takes "either a push or a pull function". There isn't even a way to write one which can take a push function, due to that having 6 (?) different forms. I mean, you can write a type-constraint for "it has to be any of these" and use a type-switch, but that isn't exactly ergonomic. So all I could think of is iterator-compositions taking a form they need and returning a form they find convenient. With the user being expected to use the appropiate glue code to transform them back-and-forth. Especially given that some of these need a separate So while I can totally see how this would enable us to iterate over user-defined collections (and I think it does that reasonably well, though I find the dangers of AIUI one of the goals is to provide the language change needed to then do #54245. But #54245 really only needs pull-functions to be |
Beta Was this translation helpful? Give feedback.
-
I like all of this proposal but it's not clear to me how control flow statements would be implemented within the body of a loop ranging over a push iterator.
Break and continue are easy ( // return value
var rval T
var doReturn bool
t.All(func(k K, v V) bool {
rval, doReturn = value, true
return false
})
if doReturn { return rval }
// goto label
var gotoLabel bool
t.All(func(k K, v V) bool {
gotoLabel = true
return false
})
if gotoLabel { goto label }
// defer fn()
var deferred []func()
defer func() {
for n := len(deferred)-1; n >= 0; n-- {
deferred[n]()
}
}()
t.All(func(k K, v V) bool {
deferred = append(deferred, fn())
}) Though I am not confident that my |
Beta Was this translation helpful? Give feedback.
This comment has been hidden.
This comment has been hidden.
-
My 5 cents:
If people were to start writing
or
then that would be worse than what exists today. So, overall, I'm skeptical of this proposal. For me, I'd probably be happier reading and writing the original code rather than these new 'range' equivalents. For me, the main objective of adding iterators to the language is to provide common types shared by many. Code using or producing iterators written by different people would be automatically compliant, because they're both standardizing on the same common library types. Without that, additional code is being written to translate one iterator type to the other. So, from this proposal I suppose the pull and push functions suggest that you might define standardized iterators to be:
And then perhaps people might decide to standardize on these two iterator data types, but frankly, they are probably not what I would choose to standardize on (although that would be a whole new discussion). |
Beta Was this translation helpful? Give feedback.
-
I like this. One tiny nit, though... I would prefer to leave out the possibility of a push function returning a bool. It doesn't save much code, as any function returning a bool can be trivially wrapped in a function that returns nothing. The tree example above could be rewritten:
I would prefer either to say that a push function must return nothing, or to say that a push function can have any return type(s) at all, including nothing, and for...range will ignore the returned values. |
Beta Was this translation helpful? Give feedback.
-
Would the 0 arity version of an iterator be allowed? What about in-line functions? |
Beta Was this translation helpful? Give feedback.
-
I really like the idea of getting some form of standardized enumeration/iteration in Go. For my 2 cents, I'd like to start with a as concise and explicit TL;DR summary of push/pull functions as I've understood them:
I generally like this, as it makes for a set of very small yet simple and flexible methods of enumeration/iteration over a collection. SuggestionNext, and feel free to disagree here, I'd like to suggest alternative names for push/pull functions:
EnumeratorMy reasoning for "enumerator" is largely due to my history with Ruby, where any object can be made enumerable by simply defining a Personally at least, the word "push" feels suggestive of pushing values into the collection. Hence when reading the code examples, I realized push functions works very different from the initial impression I got based on the name. IteratorAs for "iterator", my reasoning is simply that it feels very similar to other types of iterator objects I've come across which may have Type safety?The only thing I feel slightly uneasy about with these functions, is that I don't see how the type system could be used to reliably ensure a function given to range is a push or pull function, and not simply something completely different that has a bool as it's final return value, or a func arg with a bool as a final return value. Range intAnd finally, regarding range over ints, conceptually the wording of something like Though I personally feel fine about using the three-clause for loop on the rare occasion I need to loop N times. And that's despite my history with Ruby and its |
Beta Was this translation helpful? Give feedback.
-
If you have |
Beta Was this translation helpful? Give feedback.
-
I don't understand why it needs to be as complicated as this. Can't Go define the standard iteration interfaces that range will support in the stdlib, and give an order of precedence and/or chose the interface based on the declared range variables. All you need is the standard pull-type interfaces. I "think" this is complicated because there is still a design concern about supporting general iteration over a map - requiring a push interface. I think this is easily addressed with built-ins, e.g. iter(somemap) that return one of the above declared interfaces. For non-builtin containers this is not an issue. I don't see how "generators" align with the Go team's concerns over flow control (which seems to have been the major barrier to exceptions). "generators" (aka hidden threads or coroutines) are the magic that Go typically tries to avoid. |
Beta Was this translation helpful? Give feedback.
-
I like this idea. It solves a bunch of the confusion that arose trying to deal with an iterator I thought a bit about how a general iteration package could be implemented around this, and I think the best option is to deal with push functions primarily. Everything else can be very easily and cheaply converted to them, so it seems like the most general, simplest form. Here's some examples, assuming that #49085 or something similar isn't adopted: package iter
type Push[T any] func(yield func(T) bool) bool
type Pair[T1, T2 any] struct {
A T1
B T2
}
// Uses Pair to get the index, too.
func FromSlice[E any, S ~[]E](s S) Push[Pair[int, E]] {
return func(yield func(Pair[int, E]) bool) bool {
for i, v := range s {
ok := yield(Pair[int, E]{i, v})
if !ok {
return false
}
}
return true
}
}
func FromMap[K comparable, V any, M ~map[K]V](m M) Push[Pair[K, V]] { ... }
func FromChan[E any, C ~chan E](c C) Push[E] { ... }
func Map[T, R any](f Push[T], m func(T) R) Push[R] {
return func(yield func(R) bool) bool {
return f(func(v T) bool { return yield(m(v)) }
}
}
func Filter[T any](f Push[T], f func(T) bool) Push[T] { ... }
func Reduce[T, R any](f Push[T], initial R, f func(R, T) R) R { ... }
// These might be redundant because of Reduce(), though it would be nice to have common functionality like this pre-written.
func IntoSlice[E any, S ~[]E](s S, f Push[E]) S { ... }
func IntoMap[K comparable, V any, M ~map[K]V](m M, f Push[Pair[K, V]]) { ... } And so on. The composition of the push functions is kind of interesting, but it's a bit confusing to look at, I think. It might be easier with something like #21498, but I'm not entirely sure. |
Beta Was this translation helpful? Give feedback.
-
I am slightly concerned about ambiguity when an "element" bool is returned (i.e. ambiguity of use, as called out by @rsc as "accidental iterators"). While these may not show up all that often in the stdlib, but I wonder if these may show up more frequently in community code. For example:
The above would appear to be a valid iterator despite not likely being designed with iteration in mind. If designed to be an iterator, it'd probably look more like:
Side note: in practice, such iterators may always return true for the |
Beta Was this translation helpful? Give feedback.
-
iirc, the rationale for not including custom iterators in the language initially was to prevent hiding cost and side effects (thus decreasing the ability to reason about code). Today, if I see I wonder if the loss of these reasoning guarantees is truly outweighed by the convenience gained through this proposal. This concern would be mitigated if we had distinct syntax of some kind, such as This may also ease integration existing tooling, since I didn't see any behavior around omitted variables in the original proposal (i.e. |
Beta Was this translation helpful? Give feedback.
-
I believe there's too much magic in the variety of function signatures that will be accepted by the compiler. I'm thinking about what it would be like to teach this to new Go programmers, and I suspect there'll be a mystical aspect to this which isn't present elsewhere in the language (aside from unintended design consequences, like loop iterator bugs). A new programmer may learn that they can iterate over any function which returns They also learn that they can iterate on integers! It sounds like you can iterate on almost anything. They might infer that they can iterate on At this time, I'd favor a variation on #54245 that does not allow signatures to vary in anything but type (i.e. accept Even without extension methods, I believe there's more value (following the introduction of generics) of a single precise [generic] method signature that is accepted, rather than a family of function [and potentially method] signatures. If supporting functions, and not just methods, is critical, I'd be more comfortable with a single signature for push iterators, and a single signature for pull iterators. I think it's also just fine to encourage modeling iterators after |
Beta Was this translation helpful? Give feedback.
-
My current worry about adding pull functions as range arguments is that it's a backdoor way to add coroutines to Go. I feel like coroutines should either be first class (have a I worry that it's an avenue for possible abuse, and people are going to do "clever" things, like make "Twisted Go" (a la Twisted Python) that simulate an async system with pull functions in order to avoid the Go runtime scheduler, for whatever reason. ISTM something as important as a new mechanism that lets you suspend a function without using goroutines shouldn't be tied to the range statement. |
Beta Was this translation helpful? Give feedback.
-
In my opinion, yield is a complicated enough concept to cause a lot of bad incomprehensible code to appear, this suggestion provides only a syntax sugar for writing something, that is already more than possible in the language. I believe, this goes against a rule of |
Beta Was this translation helpful? Give feedback.
-
Pardon me if this has already been discussed, but it occurs to me that push functions could be replaced by a simple variant of pull functions. I'm not sure whether this would be better than push functions (that's surely a subjective matter where opinions will vary), but it seems to me a quite reasonable alternative. Let's consider just the case of loop yielding one values at a time: func() (T, bool) We could also consider pull functions with signature func(bool) (T, bool) The intent here is when called with Given a function for x := range pullx {
fmt.Println(x)
if x >= 5 {
break
}
} and the compiler would translate this into something similar to for x, ok := pullx(true); ok; x, ok = pullx(true) {
fmt.Println(x)
if x >= 5 {
pullx(false)
break
}
} The initial discussion remarks that any push function can be automatically transformed into a pair of a func pullx(yield bool) (value T, ok bool) {
if yield {
return next()
} else {
stop()
return
}
} and this new function can be used as the object of a Some questions around this:
|
Beta Was this translation helpful? Give feedback.
This comment was marked as spam.
This comment was marked as spam.
-
The ergonomics of push-based iterators seem nice, but I'm concerned it has a lot of corner cases to think about:
I expect these questions don't directly matter to most users, but I think they're relevant to the compiler for how it desugars control flow statements. In turn, this is indirectly relevant to users because it could affect performance. I think a lot of misuse (e.g., questions 1 and 2) could be cheaply caught by simply poisoning the closure's PC field after we don't expect it to be called any further. |
Beta Was this translation helpful? Give feedback.
-
On Wed, Jan 18, 2023 at 2:54 PM Ian Lance Taylor ***@***.***> wrote:
Actually, thinking about this more, I'm not sure that the exact order in
which the defer statements are executed should be precisely defined. One
possible implementation of for/range over a push function is to start a
new goroutine and have the push function send the values over a channel
(with a second channel used to exit the goroutine on loop termination if
necessary). I don't think we want to rule out that implementation a priori.
In that case the interleaving of the "iter" and "loop" messages would not
be fully specified.
If I understand correctly what you're suggesting, then I must disagree.
The "iter" messages must appear before the "end" message, because they are
deferred by the push function, which terminates before main defers the end
message.
And the "loop" messages must appear after the "end" message, because they
are deferred earlier and by the same function (main).
To have a new form of iteration change this would be endlessly confusing
for programmers. And I imagine it would lead to many data races in programs
with no visible goroutines. Also, a future change in implementation from
"no extra goroutine" to "a secondary goroutine" would change program
results and introduce data races.
No, an implementation using another goroutine would still have to guarantee
this order, in my opinion. I know almost nothing of the compiler internals,
but I imagine this would be doable but possibly quite difficult.
Or am I misunderstanding something?
|
Beta Was this translation helpful? Give feedback.
-
I really like how this proposal provides a unified syntax ( But, as someone who reads (reviews) much more code than I write, my only (non-blocking) concerns are about the complexified mental model around a So far when I see the following loop: for a, b := range X {
...
} I only have to determine if X is an However by introducing push/pull range iterators, the number of possible types will explode. And even more, the cost of each iteration style will be much more varied: a user-defined iterator might have some bugs or performance issues that I don't expect from built-in iterators. The risk of hidden panics will also explode (so far, no panic on iterating on a nil slice or nil map). My existing review tooling ( That is a case where this added syntactic sugar will ease write more concise Go, but increase mental load of human readers. Range over plain integers ( |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
This comment was marked as spam.
This comment was marked as spam.
-
For Range over ints, would using the same syntax as for slicing subscripts be more "Go-like"? E.g. |
Beta Was this translation helpful? Give feedback.
-
There is no standard way to iterate over a sequence of values in Go. For lack of any convention, we have ended up with a wide variety of approaches. Each implementation has done what made the most sense in that context, but decisions made in isolation have resulted in confusion for users.
In the standard library alone, we have archive/tar.Reader.Next, bufio.Reader.ReadByte, bufio.Scanner.Scan, container/ring.Ring.Do, database/sql.Rows, expvar.Do, flag.Visit, go/token.FileSet.Iterate, path/filepath.Walk, go/token.FileSet.Iterate, runtime.Frames.Next, and sync.Map.Range, hardly any of which agree on the exact details of iteration. Even the functions that agree on the signature don’t always agree about the semantics. For example, most iteration functions that return (T, bool) follow the usual Go convention of having the bool indicate whether the T is valid. In contrast, the bool returned from runtime.Frames.Next indicates whether the next call will return something valid.
When you want to iterate over something, you first have to learn how the specific code you are calling handles iteration. This lack of uniformity hinders Go’s goal of making it easy to easy to move around in a large code base. People often mention as a strength that all Go code looks about the same. That’s simply not true for code with custom iteration.
We should converge on a standard way to handle iteration in Go, and one way to incentivize that is to support it directly in range syntax. Specifically, the idea is to allow range over function values of certain types. If any kind of code providing iteration implements such a function, then users can write the same kind of range loop they use for slices and maps and stop worrying about whether they are using a bespoke iteration API correctly.
This GitHub Discussion is about this idea of allowing range over function values. This is obviously related to the iterator discussion (#54245), but one aim of this discussion is to separate out just the idea of a language change for customized range behavior, which should probably be done independently of an iterator library. A library for iterators can then be built using and augmenting the range change, not being the cause of it.
To date, range's behavior has depended only on the type of its argument, not methods the argument has, nor any other details of the argument. Range currently handles slice, (pointer to) array, map, chan, and string arguments. We can extend range to support user-defined behavior by adding certain forms of func arguments.
There are two natural kinds of func arguments we might want to support in range: push functions and pull functions (definitions below). These kinds of funcs are duals of each other, and while push functions are more suited to range loops, both are useful in different contexts.
This posts suggests that for loops allow range over both push functions and pull functions. The end of the post also suggests range over int.
The rest of this post explains all this in more detail.
Push functions
A push function is a function with a type of one of these forms:
That is, a push function takes a single argument, here named yield, although that exact name is not a requirement. The yield argument is itself a function taking N arguments (0 ≤ N ≤ 2) (denoted by
...
in the pseudo-syntax above) and returning a single bool. The push function itself must return nothing at all or else a single bool. The optional bool allows the push function to indicate whether it stopped early, which can be useful when composing push functions; when called using range syntax, the compiled code would ignore the result.The push function enumerates a sequence of values by calling yield repeatedly. The bool result from yield indicates whether to keep yielding operations (true means continue running, false means stop). Each call to yield runs the range loop body once and then returns. When there are no more values to pass to yield, or if yield returns false, the push function returns.
In short, a push function pushes a sequence of values into the yield function.
For example, here is a method to traverse a binary tree:
The method value
t.All
is a push function: it has signaturefunc(func(K, V) bool) bool
.With that method, one can write today:
(In this usage, the caller doesn’t care about the boolean result from t.All, only the fact that it calls f on every key-value pair.)
Adding support for push functions to range would allow writing this equivalent code:
In fact, the Go compiler would effectively rewrite the second form into the first form, turning the loop body into a synthesized function to pass to t.All. However, that rewrite would also preserve the “on the page” semantics of code inside the loop like
break
,continue
,defer
,goto
, andreturn
, so that all those constructs would execute the same as in a range over a slice or map.If you are worried about the subtle variable scoping difference, consider the change discussed in #56010 a prerequisite of adding func support to range.
Note that the results of the push function (if any) are discarded when using the range form. Most often a push function will return nothing at all, or else a bool indicating whether the loop stopped early, as the All method does to make recursion easier.
A method x.All(f), which may become a common pattern, has two different, equally valid interpretations. One is that f is a yield function and All passes all the tree's contents to f. The other is that f is a condition function and All reports whether the condition is true for all the contents of the tree, stopping the traversal once it determines the result.
Pull functions
A pull function is a function with a type of the form
That is, a pull function takes no arguments and returns the next set of N values (0 ≤ N ≤ 2) from the sequence. Each valid set of values comes with a final true bool result. When there are no more values, the pull function returns arbitrary values and a false bool.
A pull function must maintain internal state, so that repeated calls return successive values.
In short, a pull function lets the caller pull successive elements from the sequence, one at a time.
For example, here is a method that returns a pull function to traverse a linked list:
The method value
l.Iter
is not a pull function, but it returns one.With that method, one can write today:
Adding support for pull functions to range would allow writing this equivalent code:
In fact, the Go compiler would effectively rewrite the second form into the first form. Again, consider the scope change in #56010 a prerequisite.
If some iterator-like value had a Next method that returned (value, bool), we could write:
Note that range over pull functions has been proposed by itself as #43557, and the discussion also considered push functions (for example, #43557 (comment)). Both can be appropriate at different times.
Duality of push and pull functions
Any push function can be converted into a pull function and vice versa.
Converting a pull function into a push function is a few lines of code:
Converting a push function into a pull function is more involved. Because the push function has its own state maintained in its stack (like in the binary tree traversal), that code must run in a separate goroutine in order to give it a stack that persists across calls to the next function. The full code is in this playground snippet.
It can be arranged that the separate goroutine executes with its own stack but not actually running in parallel with the caller. With a bit of smarts in the compiler and runtime, but no changes to the Go language or any of its semantics, that lack of parallelism allows the separate goroutine to be optimized into a coroutine, so that switches between the caller and the push function are fairly cheap. The details of the optimization are beyond the scope of this discussion but are posted in the “Appendix” of #54245.
The signature for converting a push function to a pull function is
The conversion must return two functions: the pull function
next
and a cleanup functionstop
, which shuts down the goroutine.Although push and pull functions are duals, they have important differences. Push functions are easier to write and somewhat more powerful to invoke, because they can store state on the stack and can automatically clean up when the traversal is over. That cleanup is made explicit by the
stop
callback when converting to the pull form.For example, the binary tree traversal above was made very easy by being able to use recursion in its implementation. A direct implementation of a pull form would need to maintain its own explicit stack instead, like:
That implementation is much harder to reason about and probably contains a bug.
As another example of the power of push functions and automatic cleanup, consider this function that allows ranging over the lines from a file:
This could be used as:
Note that the implementation of Lines can use defer to clean up automatically when the loop is done. An implementation using a pull function would need a separate stop function to close the file.
A push function usually represents an entire sequence of values, so that it can be called multiple times to traverse the sequence multiple times. It can usually also be called simultaneously from different goroutines if they both want to traverse the sequence, without any synchronization. In contrast, a pull function always represents a specific point in one traversal of the sequence. It can be advanced to the end of the sequence, but then it can't be reused. Goroutines cannot share a pull function without synchronization, but a pull function can be used from multiple call sites in a single goroutine, such as a lexer pulling bytes from an input source.
In terms of concepts in other languages, a push function can be thought of as representing an entire collection. The implementation of the push function maintains iterator state implicitly on its stack, so that multiple uses of the push function use separate instances of the iterator state. In contrast, a pull function can be thought of as representing an iterator, not an entire collection.
Push and pull functions represent different ways of interacting with data, and one way may be more appropriate than the other depending on the data. For example, many programs process the lines in a file in a single loop, so a push function is appropriate for lines in a file. In contrast, it is difficult to imagine any programs that would process the bytes in a file with a single loop (except maybe wc), while many process bytes in a file incrementally from many call sites (again, lexers are an example), so a pull function is more appropriate for bytes in a file.
Because both forms are appropriate in different contexts, range loops should support functions of both types. Note that there is no overlap between the two function kinds: push functions always have one argument, while pull functions always have no arguments.
Alternatives
An alternative would be to extend range by recognizing special methods. For example if range knew to call a .Range method, then we could define (*Tree).Range and then use
instead of
One aesthetic reason not to do this is that range today uses types to make the decision, and it seems cleaner to continue to do that. In fact, there is nothing in the language today that calls specially defined methods. (The closest to that is the definition of the error interface, but no language construct calls the Error method.) Aesthetic reasons aside, though, there are two practical problems with a method-based decision.
The first problem with a method-based decision is that only a single method can implement the behavior. Using functions, other methods can be called instead simply by naming them. For example we might define t.AllReverse that enumerates the tree in reverse order, and then a loop can use
Similarly, an iterator that defines Next might also define Prev, allowing
The second problem with a method-based decision is that it can conflict with the type-based decision. For example if the loop calls the Range method, what happens in a range over a channel value that also has a Range method? Is it treated like other channels, ignoring the Range method? It would seem that must be the case, for backwards compatibility. But then it's confusing that the Range method doesn't win.
Continuing the type-based decision instead of introducing a new method-based decision rule avoids these problems.
Range over ints
One common problem for developers not coming from the C family of languages is puzzling through the Go idiom
When you stop to explain it, that’s a lot of machinery to say “count to n”.
One common use case that people have mentioned for user-defined range behaviors is to have a standard function to simplify that pattern, like:
used as:
If this will become the new idiom for counting to n, it's unclear where the count function would be defined. Some package that essentially every program imports?
Counting from 0 to n is so incredibly common that it could merit a predefined function, but at that point we’re talking about a language change. And if we’re talking about a language change, it makes sense to continue to extend range in a type-based way, namely by ranging over ints.
Adding support for ints to range would allow writing this code:
instead of:
For former C, C++, and Java programmers, the idea of not writing the 3-clause for loop may seem foreign. It did to me at first too. But if we adopt this change, the range form would quickly become idiomatic, and the 3-clause loop would seem as archaic as ending statements with semicolons.
Discussion
What do people think about this idea?
Should we stop at push functions and not allow pull functions in range?
Should we add range over int too?
Beta Was this translation helpful? Give feedback.
All reactions