ok so i've been reading the "specification" from "the c programming language" (1st edition). alongside that, i've also been reading the c89 rationale document, and the source code for the 7th edition unix c compiler. i've also been reading some other related stuff.
and i didn't really know anything about pre-ansi c until i did this research, but i found a bunch of stuff, so i'm documenting my findings here :3
wait hang on real quick before i get started here's a thing i thought about
so, c's grammar is context-sensitive and ambiguous and Bad; we already know this. but in the context of c's development, it kinda makes sense actually. well, ok, it doesn't make "sense", but it's more justifiable.
the original c compiler was a single-pass compiler. that was one of c's things actually, that it was even possible to write a single-pass compiler for it -- that was a necessity for a heavily memory-constrainted system like the ones they were working with. and if you're writing a single-pass compiler, then, by necessity, you're already keeping track of scopes and identifiers and stuff while parsing. so the fact that the grammar changes depending on whether or not an identifier refers to a typedef really doesn't matter, since it's not much any extra work to just check; you'll need to check at some point anyway.
in modern c, this is really hard to handle correctly because of its fucked up scoping rules, particularly that you can have a discontinuous scope if you have a nested function declarator in the prototype of a function definition. but in k&r c, this is a non-issue, since function prototype scope isn't a thing at all!
so, like, don't get me wrong, c's grammar is awful and bad and not good. and the context-sensitivity was a mistake. but, i can understand where it came from, and how it did the job well in a historical context.
anyway here's the actually interesting stuff:
void is an ansi invention, apparently
maybe this is common knowledge, but it surprised me. i guess there wasn't really a need for it back then: functions implicitly returned int, but if you didn't want to return anything meaningful, you could just not use a return statement, or use return without an expression. and all pointers were assignable to all other pointers, so char * could be used for "generic" pointers
different floating point types
there was no long double, however, there was long float, which was a synonym for double. i'm really curious what the history/rationale of this was. long double makes sense because it avoids adding a new keyword, and long float would make sense if double wasn't a keyword, but the fact that both can be used interchangeably is weird to me
some more interesting stuff with type specifiers
there was no signed specifier; only unsigned. furthermore, there were much fewer valid combinations of type specifiers: unsigned, short, and long acted as "adjectives" to be used alongside int (or float in the case of long float).
so, long wasn't valid, but long int was. the order didn't matter, so int long was also valid.
likewise, unsigned int was a thing, but unsigned wasn't, and neither was unsigned char.
there could also be only one adjective, so, for example, there was no unsigned short int (some later compilers implemented other unsigned types as extensions; more on that in a bit).
so like, the idea was that you'd almost always use signed integers, except that char was either signed or unsigned depending on what was most efficient for the target, and int could also be made unsigned. this surprised me.
different behavior for integer promotion
speaking of unsigned: so, the dialect of c documented in "the c programming language" only allows the unsigned specifier alongside int. but later compilers allowed unsigned short int and unsigned long int (and possibly unsigned char?) as extensions.
but the thing is, since there wasn't a standard, no one could agree on how integer promotion should work with unsigned integers. this wasn't documented in k&r (since it wasn't relevant), so two diverging practices emerged: "unsigned preserving" and "value preserving".
so like, assume sizeof(short int) < sizeof(int). now, what's the result of the expression -1 < (unsigned short int)0?
"unsigned preserving" implementations would say the result is 0 (false), since the operands are promoted to unsigned int. "value preserving" implementations would say the result is 1, since all possible unsigned short values fit within (signed) int, so it's made signed.
honestly, i'm surprised (but glad) that the standard ended up taking a hard stance here, rather than just making the behavior implementation defined. the standardized behavior was the "value preserving" behavior. the rationale has this to say:
The unsigned preserving rules greatly increase the number of situations where unsigned int confronts signed int to yield a questionably signed result, whereas the value preserving rules minimize such confrontations. Thus, the value preserving rules were considered to be safer for the novice, or unwary, programmer. After much discussion, the Committee decided in favor of value preserving rules, despite the fact that the UNIX C compilers had evolved in the direction of unsigned preserving.
that last sentence is really interesting to me. compilers were leaning toward one practice, but the opposing practice was standardized. maybe non-unix compilers generally did value preserving, while unix compilers generally did unsigned preserving? not sure.
unsigned integer constants
back to k&r: since unsigned integers were (seemingly) kinda an afterthought, there was no U integer suffix (there was a L integer suffix though).
furthermore, there were no unsigned constants at all! what i mean by that is: in standard c, if a non-decimal (i.e. hex, octal, or binary) integer constant doesn't fit in int, it first tries unsigned int before trying long (as opposed to decimal integer constants which are never unsigned without a suffix). k&r c has similar behavior, but with an important difference: decimal integer constants behave the same (unless the result doesn't fit into long, in which case, ¯\_(ツ)_/¯), but the behavior of non-decimal integer constants is that they have type int if they fit within unsigned int.
that is: assuming sizeof(int) == 2, 0x8000's type is int, so the value wraps around to negative. the reason the type isn't long int is presumably because hex/octal constants are often used for bit masks, whereas decimal constants are used for, like, actual numbers, but it's funny that the result type is never unsigned.
8 and 9 were valid digits in octal constants
speaking of integer constants, yeah, 078 and 0100 denoted the same value. this was also true in octal escape sequences (\078 = \100)
missing escape sequences
speaking of escape sequences: there were no \a or \v escapes, nor was there a \x escape. however, it was well-documented that if an invalid escape is used, it's not an error; the backslash is just silently dropped. so this means that, although a strict reading of k&r says that \" isn't recognized in character constants, and \? is an ansi invention (necessitated by the introduction of trigraphs, also an ansi invention), these escapes effectively already worked.
there were no float suffixes
all floating constants had type double. this sorta makes sense i guess, especially since long double didn't exist
pretty much everything was an int
a consequence of unsigned integers being an afterthought, as well as the original intent of int to be the machine word, justified how operations like sizeof and pointer subtraction yielded int, rather than size_t/ptrdiff_t. size_t and ptrdiff_t are ansi inventions, actually. everywhere size_t is used today, k&r c (and unix libc) used int. an implied consequence of this is that the size of types can't exceed INT_MAX.
there were no type qualifiers
that is, no const or volatile. i already knew about this one, but it's still kinda neat.
oh yeah fun fact, earlier drafts of the c89 standard had a noalias qualifier (e.g. noalias char *). dennis ritchie wrote a lengthy rebuttal for it, basically saying that the design is so fundamentally flawed that there's literally no way to make it work:
The committee has created an unreal language that no one can or will actually use.
it also criticizes type qualifiers in general. noalias was removed before publication of the final document. however, original prints of "the c programming language" 2nd edition reference the noalias qualifier in a couple places, though these references were removed in the second printing.
string literals designated distinct mutable objects
it's documented and well-defined that string literals in k&r c designated distinct mutable objects (as opposed to standard C where they aren't guaranteed to be distinct and must be treated as immutable). this is why, to this day, string literals still have type char * despite being immutable: the const qualifier didn't exist pre-ansi, so for compatibility with existing code the result type of string literals didn't use it. dennis ritchie actually points this out in his rebuttal/rant against type qualifiers that i linked above:
A related observation is that string literals are not of type `array of const char.' Indeed, the Rationale (88-004 version) says, `However, string literals do not have [this type], in order to avoid the problems of pointer type checking, particularly with library functions....' Should this bald statement be considered anything other than an admission that X3J11's rules are screwy? It is ludicrous that the committee introduces the `const' qualifier, and also makes strings unwritable, yet is unable to connect the two conceptions.
but that's c for ya 😎👉
entry is a reserved keyword, apparently??
this keyword wasn't used anywhere in the grammar, it was just reserved. i had literally no idea about this
line continuations didn't work everywhere
in standard c, any line can end with \ to ignore the line break and continue the line. in k&r c, this wasn't the case: \ was only allowed at the end of the line in character constants, string literals, and preprocessing directives (like #define). the standards committee decided to allow it anywhere cuz it was easier to do that than only allow it in certain places, and it didn't really cause any harm.
typed funciton parameter lists didn't exist
this one isn't super uncommon knowledge, but, the function declarator form with types in the parameter list is an ansi invention. in k&r c, function declarators could only use the form (), except for definitions, which had a list of parameter names.
but while that's pretty well known, there's a funny consequence of this which shows a big hole in the language: there was no way to declare variadic functions. this is fine when the fact that the function is variadic doesn't change the ABI's calling conventions, but if it does, well, tough luck. especially because printf is a variadic function, and like, that wasn't just a unix thing, printf was universally a C thing. and calling printf without first explicitly declaring it was common; the c89 rationale explicitly notes that the requirement for a prototype for printf was a big quiet breaking change.
a couple of things i learned about standard c while researching k&r c
the fun thing about stuff like this is that even though i'm researching pre-standard C, i still manage to learn new things about standard C which i somehow missed until now. two such things come to mind:
the first is that float parameters are implicitly adjusted to double, but only in the k&r form, for backwards compatibility. (this is irrelevant now that the k&r form has been removed in c23, of course)
the second is, a lot more embarrassing. like, i really should've known this one, but somehow i didn't: the left operand of a shifting expression isn't promoted with the right operand, as it is for other arithmetic operations. so like: the result of 1 << 1L is int, not long. the reason i found out about this is because k&r c does promote the left operand, so the result in k&r c is long int.
parameters with function types weren't adjusted to pointers
on the topic of function parameters: much like in standard c, array parameters were adjusted to pointers, but unlike standard c, function parameters weren't adjusted to pointers; they were disallowed entirely. the c89 rationale gives no explanation for why they decided to add this conversion ¯\_(ツ)_/¯
function pointers weren't implicitly dereferenced in call expressions
actually, on the topic of function pointers: in standard c, a function pointer can be called directly, without the need to explicit dereference it. this wasn't the case in k&r c, where an explicit dereference was necessary. note that function -> pointer conversion still occurred for expressions almost everywhere else; call expressions were an exception where this conversion didn't occur.
i found a mistake in the grammar for function definitions in the k&r book
ok this one's really fun lol. i had to read the compiler source to verify that this was in fact a mistake, because honestly it wouldn't have been that shocking if the grammar really was just Beyond Fucked™. the grammar for function definitions is as follows:
Function definitions have the formThe only sc-specifiers allowed among the decl-specifiers are extern or static; See §11.2 for the distinction between them. A function declarator is similar to a declarator for a ‘function returning ...’ except that it lists the formal parameters of the function being defined.function-definition: decl-specifiersopt function-declarator function-bodyfunction-declarator: declarator ( parameter-listopt ) parameter-list: identifier identifier , parameter-list
do you see the problem? function-declarator (only used for definitions) is defined as a normal declarator followed by a parameter list. this isn't how declarators normally work; normally they have spiral precedence!
structs and unions were much more limited
- you couldn't assign to a struct/union lvalue (just like with arrays)
- functions couldn't take in or return structs or unions
- the initializer for a struct declaration had to be braced (compound)
- however, compound initializers couldn't be used for
auto(stack-allocated) structs - compound initializers also couldn't be used for unions (so unions and stack-allocated structs couldn't be wholly initialised or assigned to at all; you had to assign their fields separately)
what happens when control reaches the end of main?
so, in c99+, if control reaches the end of main, it implicitly returns 0 (as a special case). this notably wasn't the case in c89, where main wasn't special cased in this regard (at least not in the standard), so the return value was undefined. however, the examples in the k&r book never have a return statement at the end of the main function, so it's implied that they exit successfully even without the explicit return.
so, to summarize: falling off the end of main was (supposedly) well-defined pre-standardization, made UB in the standard, and then 10 years later the special case was re-added. how fun
(in fairness, it's probably not fair to call it "well-defined" pre-standardization; there might've been implementation variance. and many implementations continued to allow omitting the explicit return from main even when the standard left the behavior undefined)
compound assignment operators weren't single tokens
this one really surprised me. += was lexed as two tokens: + and =. this is especially interesting because the legacy compound assignment tokens where the = goes first (like =+) were still supported, and these were single tokens, to ensure that stuff like x = -1 worked as intended.
oh, uh, this is (relatively) common knowledge, but in case you didn't know, yeah, compound assignment operators originally put the = first. this was changed because it made stuff like x=-1 ambiguous (or rather, it could do something you weren't expecting). it was considered obsolete even back when the book was published in 1978, hence why they didn't make it into the standard.
string concatenation wasn't a thing
for whatever reason this surprised me? but it kinda makes sense i guess. it does make certain macros much harder (if not impossible) to write though.
speaking of which, the # and ## preprocessing operators also weren't implemented; those are an ansi invention. in k&r c, there's no way to stringize or paste in macro definitions. but in general, the pre-standard preprocessor was the wild west; every implementation did something a bit different. many implementations expanded macros within string literals, so stringizing could be done by just surrounding a macro expansion in quotes.
no predefined macros
macros like __DATE__, __FILE__, __LINE__, __TIME__, and (obviously) __STDC__ didn't exist.
speaking of which, there also technically weren't any reserved identifiers besides keywords. that is, it wasn't documented that identifiers beginning with __ (or similar) were reserved. which makes sense; that's the kind of stuff you get from a language with no standard, but it's worth noting nonetheless
missing preprocessor directives
there was no #pragma directive, which like, yeah that kinda makes sense i guess. there also wasn't an #error directive, but, like, since it was an invalid directive it would cause an error, so it was technically supported by omission :P
more interesting to me is that there was no #elif directive. there was also no defined operator: the only way to check if a macro was defined was with #ifdef or #ifndef. i used to assume these directives were always designed as shorthands for #if defined, but i guess it's backwards compatibility all the way down!
another mistake in the k&r book
oh yeah, there's another one lol: the grammar for #line is written as follows:
For the benefit of other preprocessors which generate C programs, a line of the formcauses the compiler to believe, for purposes of error diagnostics, that the next line number is given by the constant and the current input file is named by the identifier. If the identifier is absent the remembered file name does not change.# line constant identifier
first of all, the prose suggests that the identifier should be optional, but the grammar specifies it as mandatory. but furthermore, the so-called-"identifier" shouldn't be an identifier at all! it should be a string literal.
lightning round
i don't feel like writing a full section for all of these, so here:
floats implicitly promoted todoublein all arithmetic operations- the middle expression in a conditional (ternary
?:) expression couldn't be a comma expression - no unary
+operator - no wide (
L-prefixed) character constants or string literals (nowchar_tat all, actually. which like yeah that makes sense i guess) - enums weren't a thing, at all
thanks for reading bye
i probably missed some stuff but that's ok