Skip to content

Instantly share code, notes, and snippets.

@rikkimax
Created September 19, 2024 18:12
Show Gist options
  • Save rikkimax/883dddc4a61134d4c17cb18727287d92 to your computer and use it in GitHub Desktop.
Save rikkimax/883dddc4a61134d4c17cb18727287d92 to your computer and use it in GitHub Desktop.

Value type exceptions

Field Value
DIP: (number/id -- assigned by DIP Manager)
Author: Richard (Rikki) Andrew Cattermole firstname@lastname.co.nz
Implementation: (links to implementation PR if any)
Status: Draft

Abstract

Exceptional pathway handling can be handled in a multitude of ways, this proposal introduces value-type exceptions to encourage close-to-thrown catching often with very little cost by utilising sumtypes and throws set.

Contents

Rationale

This proposal is designed to give low to zero-cost exceptions, where an unwinding mechanism and heap allocation would not be appropriate. It may also be used for when you need to indicate an error condition without having to implement a class which can be a bit of work.

It has been designed as part of a sumtype work by the author. This has led to requirements going into the sumtypes proposal that enables a lower cost and be feature-rich here.

The member-of-operator offers a no-declaration solution, to giving a single tag without a payload enabling the exception mechanism to exist as only a tag. Using the carry flag it would be possible to differentiate between the tag value and the return type when the return type is non-void and fits in a register giving true "zero-cost" exceptions.

Composite throwing is provided by structs, with some assistance with specific fields to track throws for an optional low-cost backtrace that only requires the compiler's knowledge.

Prior Work

Common error handling mechanisms in D and other languages are to use an unwinding library at runtime to find handlers of a particular exception type. Otherwise, a value that can be described as part of the function signature is used such as an error code, or optional value.

Some languages such as Java rely heavily on the IDE of the user to provide guarantees and a better user experience surrounding the throw set. In this proposal the IDE will not be adding it, it can be done instead by inferring in the compiler.

This design has some roots in Herb Sutter's zero-overhead deterministic exceptions paper and further attributes can be seen by Emil Dotchevski modification of the mechanism.

Some of the criticism of this style of exception handling by Bjarne Stroustrup can be resolved by inferring of the attribute, no one needs to be aware that the @throw attribute exists except when dealing with function pointers, or virtual methods of classes.

Another language that has come up quite a bit in the D community is Midori, the language design Joe Duffy who had worked on it, describes their error model. Where instead of offering a try catch mechanism, to instead offer a try else expression.

In the article D is described in how it relates to try finally and scope. It covers a historical and current as of 2016 view of exceptional solutions in different languages.

Description

A new exception mechanism is described in this proposal with differentiation by using a struct or member-of-operator instead of the class hierarchy Throwable.

It is designed for close to thrown catching of exceptions, where it is not always worth going to the trouble of defining a new class, or desirable to do a heap allocation and unwind on a failure case. It has the added benefit of guaranteeing the catching of a throw value, it cannot be silently ignored as each possible type is in the function signature.

Inferring the throw set is a side-effect of verifying the throw set. It is on by default. If a function signature is annotated and the inferred throw set is non-matching it is an error.

A throw attribute missing its throw set, @throw will be equivalent to @throw(Exception). This is the default if the throw attribute is missing.

Matching must take into account the class hierarchy, so that Throwable matches Exception.

To get the throw set the trait __traits(getThrowSet, symbol) can be used to get a sequence of types.

Try Catch

The way to add or remove values from the throw set is to use throwing and catching of exceptions. This translates to the @throw(...) annotation.

When using classes and overridden methods, the parent defines the throw set. Child classes may not remove items from it.

The throws set is inferred, on all functions, if it has been annotated it must be larger or equal to the inferred one:

int toCall() /* @throw(MyException) */ {
    throw new MyException;
}

