| Home | Classes | Papers | Essays | Short Fiction | About |

CS211: Types

Table of Contents

Introduction

In a previous lecture, I wrote that data is an essential "information abstraction" that falls under the category of "computational necessity". This idea was further developed when we looked at how data is stored in a machine, both as constant values and as variables. Now, we'll explore how different kinds of data are interpreted by the C programming language and the rules that govern them.

Type System

We now know that all data in a computer is stored as a collection of binary digits (bits). We also know that in C, if I want to store numeric information I can store it in something called an int variable. I could declare one such variable using the statement int x = 65, which says that x is a symbolic name for a location in memory holding the bits 1000001. But we also know that 1000001 can be interpreted as the letter 'A' in ASCII. What determines how those bits are interpreted?

In programming languages, one way of managing the interpretation of data is through the construction of a type system. In Benjamin Pierce's seminal book, Types and Programming Languages, he provides a possible definition, "a type system is a tractable syntactic method for proving the absence of certain program behaviors by classifying phrases according to the kinds of values they compute." This is a highly technical and accurate, albeit a bit bureaucratic, definition of a type system. I can give a bit more of a looser, intuitive definition,

  • A type system provides the rules for different kinds of data.

This means that type systems lay out all of the ways that data can interact during a program's execution.

Nearly every high-level programming language in use today1 has a type system. Type systems provide a variety of benefits to programmers:

  • Error checking
    • Converting a string into a number before taking the square root
  • Maintenance
    • Update the definition of a data type and its updated everywhere
  • Enforces contracts
    • Guarantees behaviors of two variables
  • Documentation
    • Easier to read code when types are involved
  • Language safety
    • A safe language is one that protects its own abstractions

The C programming language has a type system and it is important you understand how it works.

C is Statically Typed

Firstly, C is statically typed. This means that every single variable and function within a program must have a type assigned to it at compile-time. Here is an example of a small C program:

1: int main(){
2:   int i = 10;
3:   float f = 10.5;
4:   const char * s = "Hello, World!";
5: }

In order for the above program to compile, variables i, f, and s must all have types associated with them. i is declared with the type int, f with the type float, and s with the type const char *. Here's what happens if I remove the int from line 2 in Listing 1:

josephraskind@stargazer:/tmp/types$ gcc types.c 
types.c: In function ‘main’:
types.c:2:3: error: ‘i’ undeclared (first use in this function)
    2 |   i = 10;
      |   ^
types.c:2:3: note: each undeclared identifier is reported only once for each function it appears in

We can see in the error message from Listing 2 that something went wrong in the compilation process. The error message, however, looks a bit strange. It says that i must have been undeclared. Again, this is because all variables in C must be declared with a type—any reference to a variable without a type identifier behind it immediately tells the compiler that the function is not being declared, but is being used.

The compiler does not just check to see that every variable/function has a type, but it also checks to make sure that variables/functions follow the rules of their declared types. For example if I alter Listing 2 to:

1: int main(){
2:   int i = 10;
3:   float f = "Hello, World!";
4:   const char * s = "Hello, World!";
5: }

When I try to compile the code I get the following message:

josephraskind@stargazer:/tmp/types$ gcc types.c 
types.c: In function ‘main’:
types.c:3:13: error: incompatible types when initializing type ‘float’ using type ‘char *’
    3 |   float f = "Hello, World!";
      |             ^~~~~~~~~~~~~~~

This happens because the type assigned to the expression on the right side of the = is not the same as the type assigned to the variable on the left side (and cannot be implicitly converted).

C is Weakly Typed

Although C is statically typed, it's often not considered strongly typed. This is because it is very easy to "break" the type system. For example if I slightly change the sort of alteration performed in Listing 3, to the following:

1: int main(){
2:   int i = "Hello, World!";
3:   float f = 10.5;
4:   const char * s = "Hello, World!";
5: }

Then I compile it:

josephraskind@stargazer:/tmp/types$ gcc types.c 
types.c: In function ‘main’:
types.c:2:11: warning: initialization of ‘int’ from ‘char *’ makes integer from pointer without a cast [-Wint-conversion]
    2 |   int i = "Hello, World!";
      |           ^~~~~~~~~~~~~~~

I dont get an error?

Technically, the above does not "break" the type system, but it does break reasonable expectations from the programmers perspective. It seems nonsensical to be able to assign an integer to the string constant "Hello, World!". In reality, that's not exactly what's happening…

C Types

We will now go through, almost, all of the different types in C. Starting with the basic types.

Basic Types

C defines four basic types:

  1. char
  2. int
  3. float
  4. double

char

According to the ISO/IEC 9899:2024 Standard, "An object declared as type char is large enough to store any member of the basic execution character set". It also states that "the representation of each member of the source and execution basic character sets shall fit in a byte". This means that a char must be one byte large.

This does not mean that a char must be 8 bits in size! As a "byte" is defined in the standard as an "addressable unit of data storage large enough to hold any member of the basic character set of the execution environment". In x86-64 systems, it so happens that a char is actually one byte large (8 bits).

int

We went on a long investigation of ints in the data lecture where we learned that an int represents whole numbers between a range defined by the given machine. In x86-64 systems an int is 4 bytes long.

float

Similarly to ints, we looked at floats in the data lecture where we learned that a float is an IEEE 754 32-bit rational number.

double

Doubles follow the same standard as floats, except that they are 64-bit instead of 32-bit.

Signed/Unsigned Types

All integer types (char, int) can be explicitly assigned as "signed" or "unsigned" numbers. By default both types are considered signed. Here is an example:

1: #include <stdio.h>
2: int main() {
3:     int s_i = 0xFFFFFFFF;
4:     unsigned int u_i = 0xFFFFFFFF;
5:     printf("signed   / 2: %d\n", s_i / 2);
6:     printf("unsigned / 2: %d\n", u_i / 2);
7: }

If I compile and run the above program I get:

josephraskind@stargazer:/tmp/types$ gcc signs.c -o signs
josephraskind@stargazer:/tmp/types$ ./signs 
signed   / 2: 0
unsigned / 2: 2147483647

The signage meaningfully changes computations during the program's execution.

Type Conversion

As shown in Listing 6, sometimes mixing and matching types is legal in the language despite intuition pointing the opposite direction. This sort of type fluidity is called type conversion. One data type is converted to another based on a set of rules by the language specification. There are two kinds of type conversion: explicit and implicit.

Explicit Conversion

Explicit type conversion occurs when the programmer uses direct syntax to indicate the change from one type to another. Here is an example:

1: int main(){
2:   int i = (int) 10.5;
3: }

What we see on Line 2 is an explicit type conversion from the constant 10.5's double type to an int. Placing the name of the desired type in parentheses prior to the expression explicitly tells the compiler to convert the double to an int—this is called a "cast". In Line 2 we cast the double 10.5 to the int 10.

Implicit Conversion

Implicit type conversion occurs in situations where a type issue arises, but can be resolved with the compiler's intervention between compatible types. An example:

1: int main(){
2:   int i = 10.5;
3: }

Listing 10 works exactly the same as Listing lst:explicit even though there is no explicit cast. The compiler recognizes that there is a type compatibility between floats and ints as they are both numeric types (arithmetic types in the spec).

Conversion Rules

As we saw above, the C programming language allows for conversions, both implicit and explicit, between data types. In order for this to be deterministic the language specification details exactly how certain conversions are meant to be achieved/implemented by the compiler. You are not responsible for memorizing all of the edge cases provided by the language specification for handling type conversion, but you should be aware that it can happen. Some rules you should be aware of:

  • When converting from real values to integer values the fractional portion is always discarded
  • When a larger data type is converted to a smaller data type (i.e., int to char), the least significant bits are retained.
  • "Narrower" types will be automatically converted to "wider" types
    • float + intfloat + float

Type Qualifiers

C also provides the ability for programmers to assign "extra" rules to a variable by providing type qualifiers. As of C11 there are four type qualifiers: const, volatile, restrict, and _Atomic. In this course, we will only concern ourselves with const. The const qualifier turns the variable into a "constant", meaning once it has been assigned a value, that value cannot be changed. For example:

1: int main(){
2:   const int i;
3:   i = 10; // No problem
4:   i = 20; // Problem!
5: }

The above code will not compile because the integer i cannot be assigned a second value. You may think that it's odd that C provides the ability to write constant values. After all, why not just write 10 for ever instance of i? The reason is that sometimes you want to change constants when testing your code. For example, if I'm testing to see how many students pass CS211 it's easier for me to change a constant variable const PASS_SCORE than it is for me to individually change it throughout my entire source code file. This is doubly true if the constant is propagated throughout dozens of files.

Exercises

  • The lecture states that narrower types are automatically converted to wider types in mixed arithmetic expressions. Write a C program that divides two int variables where the result has a fractional component (e.g. 7 / 3), stores it in a float, and prints it. Does the fractional part appear? Now try storing the result of (float) 7 / 3 in a float and print again. Explain the difference in terms of implicit and explicit conversion.
  • Write a C program that declares a const int representing the passing grade for this course (60). Attempt to change it after declaration and record the compiler error. Then remove the const qualifier and confirm it compiles. Why might using const be preferable to just writing the number 60 throughout your code?
  • Write a C program that explicitly casts each of the following and prints the result: (int) 3.9, (int) -3.9, (char) 65, (float) 7 / 2, (float) (7 / 2). Before running, predict each result. Explain any surprises.
  • The lecture states that when converting from a larger type to a smaller type, the least significant bits are retained. Write a program that assigns int i = 256 to a char c via explicit cast and prints both. Explain the result in terms of bit representation.
  • sizeof is an operator that will report the size of a given type. Use sizeof to print the size in bytes of a char, int, float, and double on your machine. Do they match what the lecture states?
  • Listing lst:surprisingly_valid_assignment assigns a string constant to an int and produces a warning rather than an error. Run this program and print the value of i with printf("%d\n", i). What value do you get? Based on what you know about how variables are stored in memory, reason where that value might come from. (We'll come back to this when we discuss pointers.)
  • Write a C program that demonstrates implicit conversion in an arithmetic expression: declare an int and a float, add them together, and store the result in both an int and a float variable. Print all four variables. What do you observe about the results?
  • The lecture mentions that C is considered weakly typed despite being statically typed. In your own words, explain the difference between static typing and strong typing using examples from the lecture.
  • Using the limits.h header, print INT_MAX, INT_MIN, CHAR_MAX, and CHAR_MIN. Verify that the ranges match what you would expect given the bit sizes stated in the lecture.
  • Write a C program that stores 0xFFFFFFFF in both a signed int and an unsigned int and performs the following operations on each: addition of 1, division by 2, and multiplication by 2. Print all results with the correct format specifier ("%d" for signed integers, "%u" for unsigned integers). For each operation, explain why the signed and unsigned results differ.

Footnotes:

1

I'm writing this on 7/7/26. Exceptions I'm aware of are Brainfuck, which is intentionally esoteric, and BCPL which was the progenitor to C—neither of which are seriously used.

Contact: [email protected] | rss feed | Compiled with org-mode | Licensed under CC BY-NC-SA 4.0