Reimplement legacy constructor/load/open methods and add throwing constructors#3152
Reimplement legacy constructor/load/open methods and add throwing constructors#3152ChrisThrasher merged 2 commits intomasterfrom
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
|
Great work, Binary! You're helping fix hundreds of codebases that intend on updating to SFML 3. |
|
This PR constitutes a major reversal of what we decided in #2139 so I want to recap some points of discussion from that issue thread to provide a basis for what I'll be talking about later. Pardon the wall of text. I'd like to be terse but there is a lot to say about this. The main reason for opening #2139 was to lift the requirement of using two-phase initialization to load resources. SFML should not force users to write multiple expressions to load a resource. The options laid out in the description of #2139 provide a few alternatives that let you load a resource in one line of code. This is a good and uncontroversial motivation. Everyone in #2139 and elsewhere agrees that it's good to provide the option to load resources in one line of code. How we go about doing that is where the disagreements begin. Implied in much of the discussion in #2139 was that default constructors would be removed. This implication was not thoroughly questioned. I think there is a lot of value in removing the default constructors for these types. These resource types ( Also implied in #2139 was that the non-static member functions like As for the conclusion we reached, one thing I want to mention is how I was largely motivated by the exception-free history of SFML. I was not keen to be the guy to start filling SFML with exceptions since users have spent decades using SFML under the assumption that our interfaces won't throw. This would represent a fairly large subversion of expectation which could make SFML 3 harder to adopt. I'm actually sympathetic to the use of exceptions. What I take issue with is an API that requires users to write What I like about the current However, as Binary has recently pointed out, the removal of these non-static loading functions has performance implications due to them enabling "efficient object recycling after construction". You cannot recycle resources as efficiently if loading a resource requires full construction of that object from scratch. For example, internal vectors of data must be allocated and moved rather than simply repopulating an existing allocation. Moving into a So what about the old default constructors? I still don't like them, but I understand they offer a convenience to users who aren't yet ready to start using
Now I can talk about the current state of the PR. You have kept the existing The key reason you can implement this PR without duplication is that you reintroduced the empty default state. For what it's worth that empty state doesn't have to be accessible to users for this code reuse to be possible. It merely needs to exist internally to the class. In theory we could have both throwing constructors and factory functions that call a private default constructor and private non-static loading functions. However, if we already do all the work to make these types privately default constructible and privately contain non-static loading functions then it seems reasonable to make those things public. Let's recap
The extrapolation goes farther though. These two snippets are functionality identical and it doesn't make sense to have both interfaces exist alongside each other. It's too repetitious to have two interfaces that are so similar. sf::Font font;
if (!font.openFromFile(...))
// Handle errorauto font = sf::Font::openFromFile(...);
if (!font)
// Handle errorIf we are to move forward with this PR then we can't merely add to the existing API. Doing so would result in something lacking cohesion. To consider this PR is to entirely revisit and potentially revert all the decisions made in #2139. All discussion of this PR must center around whether not we got it right in #2139. |
|
I think the "missing link" between #2139 and the implementations in the respective PRs was a thorough discussion about what constitutes an "error". I am always for preventing errors using any technical means we have available to us. But to do this I have to be absolutely sure what I am trying to prevent is always going to be a provable error.
As you said, it is likely the program has a bug, but that is not a high enough bar to start enforcing something 100% of the time. If there is at least 1 scenario where this kind of usage is part of a legitimate design choice then we will not be able to enforce anything. This is also the reason why it is a very hard task to implement static analysis tools to detect potential errors. As long as they keep spitting out false positives, nobody is going to use them. Saying "it looks like your code might have a bug" isn't enough, they have to be able to prove it. Also, as long as the halting problem is still undecidable, there will never be any technical means of determining at the site of construction whether the object will actually be loaded later on in the program. I see it as the responsibility of the programmer to determine whether control flow can actually reach the point of loading and not the API. There are times when it is uncontroversial whether a function should return an Carrying over the In my opinion, the validity of an object shouldn't be defined by the usefulness of operations that can be performed on it. As long as the operations that can be performed on a given object are always defined (i.e. don't result in undefined behaviour), they will be valid from a program-is-well-defined perspective, and that is the only technically enforceable criteria we can use to decide when an object is valid or not. This would mean that "empty" objects whose operations would result in well-defined lack of side effects are also valid objects. I have no issue with us adding additional methods that allow the user to query whether object usage would result in visible side effects or not, I just have an issue with us introducing the notion of "validity". As long as these querying methods exclude any notion of "validity" and instead refer to e.g. something being "empty" we can name them whatever makes the most sense. For a concrete example, default constructing an Choosing to delay loading of resources shouldn't be categorized as a likely error. There are many legitimate scenarios where this might make sense. In order to load a resource you will have to have enough information at the site of loading to make the call to the respective loading function. If this information is only available at a later point of program execution then it should still be possible to set up your object hierarchies up front and only load in the necessary data later on. struct CatSprites : sf::Drawable
{
CatSprites() : sprite1(texture), sprite2(texture), sprite3(texture)
{
// Set up everything we can set up at this point
// positions... colors... etc.
// However we still don't know which cat picture the user likes
}
void setCatPicture(const std::filesystem::path& path)
{
if (!texture.loadFromFile(path))
// handle loading failure
}
void draw(RenderTarget& target, RenderStates states) const override
{
// This is where something like .empty() would come in
if (texture.getSize().x * texture.getSize().y == 0)
return;
target.draw(sprite1, states);
target.draw(sprite2, states);
target.draw(sprite3, states);
}
sf::Texture texture;
sf::Sprite sprite1;
sf::Sprite sprite2;
sf::Sprite sprite3;
};
// ... somewhere in event handling ...
switch (keyPressed->code)
{
case sf::Keyboard::Key::A:
catSprites.setCatPicture("CatA.jpg");
break;
case sf::Keyboard::Key::B:
catSprites.setCatPicture("CatB.jpg");
break;
default:
break;
}Prohibiting the user from creating an "empty" texture up front to be able to perform all the necessary set up for their drawable would force them to either store Another nice side-effect of being able to set up RAII hierarchies even before all information is available is address stability. It is a known fact that certain objects rely on internal references to other objects and these other objects must outlive the objects that hold references to them. The easiest way to guarantee this is by dumping them into the same composite object in the proper order and initialize their relationship in the composite object constructor as shown in the example above. As long as the address of the composite object stays stable as well (either dynamically allocate it or make sure it is never moved) it is guaranteed that there will never be any lifetime issues between the texture and the sprites. This is in fact a pattern I often use in my own code when I know that lifetime issues can arise from lack of address stability. It is also the reason I don't have to worry much about this kind of a problem. Not being able to set something like this up would end up increasing my mental load because I would have to additionally track the lifetimes of my objects. |
|
@binary1248: The problem with your code is that With my SFML fork that implements #3072, it's a non-issue. struct CatSprites
{
CatSprites()
{
// ...whatever you want...
}
void setCatPicture(const std::filesystem::path& path)
{
if (!(texture = sf::Texture::loadFromFile(path)))
// handle loading failure
sprite1.setTextureRect(texture->getRect());
sprite2.setTextureRect(texture->getRect());
sprite3.setTextureRect(texture->getRect());
}
void draw(RenderTarget& target, RenderStates states) const
{
// This is where something like .empty() would come in
if (!texture.has_value())
return;
target.draw(sprite1, *texture, states);
target.draw(sprite2, *texture, states);
target.draw(sprite3, *texture, states);
}
std::optional<sf::Texture> texture;
sf::Sprite sprite1;
sf::Sprite sprite2;
sf::Sprite sprite3;
};Everything considered, I largely agree with @ChrisThrasher, and I do think this PR is a huge step backwards for SFML 3.x. |
To be clear my previous comment should not be summarized as a disapproval of this PR. I'm keeping an open mind. If I am to approve this PR, I have a few requests that I have outlined. I haven't yet made up my mind. |
To be clear myself, I did not mean to imply you disapprove of the PR. I wanted to say that I largely agree with you and that I personally strongly disapprove of this PR. |
This is just a matter of opinion and not based on any currently available definition of when The fact that the old API supported this usage for well over a decade and this wasn't even a significant issue for many users suggests that not many share your opinion that this usage is definitely incorrect and must be prohibited at any cost. I can understand that you are an advocate for data driven design and that is fine. Everybody is entitled to their own opinions and preferences. But when one opinion declares another opinion as "invalid" or "incorrect" that is a bit too much for me. |
Perhaps the word "incorrect" is too strong, but it's definitely "unnecessary" for the sprite to hold the texture for any longer than its BTW, I am only bringing this up again because such unnecessary relationship does cause friction for users as seen in your code snippet example. |
|
You are basing your definition of usage on the current private implementation of If we were to consider a hypothetical game console, the "PlayBox64", and we wanted to support its proprietary rendering API, and this API required us to create "textures" as 2D images that are stored in the console memory and for some reason this API also only allowed us to draw textures to the screen through a "sprite" object that we also have to create and assign the texture to, then the relation between drawing to the screen and the texture completely disappears. This hypothetical example demonstrates that your idea of delaying linking of the texture and sprite to the draw call relies too much on the way our current graphics API of choice (OpenGL) operates. If we wanted to expose the entire OpenGL mode of operation to the user through our own API then there wouldn't be much value in it except maybe as a thin C++ wrapper. Because SFML aims to be a cross-platform library that attempts to provide an API agnostic of the platform implementation we can and have to define our own concepts independent of the concrete way we will end up having to implementing them. I remember there was a more in depth discussion about what a "sprite" even is somewhere else, so those who are interested can refer to that for further reading. |
Is there any such rendering API and/or platform that (1) exists, (2) is widely used, and (3) SFML is planning to support in the future? |
|
Just wanted to chime in as a poweruser, I don't aim to speak for all but just here's my stance: I can live without the old form of load functions, I can live with the optional loading factory functions. But this is all in aid of understanding that the end goal is I may turn out to be in the minority here, but I would prefer not to walk towards exceptions. It's easier to opt into using exceptions when a library doesn't (merely I agree on the intended path for error handling, I think the less frictional way to get there is to bring back default constructors here. |
|
There doesn't have to be such a platform. As I said it was purely hypothetical and meant to demonstrate we have to design an API detached from the operation of any concrete underlying implementation. If you absolutely insist on a real-world example of an API, you need not look any further than our own Vulkan example. I invite everyone who has the time to study the entire example, but for those without much time, the relevant functions are Anyone who has used multiple graphics APIs will know that OpenGL is probably the most unique among them (in a bad way). It has its roots in the early 90s and started out only providing immediate mode rendering. OpenGL has for lack of a better word the most anti-object-oriented API of probably all of them. In order to link different aspects together you need to bind resources to different global binding points. Because the relationship between different resources is not explicitly stored by OpenGL you have this side-effect where you will have to rebind resources a lot every frame. Modern OpenGL fixed this issue a bit but the problem was always the underlying reliance on these binding points. If you look at D3D or Vulkan you will see a completely different model. One that is based on objects with relationships to each other. Khronos learned from the mistakes of OpenGL and made sure that the main bottleneck of OpenGL, changing the state of the rendering pipeline, didn't get carried over into the design of Vulkan. This is also the reason why Vulkan supports multi-threading so much better than OpenGL. Assuming that texturing is a property of the draw call itself just because that is how OpenGL currently forces us to implement it is unwise at best and foolish at worst. As I said above, implementing our public API based on this concept is just carrying over the very mistake that Khronos managed to correct in Vulkan. If we are serious on supporting more than one rendering API in the future we have to be smarter than narrowing our perspective to what we have in front of us right now. |
6bb6c55 to
5cefec9
Compare
|
For the sake of making this PR simpler and more internally consistent, I removed the factory functions from all classes except |
Pull Request Test Coverage Report for Build 10295468899Details
💛 - Coveralls |
|
I'm still heavily against this change. There are a few misconceptions that I'd like to clear out and points that I'd like to make:
The PR as is it's pretty much a "over my dead body" veto for me, as I strongly believe that removing optional-based factory construction loses many practical benefits in terms of SFML program safety/stability/robustness/architecture in order to get a bit of extra convenience when writing code. Let's think about the principles that matter in software engineering:
Factory-based construction is in line with all the principles listed above. Removing is would be a step backwards. Now, I am sympathetic to the issue of "resource recycling". I would consider an alternative version of this PR where:
Addendum: the example shown in the OP is exactly why I think that factory-based construction promotes good software engineering practices. I will mark my own comments with (VR): struct MyStruct
{
// How do you handle load failure here without exception handling? You can't...
// (VR): That's a good indication that you shouldn't handle loading failure here,
// as loading a texture is not the responsibility of who is holding the texture.
// As SFML really wants to be an OOP library, let's not forget about SOLID... :)
MyStruct() : texture(sf::Texture::loadFromFile(...).value())
{
}
sf::Texture texture;
}
struct MyViralStruct
{
// The only way to support initializer list initialization is by storing an optional in my own types
// This would mean I would have to check whether it is valid every time I access it
// (VR): This is a workaround that doesn't address the actual problem. Friction is
// present, and rightfully so, because responsiblities are being mixed together.
MyStruct() : texture(sf::Texture::loadFromFile(...))
{
}
std::optional<sf::Texture> texture;
}
struct MyOtherViralStruct
{
// Another way of solving this problem is by calling the factory outside and moving in
// (VR): This is the correct solution. The struct shouldn't care about how the texture
// was loaded/created, the struct only needs to hold/use a texture. Responsiblities
// are now evenly divided. Factory-based construction is promoting good software
// engineering practices!
MyStruct(sf::Texture&& tex) : texture(tex)
{
}
sf::Texture texture;
}
// Now resource loading can only take place outside the struct/class and error handling as well
// (VR): This is again, a great thing! Users are forced to think about error handling cases,
// and they can now choose how to compose loading of resources and handling strategies.
// MyOtherViralStruct makeStruct() // Nope, would require exceptions
// {
// auto tex = sf::Texture::loadFromFile(...);
// return MyOtherViralStruct(std::move(tex.value()));
// }
// (VR): This is one example of how the loading can be done, but it's not the only one.
// It can be much simpler in a prototype or if the user doesn't care much about error
// handling. See below.
std::optional<MyOtherViralStruct> maybeMakeStruct()
{
auto tex = sf::Texture::loadFromFile(...);
if (tex)
return std::make_optional<MyOtherViralStruct>(std::move(*tex));
return std::nullopt;
}
// (VR): I added this to the example to show a simpler solution if the user wants to
// create a fast prototype.
int main()
{
MyOtherViralStruct s{sf::Texture::loadFromFile(...).value()};
// (VR): Note that the struct here is not wrapped in an optional!
// Also note that there's no need to use `std::move` at all.
} |
vittorioromeo
left a comment
There was a problem hiding this comment.
Heavily against removing factory-based construction and reintroducing an "empty" or "invalid" default state for SFML objects.
Would not block this PR or a separate one if a "recycling API" was added to SFML objects without impacting factory-based construction.
67987f0 to
68eee08
Compare
4659e92 to
122da08
Compare
ChrisThrasher
left a comment
There was a problem hiding this comment.
While there are some things I really appreciate about a std::optional-based interface for object creation, it seems like the right move to continue to support these default, empty states as they 1. are not particularly harmful and 2. convenient to users.
Moving forward with this design
122da08 to
6fb3260
Compare
eXpl0it3r
left a comment
There was a problem hiding this comment.
Bunch of documentation improvements, but also a 2-3 leftover things to consider.
One question to discuss in a more general sense is, what we're going to promote by default, throwing constructors or default construction + loadFrom* functions?
3f5ba40 to
d8d7567
Compare
fdccb4b to
f68b819
Compare
eXpl0it3r
left a comment
There was a problem hiding this comment.
As discussed, we'll move forward with this.
Bool operator and potential other adjustments can come with a separate PR.
…r resource objects that can be reused.
f68b819 to
55cdefc
Compare
This change does several things:
createmethods toresizesince this is technically what they are actually used for from a user perspectiveRenames the factory functions tocreateFromXYZsince this better conveys that they create new objects instead of operate on existing onesloadFromXYZ/openFromXYZmethods in order to allow efficient object recycling after constructionloadFromXYZ/openFromXYZmethodsI know... this is going to be another involved discussion, but it had to happen.
It has become obvious to me that we underestimated the impact that only providing factory methods would have on existing codebases. The factory methods are in no way a drop-in replacement for the old functionality and would force users to rewrite larger portions of their code than they might be comfortable with. Certain usage patterns that they have established over a long time might not be implementable using only factory methods. Only having access to factory methods also means that the only way they can construct instances of their own types that contain unwrapped SFML objects as members is by calling
.value()within the initializer list and ending up having to handle exceptions (std::bad_optional_access) anyway. This basically forces the user to wrap their own objects inoptionals and write factory functions for them as well in order to properly handle failure to load a resource. I don't feel good about SFML's API having such a "viral" nature.If anyone has been paying attention, the last example is exactly how the Shader example ended up having to be rewritten in order to make use of the new factory methods. This is a very high impact change and as I already said above, nobody is going to be able to sell it as a drop-in replacement. Not everybody is willing to pay for additional safety at any cost. The examples above should also make it obvious that the alternatives forced upon the user will end up making their code noticeably more complex than what it would have been when using the old API. This is not only annoying for experienced users trying to get work done but will also significantly degrade the experience of beginners just trying to get their first object-oriented project through the door.
Also, the lack of non-factory
loadFromXYZ/openFromXYZmethods means the only way to reload a resource is by moving a newly created one on top of the older one, which is very inefficient, performance-wise and memory-usage-wise.In addition to providing throwing constructor variants of the factory methods (equivalent to #3134), this change also reimplements the default constructors and
loadFromXYZ/openFromXYZmethods. The factory methods were renamed tocreateFromXYZto avoid name clashes and because create better reflects that they create new objects instead of operate on existing ones.The initial motivation of converting the
loadFromXYZ/openFromXYZmethods to factory methods was as a follow-up to the error handling discussions. In retrospect, I think that too little was discussed about the actual implementation and consequences of applying the mechanical transformation to all of the affected APIs.In my opinion, a discussion should have been had about what constitutes an "invalid object state" before the factory conversion was performed. As far as I can tell, using most of the default constructed objects affected by this change never really resulted in any undefined behaviour if the user didn't load anything into them before using them. What was lacking was a documented definition of the state an object would be in after it had been default constructed. Many users might have already known this and taken it into account over the years. For me, an object that is fully usable after default construction without causing any undefined behaviour also has to be considered fully initialized, thus providing default constructors and
loadFromXYZ/openFromXYZmethods was never really two-phase initialization in a formal sense.One might question what sense it makes to operate on "empty" objects, and you can make the argument that the library has to prevent this from happening, but if we are to follow the example of the C++ standard library, operations on "empty ranges" (i.e. where both
firstandlastiterators are equal) are perfectly legal and don't trigger any asserts or other diagnostic warnings. Empty containers make just as much sense as "empty" SFML objects. If C++ doesn't force us to create new containers just to assign values to an existing one then neither should SFML. It just doesn't feel natural and might come across as overreaching our domain of responsibility. Sure... performing anything on empty objects will often times be the result of a programming error somewhere, but these are logic errors and that's why we still rely on humans to program and use debuggers to find these kinds of mistakes.The 3 forms of object construction that this change provides are targeted at 3 different user groups:
loadFromXYZ/openFromXYZfor legacy projects and users who have their own established frameworks set up around themRegardless of whether the default constructors still exist or not, providing
loadFromXYZ/openFromXYZmethods still provides reload functionality even for newer projects that use throwing constructors or factory methods. Because their implementation is used as the common base for both the throwing constructors and factory methods it wouldn't make sense not to offer them since the code is already there.The examples and documentation text have not been changed and still reference the factory methods although it might need to be discussed whether throwing constructors should be mentioned as well. Default construction is deliberately not mentioned because it is not recommended for newer projects.
As with #3134 the same applies here, if we add support for our own differentiated exceptions, the user will have finer grained control over how they might want to handle them. The
std::runtime_errors can be seen as place holders until we establish if and what kind of exceptions we want to support.Closes #3134
Resolves #3120
@danieljpetersen might be interested in this.