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

Use std::optional rather than sentinel values #2964

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

ChrisThrasher
Copy link
Member

@ChrisThrasher ChrisThrasher commented Apr 29, 2024

Description

sf::InputStream and derived classes have four functions whose return value operates in the same way, open, read, seek, and tell. Upon success, they return a non-negative number which means different things for different functions. Upon failure, they return -1. -1 is a sentinel value. This error value is incompatible with other successful return values. It's not valid to, for example, add -1 to a non-negative return value from getSize(). However, the type system allows this. Because both return values are of type std::int64_t it's very easy to ignore a potential -1 return value and instead write code that behaves unexpected in the face of errors.

This PR replaces those std::int64_t return values with std::optional<std::size_t>. Using std::optional forces all callers of these functions to reckon with a potential error. This has major type safety implications since it's no longer possible to silently ignore the possibility of -1 being returned.

As it turns out, there are places where these functions were called without taking into consideration the possibility of failure. For example the following snippet assumes that file.getSize() succeeds.

std::vector<std::uint32_t> buffer(static_cast<std::size_t>(file.getSize()) / sizeof(std::uint32_t));

What happens if it does not succeed? In the case that -1 is returned then that value is underflowed to the maximum value of std::size_t. When divided by sizeof(std::uint32_t) you end up with a value that is certainly too large to be successfully allocated. Failure to check this return value morphs into an allocation failure which is a confusing user experience.

Here's another example. The return values of tell and getSize are used without handling the potential error case. It is not valid to increment offset by -1. -1 does not have any arithmetic meaning. It is a placeholder value. Subtracting the offset from -1 leads to a number that is even more negative. Yet another nonsense value yet the existing API did nothing to force us to handle this error, highlighting its lack of safety.

offset += stream->tell();
break;
case SEEK_END:
offset = stream->getSize() - offset;

In this case I chose to use .value() to ensure that the optional has a value or else let std::bad_optional_access be thrown. We could instead simply use operator* but then we open ourselves up to undefined behavior in the event that the optional is null which seemed like a worse user experience that an uncaught exception causing a program exit.

In some places we're working with C APIs that still expect -1 to signal error and those circumstances are still handled, albeit handled in a more explicit way that makes it clear that the C APIs handle errors differently than SFML itself.

In performing this refactor I reduced the number of static_casts by a count of 19. Removing the need for casts is a good sign that std::optional<std::size_t> is a more natural fit than std::int64_t.

@ChrisThrasher ChrisThrasher force-pushed the optional_input_stream branch 2 times, most recently from d949d15 to f3cab9b Compare April 30, 2024 00:04
@coveralls
Copy link
Collaborator

coveralls commented Apr 30, 2024

Pull Request Test Coverage Report for Build 9150171440

Details

  • 77 of 80 (96.25%) changed or added relevant lines in 12 files are covered.
  • 2 unchanged lines in 2 files lost coverage.
  • Overall coverage increased (+0.05%) to 55.611%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/SFML/Audio/SoundFileReaderFlac.cpp 9 10 90.0%
src/SFML/System/FileInputStream.cpp 14 16 87.5%
Files with Coverage Reduction New Missed Lines %
src/SFML/Audio/SoundFileReaderWav.cpp 1 75.0%
src/SFML/Graphics/Font.cpp 1 76.52%
Totals Coverage Status
Change from base Build 9150167137: 0.05%
Covered Lines: 11476
Relevant Lines: 19543

💛 - Coveralls

src/SFML/Graphics/Shader.cpp Outdated Show resolved Hide resolved
src/SFML/System/MemoryInputStream.cpp Outdated Show resolved Hide resolved
@ChrisThrasher
Copy link
Member Author

Rebased on master. No changes made.

@ChrisThrasher
Copy link
Member Author

I added more sf::MemoryInputStream tests to better cover edge case behavior.

Copy link
Member

@vittorioromeo vittorioromeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a good idea to me!

examples/vulkan/Vulkan.cpp Outdated Show resolved Hide resolved
examples/vulkan/Vulkan.cpp Outdated Show resolved Hide resolved
include/SFML/System/FileInputStream.hpp Outdated Show resolved Hide resolved
@ChrisThrasher
Copy link
Member Author

Found some Android APIs that return -1 on failure and thus need some extra code to check for that case to return std::nullopt.

@ChrisThrasher ChrisThrasher force-pushed the optional_input_stream branch 4 times, most recently from c79efcc to f7e35d8 Compare May 4, 2024 17:37
Copy link
Member

@vittorioromeo vittorioromeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good, some suggestions

