The future of programming with certified program synthesis
gopiandcode.uk
gopiandcode.uk
OK, so now we're programming at the specification level. I've written formal specifications for an operating system. They're harder to write than code for non-trivial programs.
Some things are trivial to specify but hard to code. A hash table, for example. It's easy to specify what a hash table does, but that gives no guidance on how to implement one. You might get out something that does a linear search. File systems and databases are similarly not too hard to specify - the specification describes the abstract brute-force approach.
They need to get much further along before announcing victory.
A complete specification must include best/worst/anortised performance constraints, and a lot of other details we often leave unstated.
And then it gets a lot harder...
Here the assumptions are infinite stack (or item count < stack size limit) and a malloc that never fails.
Incomplete specs are just as bad as buggy code. I'd even argue that incomplete specs are worse in this scenario, because the "proven correctness" gives a false sense of security.
The purpose of this article seems to be to introduce us to separation logic for reasoning about programs with pointers, and in particular the separating conjunct, which "restores compositionality of analysis" (which is lost when spatial assertions assert that some particular property holds over the entire heap.) It goes on to explain that "As it turns out, the compositionality and modularity of separation logic means that it can actually be quite easily adapted to fit into a deductive-reasoning-based automated synthesis strategy."
The title, therefore, should not be read as a claim about the future of programming in general.
X such that P(x)
where P is a test predicate. Doesn't mean you have any idea how to generate X. A long time ago, when this idea was going around for the first time, I was using a formal specification language called SPECIAL. I wrote the statement of Fermat's Last Theorem in Special to demonstrate that merely being able to formally specify something doesn't necessarily lead to a solution.One of the next steps for formally proven software development is automated analysis of algorithms. The same procedure to generate a proof of a correct implementation of a hash table could be extended to take an axiom of randomness (which would be an optimization the metaprogrammer could supply, breaking the true worst-case analysis based on their trust of the hash function and potential adversaries) for the hashing function in order to prove the amortized search/insertion time to be O(1) per element assuming the implementation is free to resize and rehash the table as it grows. The synthesizer could further search for implementations that minimize amortized/average cost using the available axioms and predicates, and benchmark them on a particular architecture much like JIT runtimes do now.
I believe it is just that - a logical paradox. It cannot be answered. We will always have to be double-checking the specifications, perhaps via model checking.
All that's to say, I agree with you, it's not like this is going to solve all problems just by using formal specs.
Well, that's the point of automating the coding effort. The specification guides a search for a program that satisfies it. In your hash table example there's no need to tell the synthesiser how to code the hash table, only what the hash table has to do.
The search of course can get very expensive and that's an important bottleneck in program synthesis. But, in principle, if the specification is correct, then a correct program is somewhere in the program search space and a search will eventually find it.
Anyway that's the rules of the game for search-based program synthesis. The best that can be done is to guarantee that the search space contains the program. It's an important bottleneck and in my opinion is part of what has kept the field back from fully achieving its goals (which are diverse- and there are diverse sub-fields also).
Also, using recursion is bad, because copying a long list will lead to a stack overflow.
To present a piece of research in such trollish terms, accusing "stubborn C developers" of causing security vulnerabilities, and then present an example of code for which "we didn't even have to bother reasoning about how exactly it did its pointer manipulations" that segfaults if it runs out of memory (it dereferences the result of malloc and writes to that location 2 lines down) is beyond hilarious.
Address space size is one of these limits, not the only one. Hitting it is rare on 64-bit operating systems, common on 32-bit ones.
The specification for the listcopy is listed slightly lower down on the article as follows:
// r :-> x ** linked_list(x,S)
void listcopy(void **r)
// r :-> y ** linked_list(y,S) ** linked_list(x,S)
As the precondition explicitly states that r points to a value x, it can not be NULL, so no NULL checks are needed.I'm not sure what the sibling post is trying to imply with recursive calls though.
I think that is based on a misunderstanding of what it being passed in at the point listcopy() is called in the body of listcopy(). The pointer passed in there can't be NULL due to how it is obtained, so no further checks is necessary.
Apart from these serious runtime behaviour flaws the code is also violently unreadable, which you strangely use as a way of attacking C programmers generally, as if this code is representative of human-coded C. This implementation would fail code review for numerous reasons anywhere I've ever worked.
The article itself looks detailed and very interesting and I'll have a proper read of it if I get the time. You didn't need to make it so aggressive and clickbaity.
I believe that Rust will make more inroads to replace C in many domains. This will eliminate many of C's known issues that Certified Program Synthesis seeks to address.
If you need formal verification or proofs, SPARK https://en.wikipedia.org/wiki/SPARK_(programming_language) is a mature and effective tool with a proven track record. It lets developers write code that is easy to understand and maintain compared to generated code and can formally prove the correctness of programs.
More modern build systems automate this process of hiding the intermediate steps. One great example of this is the way Thrift and gRPC. You never edit the stubs or server code. You just implement an interface.
The source code for the mentioned example is:
...
[copy linked list: annotations]
...
I am not saying that the compiled/generated output shouldn't be human readable -- I strongly support readable intermediate outputs. But we need always recognize what is the real source code and what is the tool. Confusing them inflates the complexity and promotes wrong attitude, such as not taking responsibility for one's code or not caring about understanding the code.Yes, exactly.
The tooling also helps. C compilers do some pretty fancy optimizations. Again, they can either re-implement that inside their tool, or they can just output C and use the existing tools.
Agreed. The issue is that I have never encountered code that did not need to be changed later.
I have encountered generated code for which the code generation system was no longer available and it was a tremendous pain to understand and modify.
Examples:
If you want to add an int32 into a protobuf you modify the .proto file with your message.
If you want to change `int32` to `int32_t` instead of `int` to make the code generator more multi-arch friendly you'd change the protoc plugin for your language.
"I have never encountered code that did not need to be changed" is the wrong mindset to have here. You're thinking of the generated code as source code instead of an internal build step (same thing as Clang or GCC's IR).
Also if you're in a situation where "code generation system was no longer available" then your build would never succeed. This should be like saying "gcc was no longer available". It shouldn't be possible and in reality the generated code shouldn't even be checked into the source tree.
>> Also if you're in a situation where "code generation system was no longer available" then your build would never succeed. This should be like saying "gcc was no longer available". It shouldn't be possible and in reality the generated code shouldn't even be checked into the source tree.
No. All I had was the generated code. It was not a "wrong mindset", it was the reality of the situation.
The generated code was all that was available. The code generation system was proprietary and ran on a VAX. I did not have the model specifications used to generate the code. I did not have the proprietary code generation system. I did not have a VAX system to do the code generation. All I had was the generated code because that was all that was available.
The generated code needed to be ported to a different machine architecture and new operating system (Unix). Compilers for the generated code were available and some of the generated code would build fine while other parts needed to be adapted for machine word size, endianness, etc.
>> the projects doing that were already starting from a broken position
Sometimes you don't get to choose the situation. Maintaining legacy code and updating it to run in modern environments is not easy, but it does pay well.
I completely agree that the original specification and code generation system are the "one true source" and that the generated source code should not have been kept, but those decisions were made long before I entered the picture.
So, nothing changes compared to using existing high level language compilers, then?
On the contrary, it acts as an additional layer of complexity.
Looking at the example generated code snippet from the article:
void listcopy(void \*r) {
void *x2 = *r;
if (x2 == NULL) {
return;
} else {
int vx22 = *(int *)x2;
void *nxtx22 = *((void \*)x2+1);
*r = nxtx22;
listcopy(r);
void *y12 = *(void \*)r;
void *y2 = (void *) malloc(2 * sizeof(void *));
*r = y2;
*((int *)y2) = vx22;
*((void \*)y2+1) = y12;
return;
}
}
If you don't have the specification for listcopy or the code generation tools, but only the code shown and you need to make changes then you have a more difficult situation than normal, human-written code.Yes, but it is not as easy to patch binaries compared to patching the source code.
Yes, that is the point of code generation. You had a positive sentiment towards Rust - Rust has a compiler. In order to work with Rust, you have to have "all of the original specifications and code generation tools." Source code is just the specification of the program, and the compiler is the code generation tool.
Why do you trust compilers any more than this?
Compilers generate machine code. When I said "all of the original specifications and code generation tools", I was referring to tools that generate SOURCE code using models or specification languages such as:
https://en.wikipedia.org/wiki/Business_Process_Model_and_Not...
https://en.wikipedia.org/wiki/Systems_Modeling_Language
https://en.wikipedia.org/wiki/Unified_Modeling_Language
These tools use models or specifications as input and generate source code output.
Frequently these are expensive, proprietary tools that are intended to let semi-technical domain experts build solutions without needing to know how to program computers. They are the previous iteration of "no code" / "low code" tools.
What happens if the model tool company goes out of business?
What happens if the model tool company gets acquired and the new owner decides to raise the prices too much and some of their customers cannot afford to pay?
What happens to all of the generated source code produced by these tools when the business using the generated source code needs changes made, but no longer has the models or tools?
Specs = source code.
Code generation tools = compiler.
How is this different from any other code? If you need to modify it, you need the source and a compiler.
Specs =
https://en.wikipedia.org/wiki/Business_Process_Model_and_Not...
https://en.wikipedia.org/wiki/Systems_Modeling_Language
https://en.wikipedia.org/wiki/Unified_Modeling_Language
Code generation tools =
https://model-based-systems-engineering.com/sysml-tools/
https://en.wikipedia.org/wiki/List_of_Unified_Modeling_Langu...
What happens if the model tool company gets acquired and the new owner decides to raise the prices too much and some of their customers cannot afford to pay?
What happens to all of the generated source code produced by these tools when the business using the generated source code needs changes made, but no longer has the models or tools?
Yes, you need both specs and tools. Vendor lock-in can really bite, especially if your business depends on tools that are no longer available.
Which is also a problem for compiles code in not-widely-supported forms (language/target combo).
Understanding, updating, and maintaining algorithmically-generated source code is the other half of the problem.
If a human is writing the code, hopefully variables have sensible names, there is logical organization of the code, and there are some comments to explain.
(Of course, some humans write code that is worse than algorithmically-generated code. // shudder)
There is no such thing: algorithmically generated code that isn’t in final executable form is not source code, its an intermediate representation. The fact that its in a language that some source is written in doesn't change that.
If you are maintaining anything other than the source form, you are doing it wrong (and if the tool is half-baked so you have to manually massage the generated code, that's definitely a problem with the tool.)
So it looks like the code is terrible, and they only give an example for a simple usecase. So I'm not very enthousiastic about using this technique in practice.
The rest of the post looks very informative though, although I haven't completely read it yet.
Yes, I'm very skeptical about this being "the future of coding!" (quoting the OP). These tools typically get stuck as soon as one needs to deal with non-trivial loops or recursive code, because it's not clear how to "synthesize" the correct loop invariant or inductive hypothesis; in particular, this cannot be done "compositionally" or "step-by-step" as in the simple case OP demonstrates. Typically, a developer has to step in and provide the right invariant so that the tool can get unstuck.
Proof synthesis has exact the same issue, so generating proof-carrying code is also something that cannot thus far be automated except in very simple cases.
Having said that, separation logic can be a valuable low-level tool and has been used to assess the correctness of, e.g. Rust borrow checking, which aims to provide lightweight, composable guarantees that can scale up to encompass larger programs. Even there though, we're nowhere near an actual proof, and soundness issues have in fact cropped up in the past as new features got added.
By "inductive hypothesis" do you mean the inductive case in a recursion?
If so, I'm curious, why do you say this? My experience is that the problem with learning recursion is constructing the "base case", particuarly when there is no example of it. Do you have specific examples where the inductive case is hard to find when learning from specifications?
In any case very interesting paper, however I feel such kind of tooling can only be enforced in scenarios where code certification is already taking place, e.g. MISRA and AUTOSAR.
The more interesting thing to me is just the idea of separation logic. The functional programming community has this idea that functional programming is the only sound programming model from a logical perspective, because it is very historically rooted in math and logic. Turing machines, by contrast, are much more 'made-up' constructions - they have weird, non-mathematical sounding components like heads and tapes, etc.
But, for whatever reason, we can produce extremely fast and small processors that pretty closely resemble Turing machines, and C-style imperative, memory-based code flows naturally from that. And, with separation logic, it can also be firmly rooted in math and logic.
That was always my feeling with the functional programming zealots, like they were just ignoring other possible universes. The lambda calculus is one formally sound model of computation, sure, but surely it's not the only one.
We developed higher-level languages like C to specify and generate machine code easier and with less errors. Then it turned out with the higher level language it's easy to make mistakes, too, so let's put another layer of specification language on top to avoid that (e.g. let's do Java). Eventually it will turn out that it's not easy to avoid mistakes in that language as well, so we put another layer on top, and again and again.
The gist is that computation is not an easy task and we can't avoid the halting problem, but still it is very good to have tools available that make negotiating through bug space easier.
That is a risk: if the specification is the entire program written in the specification language, then the same opportunities to make mistakes present themselves when writing the specification, as when writing the program by hand.
In this case though the specification language has the advantage of being purely declarative: it's only a statement of pre- and post-conditions without any assumptions about implementation. Such a specification leaves little room for mistakes.
The worm at the core of all formal verification is that we mistakenly believe that a result is absolutely correct because formal logic was employed. Logic can be subtly wrong, even though the argument appears correct. The very act of using logic does not imply actual correctness.
This is not a dismissal of the field - just a warning. There are many mistakes that can still be made when formally specifying and verifying. To believe otherwise is pure hubris.
Agreed it is bad code though. A good C example and then synthesised code of the same task would have been better imho.
Thing is, if the hypothesis is that the complexities of pointers make C harder to read than other languages, this example does not prove that, because its use of pointer features is completely gratuitous.
Below I attempted to write a saner version of the same C function. I removed the pointer arithmetic and casts in favor of just using a struct, not because I wanted to avoid those features for demonstration purposes, but because that's what any sane C code would do. I also changed it from accepting a pointer-to-pointer, where the pointee is originally the input value and is then replaced with the output value, to just taking the input as an argument and returning the output – again, because that's the simple and obvious choice.
As it happens, the resulting code's use of pointers is limited enough that it could be directly transliterated to Java or any other high-level imperative language. Change the `malloc` to a `new`, fix up the syntax, and you're done. Therefore, whatever lack of clarity remains, it can't be due to anything peculiar to C. (I'd say at this point that the code is clear enough that most people would notice if you removed a line, but YMMV. To be fair, my version also shares some flaws with the original such as not checking for allocation failure.)
That said, there are plenty of algorithms which are complex or subtle enough that even in a well-written implementation, someone might not notice a line being removed. But that can happen in any language, at least to some extent.
struct list {
int value;
struct list *next;
};
struct list *
listcopy(struct list *list) {
if (list != NULL) {
struct list *copied = malloc(sizeof(*list));
copied->value = list->value;
copied->next = listcopy(list->next);
return copied;
} else {
return NULL;
}
}I have not tried Rust for a while so this might have changed, but in the tiny embedded devices I work in, where your 'free' memory is for instance 24kb and the speed of the MCU is a few mhz, there really is only C/asm. You need to mess around with every single byte to make it fit and fast enough. There are surprisingly many of these MCU's around everywhere around us. And you (usually) cannot update their firmware remotely (or at all actually), so it's important it doesn't have massive bugs.
That said, there are more of these projects. I think there is an F* project too.
> why would another language compiled to C be any better? It just doesn't make sense to me.
It seems that the formalization in the article allows for very low level operation which you would use in C while not writing C. As said, sure you can do that in C++ but then you gain little or nothing. I have not tried this, only posted it because I would like for something robust like it to exist.
Now we use Coq (or more recent idris) to write things and hand translate them to mcu asm/c.
I would love to see a realistic (not hello world) example on a 32kb mcu. So with significant amount of function points (we do decryption, signing, otp generation, display driver, keyboard, and lot more and people told me and I read online this would not work well with Rust but this is maybe outdated or maybe the code will just look like C? I don't know examples of anything realistic): think that would turn me and more people.
I tried Zig on Linux and it is nice: any restricted embedded examples? I will try both Rust and zig when I get home to see if it could even work.
https://www.mikroe.com/mikropascal
https://www.mikroe.com/mikrobasic
Mikroe is just one possible vendor, there are others.
If anything, just to raise awareness for others.
Even C++ alone is already an improvement, provided it isn't just C compiled with a C++ compiler, e.g. AUTOSAR.
The code presented is trash and doesn't follow best practices - using the type system, descriptive variable names, etc. Void pointers for everything, are you serious? Of course it's hard to understand. Revealing that it's the generated code doesn't make me any more comfortable with it.
Yes, I get that you shouldn't care what the generated code looks like if you trust your input, but I don't find the predicate any easier to understand. How do I know that's correct? What if I need to debug this generated output?
Using the same argument for the input and output, when making a copy, is idiotic, not standard.
Well, maybe idiotic is standard, but that's another discussion.
Copying linked lists is not a widespread practice in C programs, by the way; if we don't count C programs that are Lisp interpreters. It creates memory handling problems. In this example, the nodes carry data consisting of integer values. These are referenced by pointers. The copy is shallow-copying these pointers, which could be an issue. If the integers are dynamically allocated, who cleans them up? They have to persist as long as either list is still reachable.
The average embedded C programmer's imagination doesn't even extend to the idea that a linked list would ever be copied.
"Like whaddya mean? The list nodes are embedded in the task control blocks ... you'd have to duplicate the actual processes themselves. It makes no sense."
Idiomatic C code uses structs for linked lists, not void * pointers to binary cells consisting of two void * pointers, one of which is actually an int value.
I also don't understand why the example function is casting (void **) r when r already has that type.
Here is something more reasonable, still with the shallow-copy issue of the pointers-to-integer, and no handling of a null out of malloc. The code is very easy to follow:
struct cons {
int *car;
struct cell *cdr;
};
struct cons *listcopy(struct cons *list)
{
if (list == 0) {
return list;
} else {
struct cons *ncell = malloc(sizeof *ncell);
ncell->car = list->car;
ncell->cdr = listcopy(list->cdr);
return ncell;
}
}
If the lists can be long, the stack growth can be a problem; this isn't tail recursive. You want something iterative. One possibility is a two pass version: struct cons *listcopy(struct cons *list)
{
struct cons *copy = 0;
struct cons *out = 0;
struct cons *cdr;
/* walk list, allocating new nodes, pushing
* them onto copy stack
*/
for (; list; list = list->cdr) {
struct cons *ncell = malloc(sizeof *ncell);
ncell->car = list->car;
ncell->cdr = copy;
copy = ncell;
}
/* Reverse copy stack in place by popping
* all nodes and pushing them onto the out
* stack.
*/
for (; copy; copy = cdr) {
cdr = copy->cdr;
copy->cdr = out;
out = copy;
}
return out;
}
Here we can separately verify whether the copy stack is correctly produced, and then correctly reversed into out.The average seasoned C pro will probably jump to this sort of solution, but not use any cons/car/cdr terminology he or she never heard of:
struct cons *listcopy(struct cons *list)
{
struct cons *copy = 0;
struct cons **pcdr = ©
for (; list; list = list->cdr) {
struct cons *ncell = malloc(sizeof *ncell);
ncell->car = list->car;
ncell->cdr = 0;
*pcdr = ncell;
pcdr = &ncell->cdr;
}
return copy;
}
Using a dummy node on the stack to serve as the list parent, we can notationally eliminate the ** pointer: struct cons *listcopy(struct cons *list)
{
struct cons dummy = { 0 }, *tail = &dummy;
for (; list; list = list->cdr) {
struct cons *ncell = malloc(sizeof *ncell);
ncell->car = list->car;
ncell->cdr = 0;
tail->cdr = ncell;
tail = ncell;
}
return dummy.cdr;
}We already tried this in the past:
machine code -> assembly -> C -> Haskell -> ...
The abstract of the (POPL) paper claims that "the derived programs are ... correct by construction" but what about the transformations to the specification, particularly the post-conditions? That's a bit scary. When and how can post-conditions change?
C does need to die, but until fairly recently there were not many viable replacements. D isn't bad but isn't "better enough."
It turns out that Rust has gained some attention and is capable of operating in this space. The inertia of Linux kernel code needing to inter link and being largely in C is one reason why you don’t see random bits of Rust in Linux, but this is not to preclude designing low level systems in Rust.
You ran a program, you gave it all your resources, it got confused, and ran amok. It's YOUR fault.
You shouldn't have given it all your resources. You should have only given it access to the minimum set of resources resources required to do the job.
Now, generating provable correct code does have its merits, the article should have started with those.
So, as long as the specifications, and CPU documentation are correct, it will work as intended, a high percentage of the time. (Random bit flips, disk errors, etc. are still a thing)