-
-
Notifications
You must be signed in to change notification settings - Fork 402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
New practice exercise react
#820
Conversation
New practice exercise `react` New practice exercise `react` New practice exercise `react` New practice exercise `react` Change return type
Thank you for contributing to Based on the files changed in this PR, it would be good to pay attention to the following details when reviewing the PR:
Automated comment created by PR Commenter 🤖. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a really fascinating exercise 🤔 I'm really curious how people will approach it, and I'm very happy we have more genserver!
config.json
Outdated
"recursion" | ||
], | ||
"practices": [ | ||
"processes" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I made a note in #559 that this will be a genserver exercise once we have that concept 🙂
defstruct [:name, :inputs, :compute, :value, callbacks: []] | ||
|
||
@type t :: %OutputCell{ | ||
name: String.t(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think cell names should be atoms instead?
Ooops, didn't see the PR description before posting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I chose to use cell names as strings instead of atoms, because I know that atoms are not garbage collected and this felt like the kind of system where you would programmatically create new cells, even though it doesn't happen here. But atoms are cleaner, so I'm on the fence.
I agree with the concern, let's keep strings. Maybe you could describe this in .meta/design.md
? for future maintainer generations :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good suggestion, I will do that. Those notes are only meant for maintainers right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes
test "callbacks should not be called if dependencies change but output value doesn't change" do | ||
# Some incorrect implementations simply mark a compute cell as dirty when a dependency changes, | ||
# then call callbacks on all dirty cells. | ||
# This is incorrect since the specification indicates only to call callbacks on change. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think those comments are very helpful for understanding the exercise
name: String.t(), | ||
inputs: [String.t()], | ||
compute: (... -> any), | ||
value: any, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we can allow cells to have any type, should we add a custom test case that tests string cells for example? or boolean cells? (but no error handling of type mismatch)
Problem specifications say:
If your language supports generics, you may consider allowing reactors to act on other types.
Tests for that are not included here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, sure, why not!
Any particular procedure for adding tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since those are our own, we don't need to add anything to the toml file. Maybe mention that we did that in design.md
but otherwise nothing special.
end | ||
|
||
@doc """ | ||
Set the value of an input cell |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this needs a hint about what the function returns?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, the callbacks. Yes, good idea.
Also I have a question about this. It is specified that you can only have specific callback once, so I picked a map as the data structure since the uniqueness of the keys would always enforce that. But that seems like a decision that has to be made by the student. Of course, it's also important for the tests so... I'm not sure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After some thought, I find it a bit weird that there are no actual functions involved in the callbacks 🤔.
I'm thinking the API should rather be like this:
React.add_callback(cells, "always_two", fn value -> send(self(), {:callback1, value}))
:ok = React.set_value(cells, "input", 2)
refute_received {:callback, _}, "Expected callback not to be called" # maybe the failure message can be improved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah nice!
I actually don't have a good intuition of what callbacks are, so this is very helpful. This feels closer to what it should be!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a go at something that I found sensible, please check :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks much better 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is supposed to be a difficult problem, and I'm okay with it being difficult. I think though, we are perhaps prescribing a very specific approach by defining the structs and using them in the test file.
For me, I'd like to see the interface be a little more agnostic to the solution's data model. A more generic input and allow the student to flex some creativity in their own solution. (To be clear, there is certainly nothing wrong with the approach @jiegillet has taken with their solution to use structs for this, just not sure it should be mandated for every student)
E.g.
# input format
{:input, "input", 1}
# output format
{:output, "output", ["input"], fn a -> a + 1 end}
# Then the interface for the test file becomes:
test "compute cells calculate initial value" do
{:ok, cells} =
React.new([
{:input, "input", 1},
{:output, "output", ["input"], fn a -> a + 1 end}
])
assert React.get_value(cells, "output") == 2
end
The format could be implemented as a provided type.
Interested in your thoughts
That makes sense. I use Haskell and Elm a lot, so I'm very type-driven and that's how I structure code, but I should let students make their own design choices. The change you propose is pretty straightforward too, so no problem. |
def add_callback(cells, cell, callback, send) do | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could improve argument names in the whole stub file E.g. I'm not entirely sure about the name "send". It's specific to message sending, but the callback can do anything. In practice it only makes sense if it does side-effect (because there's nobody that cares about the return value) but the side effects could be not only message sending, but also doing network requests or something 🤷
I would propose:
cell
-> cell_name
(because this is the string, not the map %{type: :input, value: 1}
and definitely not a single instance of the first argument, cells
)
callback
-> callback_name
(because "callback" usually means it's a function)
send
-> callback
or function
or callback_fun
?
cells :: pid, | ||
cell :: String.t(), | ||
callback :: String.t(), | ||
send :: (String.t(), any -> :ok) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does it really matter what the callback returns?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well the callback must send a message, which is of type :ok
, otherwise the tests can't be passed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Must send a message? The callback we use in tests sends a message so that we have an easy way of verifying that it was called, but in general the callback can do anything. It's even in your implementation:
Enum.each(cell.callbacks, fn {name, send} -> send.(name, value) end)
Enum.each
-> return value gets discarded, only side effects matter
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I get it.
I think what I was doing here is matching the type of the actually callbacks , which are all predefined in the tests as send_callback
and are indeed all send
, but I get what you are saying, in a real API, you wouldn't restrict the callback type. I will change it :)
end | ||
|
||
@doc """ | ||
Set the value of an input cell |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks much better 👍
@doc """ | ||
Start a reactive system | ||
""" | ||
@spec new(cells :: [%{atom => any}]) :: {:ok, pid} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The map %{atom => any}
will be a very specific map, right? One of:
%{type: :input, name: String.t(), value: any()}
%{type: :output, name: String.t(), inputs: [String.t()], compute: (...) -> any()}
Should we write that in the typespec instead of the generic %{atom => any}
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a good iteration, but I still think a tuple is preferable to a map/struct for the arguments
%{type: :input, name: "input", value: 1}, | ||
%{type: :output, name: "output", inputs: ["input"], compute: fn a -> a + 1 end} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand why you are using a map in your data model, to enforce a single entry per key, but I think when sending messages it is much more common to use a tuple for this.
%{type: :input, name: "input", value: 1}, | |
%{type: :output, name: "output", inputs: ["input"], compute: fn a -> a + 1 end} | |
{:input, "input", 1}, | |
{:output, "output", ["input"], fn a -> a + 1 end} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, this is not why I don't use a tuple, here I use a map because I want to have named fields, otherwise it's unnecessarily complicated for the student to figure out which value is what... This is just following the data from canonical-data.json
.
If there was only two fields, it would be ok, but if I have sometimes 2, sometimes 3, I would always go for clarity.
If you insist I will change to tuples, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, maybe with the @spec
specifying which is what it would be OK.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would rather we keep the maps precisely to have named fields. Tuples will be more confusing IMO, because there will be two different ones of different lengths...
but I think when sending messages
This is not sending messages 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean "send a message" in a more philospohical sense (archived article since my link is dead (beware, they use the dreaded OOP word, but I think it as a philosophy still stands)). Calling a function is "sending a message". Eg. when invoking the function new
I am sending a message to create a system with these constraints.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tuples for this sort of thing is still super common (especially in erlang), and we see tuples of varying lengths for various meanings all the time: {:ok, value1, value2}
vs {:error, "message"}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But that's why I suggested having a defined type for the tuple so that you can give meaning to the tuple
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about we compromise:
%{type: :input, name: "input", value: 1},
{:output, "output", ["input"], fn a -> a + 1 end}
(🤣 I'm very sorry 😆)
I will reflect on it for a bit. Thank you both for the detailed review.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I see. Ok, I won't insist on maps, if @neenjaw is sure tuples will be better in this context, they will work too if we write a nice spec.
I think it's good to merge from my side 🎉 let's see what @neenjaw says |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great! 🎉
@doc """ | ||
Start a reactive system | ||
""" | ||
@spec new(cells :: [{:input, String.t(), any} | {:output, String.t(), [String.t()], fun()}]) :: | ||
{:ok, pid} | ||
def new(cells) do | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could create a type here to break down this complex type
@doc """ | |
Start a reactive system | |
""" | |
@spec new(cells :: [{:input, String.t(), any} | {:output, String.t(), [String.t()], fun()}]) :: | |
{:ok, pid} | |
def new(cells) do | |
end | |
@type input :: {:input, name :: String.t(), value :: any} | |
@type output :: | |
{:output, name :: String.t(), dependencies :: list(String.t()), callback :: function()} | |
@type cell_definition :: input() | output() | |
@opaque cells :: pid | |
@doc """ | |
Start a reactive system | |
""" | |
@spec new(cell_definitions :: [cell_definition()]) :: {:ok, cells()} | |
def new(cell_definitions) do | |
end |
This is one a bit harder than the last two. Even combined :)
I would welcome your opinion especially concerning the API, choice of functions, return types and such. For example, I'm sending back the callbacks in a map, but I'm not sure that is idiomatic.
I chose to use cell names as strings instead of atoms, because I know that atoms are not garbage collected and this felt like the kind of system where you would programmatically create new cells, even though it doesn't happen here. But atoms are cleaner, so I'm on the fence.
I left some of the test comments in, because they gave me some insight into the problem, but I can remove them if needed.
Difficulty around 8 or 9 sounds right, but I think I'm mixing the difficulty of designing the tests.