int caller() /* @throw() */ {
    int result;

    try {
        result = toCall();
    } catch(MyException) {
        result = 0xDEADBEEF;
    }
    
    return result;
}

Sometimes it is required to catch all exceptions and handle them. To do this a sumtype may be used to acquire them. This works for both unwinding class exceptions as well as structs or member of operator thrown exceptions.

int toCall() @throw(MyException);

int caller() /* @throw() */ {
    int result;

    try {
        result = toCall();
    } catch(sumtype exceptions) {
		result = exceptions.match {
			(MyException) {
				return 0xDEADBEEF;
			};
		};
    }
    
    return result;
}

Mechanism

To allow the throwing to work, the return value of a function will be implemented similarly to a tagged union. This should be compatible with the language provided sumtype. With a specific need for the tag values to be hash-based for quick changing of throw set without the need for a match.

If a value type exception is not caught in a function, it will be automatically rethrown by the compiler implicitly.

Only extern(D) functions can have a non-empty throw set. For other ABI's it will be implicitly and required to be empty.

For structs, the mechanism is aware of two fields that it may set. These fields enable a very primitive form of back tracing using only read-only memory and will be in the form of "my.mod.ule:102".

  • lastThrow, will be set on an explicit throw.
  • originalThrow, will be set on throw but only if it is null.

When using structs, their copy constructors and destructor are called as if it was stored in a sumtype. The variable layout of the sumtype proposal can enable this without matching anytime it is required. This can be used to produce a full backtrace from the point of throw to the catch.

Zero Cost

Throwing exceptions using a non-unwinding mechanism, a goal of this can be to do it at "zero-cost". The effect of the mechanism is to get the amount of data being returned into a single register.

To enable this use case, this proposal supports the member-of-operator for creating a new tag in the throw set, without adding a payload. A struct cannot do this due to them needing to be non-zero in size.

int toCall() @throw(:FailedToDecodeUTF) {
    throw :FailedToDecodeUTF;
}

int caller() /* @throw() */ {
    int result;

    try {
        result = toCall();
    } catch(:FailedToDecodeUTF) {
		result = 0xDEADBEEF;
    }
    
    return result;
}

While this works when the return type of a function is void, it can be desirable to make it work for return types that also fit in a register. Historically there is a solution that Walter Bright remembered during DConf 2024. You can use the carry flag to indicate that an error was returned rather than a value. This allows the tag to replace the return value, and vice versa as long as the sumtype being returned is just a tag which is possible because of the member-of-operator.

ReturnType func() @throw(:Exception);

Grammar Changes

The following are the grammar changes:

AtAtribute:
+    @ throw ( ThrowArgumentList|opt )
    
+ ThrowArgumentList:
+	 ThrowType
+	 ThrowType , ThrowArgumentList

+ ThrowType:
+    MemberOfOperator
+    Type

TraitsExpression:
+    getThrowSet

CatchParameter:
+    sumtype Identifier

To offer the syntax:

void func1() @throw();
void func2() @throw;
void func3() @throw(:Identifier, Exception);

struct S {
}

try {
	S s;

	throw :Identifier;
	throw s;
} catch(sumtype varName) {
	varName.match{
	};
}

The mangling of the throw set is not specified here. However due to the alteration of effective return type, it will need to be specified beyond "is it empty?"

Breaking Changes and Deprecations

The nothrow keyword is recommended for deprecation in favour of the @throw() syntax as it is equivalent. It may be kept for compatibility by mapping one into the other.

The default throw set includes Exception matching existing code. You must explicitly annotate @throw() to indicate nothrow. A future edition can remove this default in favour of @throw().

As part of this, DIP1008 should be slated for removal. The low to minimal cost usage of exceptions is provided by this proposal.

Reference

Copyright & License

Copyright (c) 2024 by the D Language Foundation

Licensed under Creative Commons Zero 1.0

History

The DIP Manager will supplement this section with links to forum discussions and a summary of the formal assessment.

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