 | Posted by Jonathan M Davis in reply to bauss | Permalink Reply |
|
Jonathan M Davis 
| On Tuesday, March 18, 2025 1:19:49 PM MDT bauss via Digitalmars-d-learn wrote:
> On Tuesday, 18 March 2025 at 18:04:12 UTC, Steven Schveighoffer wrote:
> > On Tuesday, 18 March 2025 at 07:42:37 UTC, Jonathan M Davis wrote:
> >> The base class constructors are not nothrow, so WrappedTCP's constructor cannot be nothrow. There really isn't a way out of that, because if a constructor throws, the object's state is destroyed. So, catching and handling the Exception to make your function nothrow isn't really an option like it would be with many functions.
> >
> > FWIW, this does compile:
> >
> > ```d
> > class A
> > {
> > this() {}
> > }
> >
> > class B : A
> > {
> > this() nothrow {
> > try {
> > super();
> > } catch(Exception e) {}
> > }
> > }
> > ```
> > Not sure if it should...
> >
> > -Steve
>
> Interesting that it doesn't break anything really.
>
> ```
> import std;
>
> class A
> {
> this() { throw new Exception("test"); }
> }
>
> class B : A
> {
> int a;
>
> this() nothrow {
> try {
> super();
> } catch(Exception e) {}
> }
> }
>
>
> void main()
> {
> auto b = new B;
> b.a = 200;
>
> writeln(b.a);
> }
> ```
How bad it is depends on the type, but if an exception is thrown from a constructor, then the object is not properly constructed, and its member variables which were constructed are then destroyed.
So, if any members have destructors, they're going to be left in a destroyed state, are there are no guarantees what that looks like. It's normally supposed to be the case that you can't even access a destroyed object, though given the mess that we're getting with move constructors and __rvalue where objects can be destroyed multiple times, the compiler really should enforce that a destroyed object is set to its init value (or just do it itself and let the optimizer optimize it out if appropriate), but there is no such guarantee at present.
It's even worse if a member is const or immutable, since then if it has a destructor run and then you somehow access the object afterwards (as Steven showed can be done in a derived class constructor right now), the const or immutable object was mutated, which violates the guarantees that const and immutable are supposed to have.
Member variable that weren't actually initialized in the constructor and don't have constructors will have their init value after the constructor throws, but if the type disables default initialization that could be problematic, and if the class' logic assumes that the constructor succeeded (which would be a pretty normal thing to do), then not having completed the constructor could leave the object in an invalid state as far as its internal logic goes.
And any member variables which were initialized and don't have destructors will end up in whatever state they were in when the exception was thrown, which again, could cause logic issues for the class.
In principle, when a constructor fails, the compiler is supposed to be undoing the partial construction, and then the object is supposed to be inaccessible - and for the most part, the language enforces that, because other than in the constructor itself, you normally only have access to an object once its constructor has completed. The primarily exception is emplace, because you're constructing an object in memory that you control rather than constructing it on the stack or asking the GC to allocate memory, construct the object in it, and then give you access to that memory.
As for undoing the partial construction, the only thing that the compiler normally _needs_ to do for correctness is then run the destructors of the member variables, and the state of the object afterwards doesn't realy matter, because it's inaccessible. So, it doesn't bother trying to put the object in a sane state beyond doing that clean up.
So, it's definitely hole in the type system that a derived class constructor can access catch an exception and continue construction.
And testing this a bit, I don't think that the members are even being destroyed in the correct order. This code
```
import std.stdio;
struct S(string name)
{
int i;
~this()
{
writefln("destroyed %s", name);
i = 42;
}
}
class A
{
S!"s" s;
this()
{
writeln("begin A()");
throw new Exception("foo");
}
}
class B : A
{
S!"t" t;
this()
{
writeln("begin B()");
super();
writeln("end B()");
}
}
class C : B
{
S!"u" u;
this()
{
writeln("begin C()");
try
super();
catch(Exception e)
{}
writeln("end C()");
}
}
void main()
{
writeln("before");
auto obj = new C;
writefln("obj.s.i: %s", obj.s.i);
writefln("obj.t.i: %s", obj.t.i);
writefln("obj.u.i: %s", obj.u.i);
}
```
prints out
before
begin C()
begin B()
begin A()
destroyed s
destroyed t
end C()
obj.s.i: 42
obj.t.i: 42
obj.u.i: 0
So, as you can see, not only are destroyed member variables accessible, but the members in A are destroyed before the members in B whereas destruction should be going in the reverse order of construction.
- Jonathan M Davis
|