-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Beats: Make return value of mutation methods optional #4409
Conversation
c0aeff1
to
08ff362
Compare
scale()
failsnullptr
if scale()
fails
@daschuer I also stumbled over this. Returning a nullptr instead of the current, unmodified instance is dangerous IMHO. But I don't know why it has been designed this way. Every mutating operation should return a valid instance, even if it failed and leaves the instance unchanged. Otherwise the calling code becomes too complicated. |
Hmm, the alternative to returning a |
I prefere also to always return a valid pointer. |
I just implemented that and tbh it makes the code even more complicated. We only use the scale method in two locations (WTrackMenu and the track dialog) and in both we do want to know if scaling worked. So I think just returning nullptr would be simpler. But I don't really care, we can also close this. |
src/library/dlgtrackinfo.cpp
Outdated
const auto pBeatsScaled = m_pBeatsClone->scale(bpmScale, &ok); | ||
if (!ok) { | ||
return; | ||
} | ||
m_pBeatsClone = pBeatsScaled; | ||
updateSpinBpmFromBeats(); |
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 something like this in mind:
const auto pBeatsScaled = m_pBeatsClone->scale(bpmScale, &ok); | |
if (!ok) { | |
return; | |
} | |
m_pBeatsClone = pBeatsScaled; | |
updateSpinBpmFromBeats(); | |
m_pBeatsClone = m_pBeatsClone->scale(bpmScale, &ok); | |
if (ok) { | |
updateSpinBpmFromBeats(); | |
} |
I don't have a strong opinion here. The current solution looks good. |
Returning a nullptr as an indicator for an error is dangerous, not intuitive, and conceptually wrong. Unfortunately, we are hitting some C++ limitations here with this design. As the caller I want to reassign the pointer with every call independent of the outcome. On errors the last state (= pointer) should be preserved and information about the error should be returned separately. Otherwise I need to juggle pointers on every fallible operation. |
The only alternative for out parameters would be exceptions. But exceptions strike loopholes into the type system. Don't use them for anything but fatal errors. Instead of out parameters you could return a pair. Not sure if structured binding in C++17 is sophisticated enough to allow writing readable code. |
I consider using std::pair as an anti pattern in terms of readability. |
This is your personal opinion. Tell that the Go folks or consider any other modern programming language that supports anonymous tuples out-of-the box, including C++17. It works considerably well in strongly typed languages, even if I don't like how error handling is done in Go. We should not rule out this option unconditionally for the whole project. |
I think the semantically correct way would be using If you dont care if scaling worked or not, just use |
I didn't consider this option, seems to be convenient to use for preserving the existing state in case of a failure. Go for it. |
b70919a
to
bfb9cc7
Compare
nullptr
if scale()
fails
In my opinion this is now the most complex, solution and not the best fit. std:::optional is useful if the returned value has no null value. A pointer already has a null value. The nice thing of the original and the pOK solution was that you can use unconditional |
src/track/beatgrid.cpp
Outdated
@@ -261,9 +261,9 @@ mixxx::Bpm BeatGrid::getBpmAroundPosition(audio::FramePos position, int n) const | |||
return getBpm(); | |||
} | |||
|
|||
BeatsPointer BeatGrid::translate(audio::FrameDiff_t offset) const { | |||
std::optional<BeatsPointer> BeatGrid::translate(audio::FrameDiff_t offset) const { |
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.
Renaming these methods to tryTranslate()
and adding an infallible translate()
method might resolve @daschuer's concerns. The other option would be to add a translateInfallible()
single-line function, but I like the trySomething()
naming more for emphasizing fallibility.
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.
std::option
is required to prevent unconsciously using the return value which might be a nullptr. This is how you catch bugs at compile time instead at runtime!
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 considered that, too. But it's kind of pointless to add methods that are never used IMHO.
I can do it @daschuer insists, even though it's dead code.
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.
Please don't add methods until/unless they are needed.
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.
Renaming the fallible methods to trySomething() might still help to easily distinguish them at the call site without needing to look up their definition.
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 the infallible variants are not needed/used then don't add them.
I don't insist anything in this discussion. I have been jumped in because I was mentioned here. All these solutions are working for me. I have only expressed my personal opinion, else I would have issued a formal review. But I actually like the proposed name, if we not always return a valid pointer. Keeping unused functions with the old name does not make sense. |
Yeah sorry, i wasn't meaning to say you were stubborn or something, I just wanted to say that if you'd prefer to add the unused infallible methods I could do that. Just trying to find a solution here that everyone can live with :D
I agree that using First and foremost, it's semantically correct (the return value is literally optional). In contrast to the In contrast to the pointer argument solution, both the method implementation and the calling code are much shorter and nicer to read IMHO.
If you're not interested in the failure case, the code only becomes slightly longer:
|
OK, if you now rename the function with the try prefix we have a good solution. |
This allows the caller to handle the failure case more efficiently, e.g. by exiting early if nothing changes. If the caller does not care wether the operation worked and resulted in changes or not and simply needs a valid beats pointer, the following expression can be used: pBeats->tryScale(...).value_or(pBeats)
bfb9cc7
to
8534357
Compare
By the way: We can get rid of all these boilerplate code around pointers if we turn the Beatpointer into a copy on write Beats class that takes the a const pointer internally like QByteArray. |
@@ -610,7 +612,7 @@ void DlgTrackInfo::slotSpinBpmValueChanged(double value) { | |||
if (oldValue == bpm) { | |||
return; | |||
} | |||
m_pBeatsClone = m_pBeatsClone->setBpm(bpm); | |||
m_pBeatsClone = m_pBeatsClone->trySetBpm(bpm).value_or(m_pBeatsClone); |
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.
Here is the only occurrence where the optional is not used. Unsure if we want to add an infallible setBpm
just for that, especially because it would be inconsistent with the other beats mutation methods. If we add it, maybe we should add it for the other ones for consistency. Or we leave it like this. I don't really care, let me know what you prefer.
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 don't mind as well.
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.
LGTM, thank you!
@@ -610,7 +612,7 @@ void DlgTrackInfo::slotSpinBpmValueChanged(double value) { | |||
if (oldValue == bpm) { | |||
return; | |||
} | |||
m_pBeatsClone = m_pBeatsClone->setBpm(bpm); | |||
m_pBeatsClone = m_pBeatsClone->trySetBpm(bpm).value_or(m_pBeatsClone); |
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 don't mind as well.
This allows the caller to handle the failure case more efficiently, e.g. by exiting early if nothing changes.
If the caller does not care wether the operation worked and resulted in changes or not and simply needs a valid beats pointer, the following expression can be used:
Original PR description
Unsure if this is desired as it increases the risk for crashes (i.e. if the caller forgets to check fornullptr
) and makes the code slightly less readable.On the other hand it allows skipping code execution if nothing changed. This will become more relevant with #4255 (which only allows scaling if the scaled number of beats between markers is an integer).