Skip to content
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

Merged
merged 1 commit into from
Oct 15, 2021

Conversation

Holzhaus
Copy link
Member

@Holzhaus Holzhaus commented Oct 13, 2021

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->scale(...).value_or(pBeats)

Original PR description

Unsure if this is desired as it increases the risk for crashes (i.e. if the caller forgets to check for nullptr) 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).

@Holzhaus Holzhaus added this to the 2.4.0 milestone Oct 13, 2021
@Holzhaus Holzhaus changed the title Beats: Avoid unnecessary copies if scale() fails Beats: Return nullptr if scale() fails Oct 13, 2021
@uklotzde
Copy link
Contributor

@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.

@Holzhaus
Copy link
Member Author

Holzhaus commented Oct 13, 2021

@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 nullptr would be to add an optional bool* ok parameter that is set to false in the failure case.

@daschuer
Copy link
Member

I prefere also to always return a valid pointer.
The bool* ok sounds reasonable. If we default it to nullptr we can pass it only if we have a something to do in the failure case.

@Holzhaus
Copy link
Member Author

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.

Comment on lines 548 to 553
const auto pBeatsScaled = m_pBeatsClone->scale(bpmScale, &ok);
if (!ok) {
return;
}
m_pBeatsClone = pBeatsScaled;
updateSpinBpmFromBeats();
Copy link
Member

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:

Suggested change
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();
}

@daschuer
Copy link
Member

I just implemented that and tbh it makes the code even more complicated.

I don't have a strong opinion here. The current solution looks good.
@uklotzde What do you think?

@uklotzde
Copy link
Contributor

uklotzde commented Oct 13, 2021

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.

@uklotzde
Copy link
Contributor

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.

@daschuer
Copy link
Member

daschuer commented Oct 14, 2021

I consider using std::pair as an anti pattern in terms of readability.

@uklotzde
Copy link
Contributor

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.

@Holzhaus
Copy link
Member Author

Holzhaus commented Oct 14, 2021

I think the semantically correct way would be using std::optional.

If you dont care if scaling worked or not, just use pBeats->scaled(...).value_or(pBeats).

@uklotzde
Copy link
Contributor

I think the semantically correct way would be using std::optional.

If you dont care if scaling worked or not, just use pBeats->scaled(...).value_or(pBeats).

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.

@Holzhaus Holzhaus changed the title Beats: Return nullptr if scale() fails Beats: Make return value of mutation methods optional Oct 14, 2021
@daschuer
Copy link
Member

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
m_pBeatsClone = m_pBeatsClone->scale(bpmScale)
This simplicity and safety is now lost.

@@ -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 {
Copy link
Contributor

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.

Copy link
Contributor

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!

Copy link
Member Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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.

Copy link
Contributor

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.

@daschuer
Copy link
Member

I can do it @daschuer insists, even though it's dead code.

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.

@Holzhaus
Copy link
Member Author

Holzhaus commented Oct 15, 2021

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.

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

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.

I agree that using std::optional to wrap a pointer seems weird at first, but I think this approach has several advantages the alternatives (using nullptr, using an pointer argument for output).

First and foremost, it's semantically correct (the return value is literally optional).

In contrast to the nullptr solution, it's not possible to accidently segfault mixxx by forgetting to check for a nullptr return value, because the type is different. It will cause a compiler warning.

In contrast to the pointer argument solution, both the method implementation and the calling code are much shorter and nicer to read IMHO.

The nice thing of the original and the pOK solution was that you can use unconditional

 m_pBeatsClone = m_pBeatsClone->scale(bpmScale)

This simplicity and safety is now lost.

If you're not interested in the failure case, the code only becomes slightly longer:

 m_pBeatsClone = m_pBeatsClone->tryScale(bpmScale).value_or(m_pBeatsClone)

@daschuer
Copy link
Member

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)
@daschuer
Copy link
Member

daschuer commented Oct 15, 2021

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);
Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member

@daschuer daschuer left a 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);
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants