thradams
Since then, I've significantly improved Cake's flow analysis.
The new analysis can track the origins of states across control-flow paths, making it easier to understand where a state came from and how it reached a particular point in the program.
Another milestone is that the entire Cake source code is now annotated with ownership and nullability, and checked by Cake itself.
I've documented the flow analysis here:
Ownership model and nullable pointers for C
"Owner pointers" must be uninitialized or null at the end of their scope.
Basically, the nullable state needs to be tracked at compile time, and nullable pointers,despite being a separate feature, reuse the same flow analysis.
For the impatient reader, a simplified way to think about it is to compare it with C++'s unique_ptr.
The difference is that, instead of runtime code being executed at the end of the scope (a destructor), we perform a compile-time check to ensure that the owner pointer is not referring to any object. The same before assignment.
So we get the same guarantees as C++ RAII, with some extras. In C++, the user has to adopt unique_ptr and additional wrappers (for example, for FILE). In this model, it works directly with malloc, fopen, etc., and is automatically safe, without the user having to opt in to "safety" or write wrappers. Safety is the default, and the safety requirements are propagated automatically.
It is interesting to note that propagation also works very well for struct members. Having an owner pointer as a struct member requires the user to provide a correct "destructor" or free the member manually before the struct object goes out of scope.
#pragma safety enable
#include <stdio.h>
int main() { FILE *_Owner _Opt f = fopen("file.txt", "r"); if (f) { fclose(f); } }
At the end of the scope of f, it can be in one of two possible states: "null" or "moved" (f is moved in the fclose call).
These are the expected states for an owner pointer at the end of its scope, so no warnings are issued.
Removing _Owner _Opt we have exactly the same code as users write today. But with the same or more guarantees than C++ RAII.
The C++ mutable keyword came to mind as a potential solution.
During the object creation (or destruction), the instance is considered to be in a transitional state, where the usual constraints—such as non-nullable pointers and immutability—are lifted. Once the transitional phase is over and the object is returned, the contract that governs the object (such as immutability of name and non-nullability of pointers) is fully reinstated.
Fixing C source code from the internet using cake static analyzer
This video shows how cake can help programmers to create safe code just fixing warnings.
We copy paste code then we add pragma safety enable
This enables two features ownership and nullable checks. Ownership will check if the fclose is called for instance, also checks double free etc, while nullable checks will check for de-referencing null pointers.
New qualifiers _Opt and _Owner are used but they can be empty macros, allowing the same code to be compiled without cake.
( The sample I have is 0, NULL and nullptr where nullptr is something new. Two ways of doing something makes the language complex. )
#include <ownership.h>
#include <stdio.h>
int main()
{
FILE *owner f = fopen("file.txt", "r");
if (f)
fclose(f);
}
But comparisons are inevitable, and I also think there are lessons learned
in Rust.C programmers uses contracts, these contracts are part of documentation of some API. For instance, if you call fopen you must call fclose.
All we need is to create contracts that the compiler can read and verify automatically.
Let's say I have to objects on the heap. A and B. A have a "view" to B.
Then we put a prompt for the user. (or dynamic condition) "Which object do you want to delete first A or B?" Then user select B. How this can be checked at compile time?
The cake source itself is moving to another experimental feature that is nullable types.
Similar of C# https://learn.microsoft.com/en-us/dotnet/csharp/nullable-ref...
int * owner p = malloc(sizeof(int));
if (condition)
free(p);
free(p); // error p may be initialized/moved.
to fix int * owner p = malloc(sizeof(int));
if (condition)
{
free(p);
p = 0;
}
free(p);Sample macro NEW using c23 typeof.
#include <stdlib.h>
#include <string.h>
static inline void* allocate_and_copy(void* s, size_t n) {
void* p = malloc(n);
if (p) {
memcpy(p, s, n);
}
return p;
}
#define NEW(...) (typeof(__VA_ARGS__)*) allocate_and_copy(&(__VA_ARGS__), sizeof(__VA_ARGS__))
#pragma expand NEW
struct X {
const int i;
};
int main() {
auto p = NEW((struct X) {});
}
The generated code is #include <stdlib.h>
#include <string.h>
static inline void* allocate_and_copy(void* s, size_t n) {
void* p = malloc(n);
if (p) {
memcpy(p, s, n);
}
return p;
}
#define NEW(...) (typeof(__VA_ARGS__)*) allocate_and_copy(&(__VA_ARGS__), sizeof(__VA_ARGS__))
#pragma expand NEW
struct X {
const int i;
};
int main() {
struct X * p = (struct X*) allocate_and_copy(&((struct X) {0}), sizeof((struct X) {0}));
}In C++ for instance, for smart pointers, the destructor will have a "if p!= NULL". Then if the smart pointer was moved, it makes the pointer null and the destructor checks at runtime for it.
Additionally, compilers often offer the option to compile multiple files. Therefore, we cannot afford to leak memory with each file compilation.
Initially I was planning a global allocator for cake source. It had a lot of memory leaks that would be solved in the future.
When ownership checks were added it was a perfect candidate for fixing leaks. (actually I also had this in mind)
int main() {
struct X * owner p = malloc(sizeof(struct X));
p->text = malloc(10);
free(p->text); //object text destroyed
//p->text is on uninitialized state.
//cannot be used (except assignment)
struct X x2 = {0};
*p = x2; //x2 MOVED TO *p
x_delete(p);As I said I am not Rust specialist.
Also, in my understanding is that in Rust, sometimes a dynamic state is created when the object may or may not be moved.
In cake ownership this needs to me explicit ( and the destructor is not generated)
I also had a look at Rust in lifetime annotations. This concept may be necessary but I am avoiding it.
Consider this sample.
struct X {
struct Y * pY;
};
struct Y {
char * owner name;
};
An object Y pointed by pY must live longer than object X.
(Cake is not checking this scenario yet)Also (classic Rust sample)
int * max(int * p1, int * p2) {
return *p1 > *p2 ? p1 : p2;
}
int main(){
int * p = NULL;
int a = 1;
{
int b = 2;
p = max(&a, &b);
}
printf("%d", *p);
}
This is not implemented yet but I want to make the lifetime of p be the smallest scope. (this is to avoid lifetime annotations) int * p = NULL;
int a = 1;
{
int b = 2;
p = max(&a, &b);
} //p cannot be used beyond this point* int * owner p = calloc(1, sizeof(int));
defer free(p);
However, with ownership checks, the code is already safe. This may also change the programmer's style, as generally, C code avoids returns in the middle of the code.In this scenario, defer makes the code more declarative and saves some lines of code. It can be particularly useful when the compiler supports defer but not ownership.
One difference between defer and ownership checks, in terms of safety, is that the compiler will not prompt you to create the defer. But, with ownership checks, the compiler will require an owner object to hold the result of malloc, for instance. It cannot be ignored.
The same happens with C++ RAII. If you forgot to free something at our destructor or forgot to create the destructor, the compiler will not complain.
In cake ownership this cannot be ignored.
struct X {
FILE * owner file;
};
int main(){
struct X x = {};
//....
} //error x.file not freedthis is the way you tell C what is the size of array.
void f(int n, int a[n]) {
}For instance, this code is correct.
#include <ownership.h>
#include <stdlib.h>
struct X {
char * owner text;
};
void x_delete(struct X * owner p)
{
if (p)
{
free(p->text);
free(p);
}
}
int main() {
struct X * owner p = malloc(sizeof(struct X));
p->text = malloc(10);
free(p->text); //object text destroyed
struct X x2 = {0};
*p = x2; //x2 MOVED TO *p
x_delete(p);
//no need to destroy x2
} struct X x = {0};
//...
view struct X x2 = x;
destroy(&x);
//x2 does not need destructorThe difference with cake ownership and RAII , is that with C++ RAII, the destructor is unconditionally called at end of scope. Then flow analysis is not required in RAII.
Cake requires flow analysis because "destructor" is not unconditionally called.
When the compiler can see that the owner is not owning a object (because the pointer is null for instance) then the "destructor" is not necessary.
To understand the difference.
With flow analysis (how it works today)
int main()
{
FILE *owner f = fopen("file.txt", "r");
if (f)
fclose(f);
}
Without flow analysis (or with a very simple one, where the destroy must be the last statement) void fclose2(FILE * owner p) {
if (p) fclose(p);
}
int main()
{
FILE *owner f = fopen("file.txt", "r");
if (f){
}
fclose2(f);
}Owner pointers take on the responsibility of owning the pointed object and its associated memory, treating them as distinct entities. A common practice is to implement a delete function to release both resources, as illustrated in Listing 7:
Listing 7 - Implementing the delete function
#include <ownership.h>
#include <stdlib.h>
struct X {
char *owner text;
};
void x_delete(struct X *owner p) {
if (p) {
/*releasing the object*/
free(p->text);
/*releasing the memory*/
free(p);
}
}
int main() {
struct X \* owner pX = calloc( 1, sizeof \* pX);
if (pX) {
/*...*/;
x_delete( pX);
}
}The temporary solution, is the re-declare the malloc etc when compiling with cake and not complain withe the function signature difference only by owner qualifiers.
if this ownership were standard then gcc and mscv headers would have the qualifiers there enabled or not , but they would be there.