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

Defer attributes #1829

Open
lerno opened this issue Jan 13, 2025 · 7 comments
Open

Defer attributes #1829

lerno opened this issue Jan 13, 2025 · 7 comments
Labels
Discussion needed This feature needs discussion to iron out details Enhancement Request New feature or request Loose idea Probably will not be included in the language, but documented to allow discussion

Comments

@lerno
Copy link
Collaborator

lerno commented Jan 13, 2025

This is more of a "let's see if this idea is worth looking at" issue.

The idea can be implemented in several ways, here is one:

{
  Foo* f = mem::new(Foo) @deferfree;
  ...
  f = null; // This is fine
} // the allocation is freed here.

The code above implicitly becomes:

{
  Foo *__temp;
  Foo* f = __temp = mem::new(Foo)
  defer free(__temp);
  ...
  f = null;
}

We might instead envision it like a method call – this is more appropriate if we think invocation will be done as a method call:

File! f = file::open @defer(close); // inserts a defer __temp.close()

It could also be more complex, maybe you'd define something like:

def @DeferFree(&self) = { @defer(free(self)) };
def @DeferClose(&self) = { @defer(self.close()) };
@lerno lerno added Enhancement Request New feature or request Discussion needed This feature needs discussion to iron out details labels Jan 13, 2025
@lerno
Copy link
Collaborator Author

lerno commented Jan 13, 2025

This is clearly a very rough idea. This is more intended for resource handling than memory management.

Here would be a way to do @pool in a flat way:

fn void test()
{
    mem::temp_push() @defer(pop);
    void* t = tmalloc();
}  

Which then would replace:

fn void test()
{
    @pool() 
    {
        void* t = tmalloc();
    };    
}  

@lerno
Copy link
Collaborator Author

lerno commented Jan 13, 2025

However, this is not the only way to do "flat" @pool. We can also imagine macros that could insert defers, if so then we'd just do:

fn void test()
{
    mem::@temp_push_pop();
    void* t = tmalloc();
}

So it's not necessarily the simplest way of doing this. Similarly, we can have a file with close:

File! f = file::@open_with_autoclose();

@lerno
Copy link
Collaborator Author

lerno commented Jan 13, 2025

The downside of macros that insert defers is that this hidden control flow can be hard to understand, even compared to lazy parameters, this s why @defer attributes might be preferable.

@cbuttner
Copy link
Contributor

Allowing macros to insert defer into parent scope feels like opening a big can of worms.

But I think there should be something in this direction. One example I've found when annotating code for profilers, when you want a scope to be profiled, inserting a single line @zone_scoped(); would be much better than having to wrap the entire scope (often the entire function) in braces which makes code that is compiled out 99% of the time very intrusive. Currently zone_begin(); defer zone_end(); is the only one-liner alternative to the trailing body macro.

@lerno lerno added the Loose idea Probably will not be included in the language, but documented to allow discussion label Jan 13, 2025
@alexveden
Copy link
Contributor

alexveden commented Jan 14, 2025

As we discussed on Discord, the proposal of @defer attribute is along the lines of Python with statement. I wrote maybe 500k+ of lines in Python, so I've tried my best to collect all my experience with the snake language and outline a broader picture, if you will.

Python with

There are several cool features with python's with statement:

  1. It acts as a context manager: initializes the object and does cleanup when scope is exited.
  2. The scope itself is a mental model. When we see entering into scope, our brain unconsciously switches its context. Scope also wraps around a chunk of code which has a same purpose, solving an isolated set of tasks.
  3. It's very cool when working with short-lived objects and resources (files, http connections)
  4. with statement in Python also has a temp variable feature, e.g. with open("file.txt", "r") as fh <- variable - this is a super cool, because we don't clutter function local variables and we can use shorter names for shorter scopes. It's less typing :)
  5. Python allows combining multiple with statements for the same scope with open("file.txt", "r") as fh, open("file.txt", "r") as fh2:

Pros

In these cases I found with useful:

  1. Temporary file operations - open, real all, close... open, dump all, close
  2. Acquiring locks / mutexes
  3. Sockets/ DB connections / stuff like that
  4. getting short variables from long names with my_factory.production.mega_class as pmc:
  5. Using with in unit test mocks
with mock.patch("my_func") as mock:
   mock.return_value = False
   func_using_my_func()