@@ -44,15 +44,17 @@ FLAC__StreamDecoderReadStatus streamRead(const FLAC__StreamDecoder*, FLAC__byte
{
auto* data = static_cast<sf::priv::SoundFileReaderFlac::ClientData*>(clientData);

const std::int64_t count = data->stream->read(buffer, static_cast<std::int64_t>(*bytes));
if (count > 0)
if (const auto count = data->stream->read(buffer, *bytes))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (const auto count = data->stream->read(buffer, *bytes))
if (const auto count = data->stream->read(buffer, *bytes); count.has_value())

Nitpick, but this suggestion makes it obvious that count is an optional in this context

@@ -75,10 +76,9 @@ FLAC__StreamDecoderTellStatus streamTell(const FLAC__StreamDecoder*, FLAC__uint6
{
auto* data = static_cast<sf::priv::SoundFileReaderFlac::ClientData*>(clientData);

const std::int64_t position = data->stream->tell();
if (position >= 0)
if (const auto position = data->stream->tell())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Suggested change
if (const auto position = data->stream->tell())
if (const auto position = data->stream->tell(); position.has_value())

@@ -91,10 +91,9 @@ FLAC__StreamDecoderLengthStatus streamLength(const FLAC__StreamDecoder*, FLAC__u
{
auto* data = static_cast<sf::priv::SoundFileReaderFlac::ClientData*>(clientData);

const std::int64_t count = data->stream->getSize();
if (count >= 0)
if (const auto count = data->stream->getSize())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (const auto count = data->stream->getSize())
if (const auto count = data->stream->getSize(); count.has_value())

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChrisThrasher: The reason why I suggested this change (and similar ones) is that if I am just reading this line of code in isolation I will assume that getSize() returns a number and that count is just a number. Nothing in the name of the variable nor the name of the function suggests optionality.

I would rather read

if (const auto count = data->stream->getSize(); count.has_value())

or

if (const std::optional count = data->stream->getSize())

or

if (const std::optional<std::size_t> count = data->stream->getSize())

src/SFML/Audio/SoundFileReaderMp3.cpp Show resolved Hide resolved
return position < 0 ? -1 : 0;
auto* stream = static_cast<sf::InputStream*>(data);
const auto position = stream->seek(static_cast<std::size_t>(offset));
return position ? 0 : -1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return position ? 0 : -1;
return position.has_value() ? 0 : -1;

Comment on lines +61 to +64
const auto newPosition = AAsset_seek(m_file.get(), static_cast<off_t>(position), SEEK_SET);
if (newPosition < 0)
return std::nullopt;
return newPosition;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto, like above

src/SFML/System/Android/ResourceStream.cpp Show resolved Hide resolved
src/SFML/System/MemoryInputStream.cpp Show resolved Hide resolved
src/SFML/System/MemoryInputStream.cpp Show resolved Hide resolved
Comment on lines 74 to 91
////////////////////////////////////////////////////////////
std::int64_t MemoryInputStream::tell()
std::optional<std::size_t> MemoryInputStream::tell()
{
if (!m_data)
return -1;
return std::nullopt;

return m_offset;
}


////////////////////////////////////////////////////////////
std::int64_t MemoryInputStream::getSize()
std::optional<std::size_t> MemoryInputStream::getSize()
{
if (!m_data)
return -1;
return std::nullopt;

return m_size;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use ternaries here

Copy link
Member Author

@ChrisThrasher ChrisThrasher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With respect to the use of has_value() I've gone back and forth while writing this PR. Sometimes I like explicitly saying this is an optional but other times I prefer the elegance of letting the type system quietly handle things even if the reader doesn't necessarily know what's going on. I'm currently erring on the side of preferring we write less code since doing so doesn't reduce type safety whatsoever.

With respect to the use of ternaries, I don't have a strong opinion. In the past we haven't made a point to maximize the use of ternaries. If the code was already written to use ternaries I wouldn't replace them with if statements. I don't really see either as particularly superior to the other so I just kept using whatever control flow already existed prior to this PR to reduce how much code I changed.

In both cases if the rest of the team agrees in one direction or the other I'll go along with what they prefer.

src/SFML/System/Android/ResourceStream.cpp Show resolved Hide resolved
src/SFML/System/MemoryInputStream.cpp Show resolved Hide resolved
@ChrisThrasher
Copy link
Member Author

Rebased on master and fixed conflicts

@ChrisThrasher ChrisThrasher force-pushed the optional_input_stream branch 2 times, most recently from ad82b0b to 51b0492 Compare May 18, 2024 04:00
@ChrisThrasher
Copy link
Member Author

ChrisThrasher commented May 18, 2024

Rebased on master and fixed conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
Status: Discussion
Development

Successfully merging this pull request may close these issues.

None yet

4 participants