with mock.patch("my_func") as mock:
   mock.return_value = True
   func_using_my_func()
  1. Since Python is a garbage collected language, so the resource management problem is secondary. In C3, I think, the mechanism like with is a game changer.

Cons

  1. Everything is good in moderation, when with() is abused code become less readable.
  2. Size of scope should be relatively small (to my taste, 20–50 lines of code) otherwise it would make an impression of spaghetti code. The same problem as with huge if/else scopes.

Thoughts about C3

We have @pool pattern is used all over the place, I think it's good to extend it to other use cases (list is similar to Python's cases above).

with( f: file::open("file.txt", "r")! ) 
{
   f.write(stuff);
}

// file.c3
module file;

fn File open(String path, String mode) @deferable(File.close) {
}

fn void File.close(File self) {
}

Another example:

mut = Mutex();

with( mut.lock() ) 
{
   f.write(stuff);
}

// mutex c3
module mutex;

fn bool! Mutex.lock(&self) @deferable(Mutex.unlock) {
}

fn void Mutex.unlock(self) {
}

Things I don't like

Philosophically speaking, the function is like an article (it has a common topic), scopes of the functions are paragraphs in the article, lines of code are sentences, and operators/calls are words. Without scoping, the code may read as a wall of text. Because of this, I prefer to stick to one line - one thought principle.

This 1st line of code actually contains 3 thoughts: open the file, rethrow the error, close

File! f = file::open("file.txt", "r")! @defer(close); 
f.write(stuff);

C-style version:

File! f = file::open("file.txt", "r"); 
if (catch err = f) return err?;
f.write(stuff);
f.close()

Explicit version:

File! f = file::open("file.txt", "r"); 
if (catch err = f) return err?;
defer f.close()
f.write(stuff);

Middle ground

File! f = file::open("file.txt", "r")!; 
defer f.close()
f.write(stuff);

Alternative universe:

with( f: file::open("file.txt", "r")! ) 
{
   f.write(stuff);
}

What about long examples?

I mean looooong!

File! f = file::open("file.txt", "r")! @defer(close); 
<<
+200 lines of code here
>>
f.write(stuff);

C-style version:

File! f = file::open("file.txt", "r"); 
if (catch err = f) return err?;
<<
+200 lines of code here
>>
f.write(stuff);
f.close()

Explicit version:

File! f = file::open("file.txt", "r"); 
if (catch err = f) return err?;
defer f.close()
<<
+200 lines of code here
>>
f.write(stuff);

Middle ground

File! f = file::open("file.txt", "r")!; 
defer f.close()
<<
+200 lines of code here
>>
f.write(stuff);

Alternative universe (reads worse, but scope indicates we could be working on something):

with( f: file::open("file.txt", "r")! ) 
{
<<
+200 lines of code here
>>
   f.write(stuff);
}

For longer version, I would prefer C-style goto fail; or free resources at the end of function. I don't have much experience with defer, it has a cool features of keeping init and de-init code in the same place. But also I found myself returning to the middle of the code, asking myself if I did defer the resource allocation. Currently, it's somewhat a mental overhead, but I think I will get used to it eventually.

Long story short, I would stick to "middle ground" approach (#1830 kinda thing), and maybe think about introducing "alternative universe" version. Including refactoring @pool usage to more universal with or @with, and adding compatibility with this mechanism to std lib.

@alexveden
Copy link
Contributor

Ah forgot, multiple expressions in with too:

with( 
   f: file::open("file.txt", "r")!,
   another: file::open("file.txt", "r")!,
   mut.lock()
) {
   f.write(stuff);
}

@cbuttner
Copy link
Contributor

Maybe one could require a double @@ prefix to make it clear there is some hidden control flow. Or require to append a special attribute when calling. Just in hopes to discourage C++-like RAII/implicit destruction a little.

fn Zone @@zone(String name) @defer(zone_end) {
  // ...
}
fn zone_end(Zone zone) {
  // ...
}


fn void foo() {
  profiler::@@zone("foo");
}

One could also have @body implicitly capture the remainder of the scope the macro is invoked in, and this would also have to be clearly indicated at the calling site.

macro Zone @@zone(String name) {
  $if env::PROFILE:
    Zone zone = zone_begin(name);
    defer zone_end(zone);
  $endif
  @body(); 
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Discussion needed This feature needs discussion to iron out details Enhancement Request New feature or request Loose idea Probably will not be included in the language, but documented to allow discussion
Projects
None yet
Development

No branches or pull requests

3 participants