Chapter 5: Exploring Operators and Expressions

So far, we have seen how values are stored to and from variables. Simply storing and retrieving values to and from variables, while important, is only a small part of handling values. What is far more important is the ability to manipulate values in useful ways, which corresponds to the ways we manipulate real-world values, such as adding up our restaurant bill or calculating how much further we have to go to get to grandma's house and how much longer that might take.

The kinds of manipulations that are reasonable to perform on one or more values depend entirely on what kinds of values they are, that is, their data types. What makes sense for one data type may not make sense for another. In this chapter, we will explore the myriad ways that values can be manipulated.

The following topics will be covered in this chapter:

  • Understanding expressions and operations
  • Exploring operations on numbers and understanding the special considerations regarding numbers
  • Understanding how to convert values from one type to another – type conversion and casting
  • Exploring character operations
  • Exploring various ways to compare values
  • Writing a program to print out truth tables
  • Examining a snippet that performs simple bitwise operations
  • Exploring the conditional operator
  • Examining the sequence operator
  • Exploring compound assignment operators
  • Examining multiple assignment statements
  • Exploring increment and decrement operators
  • Writing a program to illustrate grouping
  • Understanding operator precedence and why to avoid relying upon it (there is an easier way)

Technical requirements

As detailed in the Technical requirements section of Chapter 1, Running Hello, World!, continue to use the tools you have chosen.

The source code for this chapter can be found at https://github.com/PacktPublishing/Learn-C-Programming-Second-Edition/tree/main/Chapter05.

Understanding expressions and operations

What are expressions? Well, in simple terms, an expression is a way of computing a value. We've seen how to do this with functions and return values. We will now turn to C's basic set of arithmetic operators for addition, subtraction, multiplication, and division, which are common in most programming languages. C adds to this a large number of operations that includes incrementation/decrementation, relational operators, logical operators, and bitwise manipulation. C further extends assignment operations in useful ways. Finally, C includes some unusual operators that are not commonly found in other languages, such as conditional and sequence operators.

The expressions we will explore in this chapter consist of one or more values as variables or constants combined with the help of operators. Expressions can be complete statements; however, just as often, expressions are components of complex statements. Operators work on one or more expressions, where an expression can be simple or complex, as shown in the following examples:

  • 5 is a literal expression that evaluates to the value of 5.
  • 5 + 8 is an arithmetic expression of two simple expressions (literal constants), which, with the addition operator, evaluates to 13.
  • A more complex expression, 5 + 8 - 10, is really two binary arithmetical operations where 5 and 8 are first evaluated to produce an intermediate result, and then 10 is subtracted from it.
  • 5; is an expression statement that evaluates to 5 and then moves on to the next statement. A more useful version of this would be aValue = 5;, which is really two expressions – the evaluation of 5 and then the assignment of that value to the aValue variable.

Each value of an expression can be one of the following:

  • A literal constant
  • A variable or constant variable
  • The returned value from a function call

An example expression using all of these would be as follows:

5 + aValue + feetToInches( 3.5 )

Consider the following statement:

aLength = 5 + aValue + feetToInches( 3.5 );

The preceding statement is, in reality, five distinct operations in one statement:

  • The retrieval of the value from the aValue variable
  • The function call to feetToInches()
  • The addition of the literal 5 value, with the value of aValue giving an intermediate result
  • The addition of the function call result to the intermediate result
  • The assignment of the intermediate result to the aLength variable

An alternative way in which to calculate the same result can involve three simple statements instead of one complex statement, as follows:

aLength = 5;
aLength = aLength + aValue;
aLength = aLength + feetToInches( 3.5 );

In this way, the different values are evaluated and added to the aLength variable. Instead of one assignment, there are three. Instead of a temporary intermediate result, the results of the additions are accumulated explicitly in the aLength variable as a result of each statement.

A simple program, calcLength.c, that applies each method of using simple and complex expressions is as follows:

#include <stdio.h>
int feetToInches( double feet ) {
  int inches = feet * 12;
  return inches;
}
int main( void )  {
  int aValue   = 8; 
  int aLength  = 0;
  aLength = 5 + aValue + feetToInches( 3.5 );
  printf( "Calculated length = %d
" , aLength );
  aLength = 5;
  aLength = aLength + aValue;
  aLength = aLength + feetToInches( 3.5 );
  printf( "Calculated length = %d
" , aLength );
  return 0;
}

This program calculates aLength from the sum of a literal value, which, in this case, is 5, an aValue variable, and the result of the feetToInches() function. It then prints out the result to the terminal. The program itself is not very useful – we have no idea what we are calculating, nor do we know why the values that were chosen are significant. For now, however, let's just focus on the expression of aLength. This is a value calculated by adding three other values together in one complex statement and again with three simple statements.

Now, create the calcLength.c file, type in the program, and then save the file. Compile the program and run it. You should see the following output:

Figure 5.1 – Screenshot of calcLength.c output

Figure 5.1 – Screenshot of calcLength.c output

As you can see, the single statement for calculating aLength is far less verbose than using the three statements to do so. However, neither approach is incorrect, nor is one method always preferred over the other. When calculations are relatively simple, the first method might be clearer and more appropriate. On the other hand, when calculations become much more complex, the second method might make each step of the computation clearer and more appropriate. Choosing which method to employ can be a challenge, as you are trying to find a balance between brevity and clarity. Whenever you have to choose one over the other, always choose clarity.

Introducing operations on numbers

The basic arithmetic operators on numbers are addition (+), subtraction (-), multiplication (*), and division (/). They are binary operations, as they work on one pair of expressions at a time. They work largely as you would expect them to for both integers (whole numbers) and real numbers (numbers with fractions). Division of two real numbers results in a real number. Division of two whole numbers results in a whole number; any possible fraction part is discarded from the result. There is also the modulo operator (%) that will provide the integer remainder of the division of two integers.

For example, 12.0 / 5.0 (two real numbers) evaluates to 2.5, whereas 12 / 5 (two integers) evaluates to 2. If we were working only with integers and we needed the remainder of 12 / 5, we would use the remainder operator, %. Thus, 12 % 5 evaluates to another integer, 2.

Many languages have an exponent operator. C does not. To raise an expression to a power, standard C provides the pow( x , y) library function. The prototype for this function is double pow( double x , double y );, which raises the value of x to the power of value y and yields double as its result. To use this function in your program, include the <math.h> header file wherever the prototype is declared.

Let's create a new file, convertTemperature.c, where we will create two useful functions, celsiusToFahrenheit() and fahrenheitToCelsius(), as follows:

  // Given a Celsius temperature, convert it to Fahrenheit.
double celsiusToFahrenheit( double degreesC )  { 
  double degreesF = (degreesC * 9.0 / 5.0 ) + 32;
  return degreesF;
}
  // Given a Fahrenheit temperature, convert it to Celsius.
double fahrenheitToCelsius( double degreesF )  {
  double degreesC = (degreesF - 32 ) * 5.0 / 9.0 ;
  return degreesC;
}

Each function takes a double value type as an input parameter and returns the converted value as double.

There are a couple of things to take note of regarding these functions.

First, we could have made them single-line functions by combining the two statements in each function body into one, as follows:

  return (degreesC * 9.0  / 5.0 ) + 32;

Here is another example:

  return (degreesF - 32 ) * 5.0 / 9.0;

Many programmers would do this. However, as your programming skills advance, this actually becomes a needless practice – it doesn't really save much of anything, and it makes debugging with a debugger (an advanced topic) far more difficult and time-consuming.

Many programmers are further tempted to turn these functions into #define macro symbols (another advanced topic), as follows:

#define celsiusToFahrenheit( x )  (((x) * 9.0  / 5.0 ) + 32)
#define fahrenheitToCelsius( x )  (((x) - 32 ) * 5.0 / 9.0)

Using macros can be dangerous because we could lose type information or the operations in such a macro might not be appropriate for the type of value given. Furthermore, we would have to be extremely careful about how we craft such preprocessor symbols to avoid unexpected results. Note that in this example, there is an extra set of () around each x; this is to avoid misinterpretation of any value of x, for instance, x being given as aValue + 5. For the few characters of typing saved, neither the single-line complex return statement nor the macro definitions are worth the potential hassle.

Second, we use the grouping operator, ( and ), to ensure our calculations are performed in the correct order. For now, just know that anything inside ( and ) is evaluated first. We will discuss this in more detail later on in this chapter.

A Note About the Use of the Preprocessor

There are many temptations to use the preprocessor as much as possible – that is, to overuse the preprocessor. There lies the road to perdition! Instead, if you find yourself being pulled by such temptations for whatever reason, take a look at the Using preprocessor effectively section from Chapter 24, Working With Multi-File Programs. We will revisit the above macros and why the additional parentheses are needed in Chapter 25, Understanding Scope.

We can now finish the program that uses the two functions we created. Add the following to convertTemperature.c before the two function definitions:

#include <stdio.h>
double celsiusToFahrenheit( double degreesC );
double fahrenheitToCelsius( double degreesF );
int main( void )  {
  int c = 0;
  int f = 32;
  printf( "%4d Celsius    is %4d Fahrenheit
" ,
           c , (int)celsiusToFahrenheit( c ) );
  printf( "%4d Fahrenheit is %4d Celsius

"  , 
           f , (int)fahrenheitToCelsius( f ) );
  c = 100;
  f = 212;
  printf( "%4d Celsius    is %4d Fahrenheit
" ,
           c , (int)celsiusToFahrenheit( c ) );
  printf( "%4d Fahrenheit is %4d Celsius

"  ,
           f , (int)fahrenheitToCelsius( f ) );
  c = f = 50;
  printf( "%4d Celsius    is %4d Fahrenheit
" , 
          c , (int)celsiusToFahrenheit( c ) );
  printf( "%4d Fahrenheit is %4d Celsius

"  ,
          f , (int)fahrenheitToCelsius( f ) ); 
  return 0;
}
// function definitions here...

With all of the parts in place, save the file, compile it, and then run it. You should see the following output:

Figure 5.2 – Screenshot of convertTemperature.c output

Figure 5.2 – Screenshot of convertTemperature.c output

Note how we exercised our functions with known values to verify that they are correct. First, freezing values for each scale were converted to the other scale. Then, boiling values for each scale were converted to the other scale. We then tried a simple middle value to see the results.

You may be wondering how to perform the conversions if we pass values other than doubles into the function. You might even be inclined to create several functions whose only difference is the type of variables. Take a look at the convertTemperature_NoNo.c program. Try to compile it for yourself and see what kind of errors you get. You will find that, in C, we cannot overload function names, that is, use the same function name but with different parameter and return types. This is possible with other languages but not with C.

In C, each function is simply called by its name; nothing else is used to differentiate one function call from another. A function having one name with a given type and two parameters cannot be distinguished from another function of the same name with a different type and no parameter.

We could try to embed the type names into the function names, such as fahrenheithDblToCelsiusInt() and celsiusIntToCelsiusDbl(), but this would be extremely tedious to declare and define for all data types. Additionally, it would be extremely difficult to use in our programs. Compiler errors, due to mistyping the function names and even mistyping the calling parameters, would be highly likely and time-consuming to work through in a large or complicated program. So, how does C deal with this?

Don't fret! We will consider this very topic in the next section, along with a complete program showing how to use these functions.

Considering the special issues resulting from operations on numbers

When performing calculations with any numbers, the possible ranges of both the inputs and outputs must be considered. For each type of number, there is a limit to both its maximum values and minimum values. These are defined on each system in the C standard library for that system in the header file, limits.h.

As the programmer, you must ensure that the results of any arithmetic operation are within the limits of the range for the data type specified or your program must check for valid inputs, thereby preventing invalid outputs. There are four types of invalid outputs – Not a Number (NaN), Infinity (Inf), underflow, and overflow.

Understanding NaN and Inf

NaN and Inf outputs can occur as a result of expressions upon floating-point numbers. There are two NaN types, one that is quiet and allows computations to continue, and one that signals an exception. What happens when such an exception occurs depends upon the C implementation; it may continue, halt the program, or perform some other behavior.

A NaN result occurs when the result of an operation is an undefined or unrepresentable number.

An Inf result occurs when the result of an operation is an inexpressibly large number or infinity.

Consider this equation – y = 1 / x. What is the value of y as x approaches zero from the positive side? It will become an infinitely large positive value. This results in a positive Inf result. What then is the value of y as x approaches zero from the negative side? It will become an infinitely large negative value and result in a negative Inf output.

A NaN result also occurs when the data types are real but the result of the computation is a complex number, for example, the square root of a negative number or the logarithm of a negative number. A NaN result can also occur where discontinuities appear in inverse trigonometric functions.

Consider the following program, Inf_Nan.c:

#include <stdio.h>
#include <math.h>
int main( void ) {
  double y = 1 / 0.0;
  printf( " 1 / 0.0 = %f
" , y );
  
  y = -1/0.0;
  printf( "-1 / 0.0 = %f
" , y );
  
  y = log( 0 );
  printf( "log( 0 ) = %f
" , y );
  
  y = sqrt( -1 );
  printf( "Square root of -1 = %f
" , y );
  return 0;
}

This program will generate a positive Inf output and two negative Inf outputs, as well as a NaN. We need to include <math.h>, a standard library file that will enable us to call the log() and sqrt() functions.

When you enter, save, compile, and run this program, you will see the following output:

Figure 5.3 – Screenshot of Inf_Nan.c output

Figure 5.3 – Screenshot of Inf_Nan.c output

Understanding underflow

Underflow occurs when the result of an arithmetic operation is smaller than the smallest value that can be represented by the type specified.

For integers, this would mean either a number less than 0 if the integer is unsigned or a very large negative number if the integer is signed (for instance, -2 is smaller than -1).

For real numbers, this would be a number very, very close to zero (that is, an extremely small fractional part), resulting from the division of an extremely small number by a very large number, or the multiplication of two extremely small numbers.

Understanding overflow

Overflow occurs when the result of an arithmetic operation is greater than the greatest value that can be represented for the type specified.

This would occur with both the addition and multiplication of two extremely large numbers or the division of a very large number by an extremely small number.

In the following program, Overflow_Underflow.c, we can see how overflow and underflow can occur:

#include <stdio.h>
#include <inttypes.h>
#include <float.h>
int main( void )  {
  uint16_t biggest  = UINT16_MAX;
  uint16_t overflow = biggest + 1;
  printf( "Biggest=%d and overflow=%d
" ,
           biggest , overflow );
  
  int16_t  smallest  = INT16_MIN;
  int16_t  underflow = smallest - 1;
  printf( "Biggest=%d and underflow=%d
" ,
          smallest , underflow );
  
  float    fBiggest  = FLT_MAX;
  float    fOverflow = fBiggest * 2;
  printf( "FloatBiggest = %g FloatOverflow (FloatBiggest * 2) = %g
" , fBiggest , fOverflow );
  
  float    fSmallest  = FLT_MIN;
  float    fUnderflow = fBiggest / fSmallest;
  printf( "FloatSmallest = %g FloatUnderflow (FloatBiggest/FloatSmallest) = %g
", fSmallest , fUnderflow );
  return 0;
}

We need to include inttypes.h and float.h, both C standard library headers, to declare variables of known size regardless of your system or C implementation. uint16_t is an unsigned 16-bit integer and int16_t is a signed 16-bit integer. Overflow occurs when we add 1 to the largest possible 16-bit unsigned integer. Underflow occurs when we subtract 1 from the smallest 16-bit signed integer; note how the value became positive. We then demonstrate overflow and underflow with floats (floats and doubles already have an implementation-independent size).

Note that these are not the only expressions that may cause overflow and underflow.

If you create, save, compile, and run Overflow_Underflow.c, you will see the following output:

Figure 5.4 – Screenshot of Overflow_Underflow.c output

Figure 5.4 – Screenshot of Overflow_Underflow.c output

For integers, we can see that when we add 1 to the largest 16-bit unsigned integer, we don't get 65536, but instead get 0 because 65537 cannot be represented in 16 bits. Likewise, when we subtract 1 from the smallest 16-bit signed integer, we don't get –32769 but instead get 32767 (the sign bit becomes 0 instead of 1 and we get a very large number as a result).

Likewise, for floating-point numbers, when we multiply the largest float by 2, we get an overflow value that becomes inf. When we divide the largest float by the smallest float, we should get a very large number. But it cannot be represented in a float, so we get an inf result.

When we reach these overflow/underflow conditions, unpredictable results can happen. One way to mitigate this problem is to assign the results of one size computation (two 16-bit ints or two floats) to a larger data type (32-bit int or double, respectively).

Considering precision

When performing calculations with real numbers, we need to be concerned with the exponential difference between the two of them. When one exponent is very large (positive) and the other very small (negative), we will likely produce either insignificant results or a NaN output. This happens when the calculated result will either represent an insignificant change to the largest exponent value via addition and subtraction – therefore, precision will be lost – or be outside the possible range of values via multiplication and division – therefore, a NaN or Inf output will result. Adding a very, very small value to a very, very large value may not give any significant change in the resulting value – again, precision in the result will be lost.

It is only when the exponents are relatively close, and the calculated result is within a reasonable range, that we can be sure of the accuracy of our result.

With 64-bit integer values and up to 128-bit real values, the ranges of values are vast, even beyond ordinary human conception. More often, however, our programs will use data types that do not provide the extreme limits of possible values. In those cases, the results of operations should always be given some consideration.

Exploring type conversion

C provides mechanisms that allow you to convert one type of value into another type of the same value. When there is no loss of precision – in other words, when the conversion of values results in the same value – C operates without complaining. However, when there is a possible loss of precision, or if the resulting value is not identical to the original value, then the C compiler does not provide any such warning.

Understanding implicit type conversion and values

So, what happens when expressions are performed with operands of different types, for example, the multiplication of an int with a float, or the subtraction of a double from a short?

To answer that, let's revisit our sizes_ranges2.c program from Chapter 3, Working with Basic Data Types. There, we saw how different data types took different numbers of bytes; some are 1 byte, some are 2 bytes, some are 4 bytes, and most values are 8 bytes.

When C encounters an expression of mixed types, it first performs an implicit conversion of the smallest data type (in bytes) to match the number of bytes in the largest data type size (in bytes). The conversion occurs in such a way that the value with the narrow range would be converted into the other with a wider range of values.

Consider the following calculation:

int    feet  = 11;
double yards = 0.0;
yards = feet / 3;

In this calculation, both the feet variables and 3 are integer values. The resulting value of the expression is an integer. However, the integer result is implicitly converted into a double upon assignment. The feet integer divided by the 3 integer is not 3.2 but 3 because an integer cannot have a fractional part. The value of yards is then 3.0 (3 converted to a double is 3.0), which is clearly incorrect. This error can be corrected by either type casting feet or by using a decimal literal, as follows:

yards = (double)feet / 3; // type casting
yards = feet / 3.0;       // forcing proper type with a 
                          // float literal

The first statement casts feet to double and then performs the division; the result is double. The second statement specifies a decimal literal, which is interpreted as double and performs the division; the result is  double. In both statements, because the result is double, there is no conversion needed upon assignment to yards; that now has the correct value of 3.66667.

Implicit conversion also occurs when the type of the actual parameter value is different from the defined parameter type.

A simple conversion is when a smaller type is converted into a larger type. This would include short integers being converted into long integers or float being converted into double.

Consider the following function declaration and the statement that calls it:

long int add( long int i1 , long int i2 )  {
  return i1 + i2;
}
int main( void )  {
  unsigned char b1 = 254;
  unsigned char b2 = 253;
  long int r1;
  r1 = add( b1 , b2 );
  printf( "%d + %d = %ld
" , b1 , b2 , r1 );
  return 0;
}

The add() function has two parameters, which are both long integers of 8 bytes each. Later, add() is called with two variables that are 1 byte each. The single-byte values of 254 and 253 are implicitly converted into the wider long int when they are copied into the function parameters. The result of the addition is 507, which is correct.

The output of this program, bytelong.c, is as follows:

Figure 5.5 – Screenshot of bytelong.c output

Figure 5.5 – Screenshot of bytelong.c output

Most integers can easily be converted into a float or double. In the multiplication of an int (4 bytes) with a float (4 bytes), an implicit conversion will happen – int will be converted into a float. The implicit result of the expression would be a float.

In the subtraction of a double (8 bytes) from a short (2 bytes), one conversion happens on the short –   it is converted into a double (8 bytes). The implicit result of the expression would be a double. Depending on what happens next in the compound expression, the implicit result may be further converted. If the next operation involves an explicit type, then the implicit result will be converted into that type, if necessary. Otherwise, it may again be converted into the widest possible implicit type for the next operation.

However, when we assign an implicit or explicit result type to a narrower type in the assignment, a loss of precision is likely. For integers, loss involves the high-order bits (or the bits with the largest binary values). A value of 32,000,000 assigned to a char (signed or unsigned) will always be 0. For real numbers, truncation and rounding occur. Conversion from a double to a float will cause rounding or truncation, depending upon the compiler implementation. Conversion from a float to an int will cause the fractional part of the value to be lost.

Consider the following statements:

long int add( long int i1 , long int i2 )  {
  return i1 + i2;
}
int main( void )  {
  unsigned char b1 = 254;
  unsigned char b2 = 253;
  unsigned char r1;
  r1 = add( b1 , b2 );
  printf( "%d + %d = %d
" , b1 , b2 , r1 );
  return 0;
}

The only change in these statements is the type of the r1 variable; it is now a single byte. So, while b1 and b2 are widened to long int, add() returns a long int, but this 8-byte return value must be truncated into a single byte. The value assigned to r1 is incorrect; it becomes 251. Note that we also changed the format specifier from %ld to just %d.

When we create, edit, save, compile, and run this program, longbyte.c, we will see the following output:

Figure 5.6 – Screenshot of longbyte.c output

Figure 5.6 – Screenshot of longbyte.c output

As you can see, 254 + 253 is clearly not 251. This shows the result of incorrect results from having to narrow a value from a long int to a single byte (unsigned char).

When performing complicated expressions that require a high degree of precision in the result, it is always best to perform the calculations in the widest possible data type and only at the very end convert the result into a narrower data type.

Let's test this with a simple program. In truncRounding.c, we have two functions – one that takes a double as a parameter and prints it, and one that takes a long int as a parameter and prints it. The following program illustrates implicit type conversion, in which the parameter values are assigned to actual values:

#include <stdio.h>
void doubleFunc(  double   dbl );
void longintFunc( long int li );
int main( void )  {
  float floatValue   = 58.73;
  short int intValue = 13;
  
  longintFunc( intValue );
  longintFunc( floatValue ); // possible truncation
  doubleFunc( floatValue );
  doubleFunc( intValue ); 
  return 0;
}
void doublFunc( double dbl )  {
  printf( "doubleFunc %.2f
" , dbl );
}
void longintFunc( long int li )  {
  printf( "longintFunc %ld
" , li );
}

We have not yet explored the ways in which printf() can format values. For now, simply take for granted that %.2f will print a double value with two decimal places, and that %ld will print out a long int. This will be explained fully in Chapter 19, Exploring Formatted Output.

Enter, compile, and run truncRounding.c. You should see the following output:

Figure 5.7 – Screenshot of truncRounding.c output

Figure 5.7 – Screenshot of truncRounding.c output

Note that no rounding occurs when 58.73 is converted into a long int. However, we do lose the fractional part; this is called truncation, where the fractional part of the value is cut off. A short int is properly converted into a double just as a float is properly converted into a double.

Also, note that when you compiled and ran truncRounding.c, no compiler error nor runtime warning was given when the float was converted into a long int, resulting in the loss of precision.

Using explicit type conversion – casting

If we rely on implicit casting, our results may go awry or we may get unexpected results. To avoid this, we can cause an explicit, yet temporary, type change. We do this by casting. When we explicitly cast a variable to another type, its value is temporarily converted into the desired type and then used. The type of the variable and its value does not change.

Any expression can be prefixed by (type) to change its explicit type to the indicated type for the lifetime of the expression. This lifetime is only a single statement. The explicit type is never changed, nor is the value stored in that explicitly typed variable. An example of this is given in the following program, casting.c:

#include <stdio.h>
int main( void ) {
  int numerator   =  33;
  int denominator =   5;
  double result   = 0.0;
    
  result = numerator / denominator; 
  printf( "Truncation: %d / %d = %.2g
" , 
           numerator , denominator , result );
  
  result = (double) numerator / denominator;
  printf( "No truncation: %.2f / %d = %.2f
" , 
          (double)numerator , denominator , result );
  result = numerator / (double)denominator;
  printf( "               %d / %.2f = %.2f
" , 
           numerator , (double)denominator , result );
  return 0;
}

Enter, compile, and run casting .c. You should see the following output:

Figure 5.8 – Screenshot of casting.c output

Figure 5.8 – Screenshot of casting.c output

In casting.c, we can see, in the first division expression, that there is no casting and no implicit conversion. Therefore, the result is an int output and the fractional part is truncated. When the int result is assigned to a double, the fractional part has already been lost. In the second and third division statements, we guarantee that the operation is done on double values by casting either one of them to double. The other value is then implicitly converted to double. The result is a double, so when it is assigned to a double, there is no truncation.

The types of numerators and denominators are not changed permanently but only within the context of the expression where casting occurs.

Introducing operations on characters

The integer value of the letter 'a' is 97. When we treat that value as a char, the 97 value becomes 'a'. Since characters are internally represented as integers, any of the integer operations can be applied to them too. However, only a couple of operations make sense to apply to characters – the additive operators (that is, addition and subtraction). While multiplying and dividing characters are legal, those operations never produce any practical or useful results:

  • char - char yields int. The result represents the distance between characters.
  • char + int yields char. This yields the character that is the specified distance from the original character.
  • char - int yields char. This also yields the character that is the specified distance from the original character.

So, 'a' + 5 is 'f'; that is, 97 + 5 is 102, which as a character is 'f'. 'M' - 10 is 'C'; that is, 77 – 10 is 67, which as a character is 'C'. And finally, 'a' - 'A' is 32; that is, 97 – 65 is 32, the distance between a and A.

Remember that a char is only one unsigned byte, so any addition or subtraction outside of the range of 0..255 will yield unexpected results due to the truncation of high-order bits.

A common use of the addition and subtraction of characters is the conversion of a given ASCII character to uppercase or lowercase. If the character is uppercase, then simply adding 32 to it will give you its lowercase version. If the character is lowercase, then simply subtracting 32 from it will give you its uppercase version. An example of this is given in the following program, convertUpperLower.c:

#include <stdio.h>
int main( void ) {
  char lowerChar = 'b';
  char upperChar = 'M';
  char anUpper = lowerChar - 32;
  char aLower  = upperChar + 32;
  printf( "Lower case '%c' can be changed to upper case '%c'
" , 
           lowerChar , anUpper );
  printf( "Upper case '%c' can be changed to lower case '%c'
" , 
           upperChar , aLower );
  return 0;
}

Given a lowercase 'e', we convert it into uppercase by subtracting 32 from it. Given an uppercase 'S', we convert it into lowercase by adding 32 to it. We will explore characters much more thoroughly in Chapter 15, Working with Strings.

In your editor, create a new file and enter the convertUpperLower.c program. Compile and run it in a terminal window. You should see the following output:

Figure 5.9 – Screenshot of convertUpperLower.c output

Figure 5.9 – Screenshot of convertUpperLower.c output

Another common use of operations on characters is to convert the character of a digit ('0' to '9') into its actual numerical value. The value of '0' is not 0 but some other value that represents that character. To convert a character digit into its numerical value, we simply subtract the '0' character from it. An example of this is given in the following program, convertDigitToInt.c:

int main( void )  {
  char digit5 = '5';
  char digit8 = '8';
  int sumDigits = digit5 + digit8;
  printf( "digit5 + digit8 = '5' + '8' = %d (oh, dear!)
" , 
           sumDigits );
  char value5 = digit5 - '0';   // get the numerical value 
                                // of '5'
  char value8 = digit8 - '0';   // get the numerical value 
                                // of '8'
  sumDigits = value5 + value8;
  printf( "value5 + value8 = 5 + 8 = %d
" ,
           sumDigits );
  return 0;
}

When we simply add characters together, unexpected results are likely to occur. What we really need to do is to convert each digit character into its corresponding numerical value and then add those values. The results of that addition are what we want.

In your editor, create a new file and enter the convertDigitToInt.c program. Compile and run it in a terminal window. You should see the following output:

Figure 5.10 – Screenshot of convertDigitToInt.c output

Figure 5.10 – Screenshot of convertDigitToInt.c output

In order to understand the difference between a character and its value, we will explore characters in much greater depth in Chapter 15, Working with Strings.

Logical operators

A Boolean value is one that has either the value of true or false and no other value. Early versions of C did not have explicit Boolean (true and false) data types. To handle boolean values, C implicitly converts any zero value into the Boolean false value and implicitly converts any non-zero value into the Boolean true value. This implicit conversion comes in handy very often but must be used with care.

However, when we use #include <stdbool.h>, the official bool types and true and false values are available to us. We will explore later how we might choose to define our own Boolean values with enumerations (Chapter 9, Creating and Using Structures) or with custom types (Chapter 11, Working with Arrays).

There are three Boolean operators:

  • ||: The binary logical OR operator
  • &&: The binary logical AND operator
  • ^: The binary logical XOR operator
  • !: The unary logical NOT operator

These are logical operators whose results are always Boolean true (non-zero) or false (exactly zero). They are so named in order to differentiate them from bitwise operators whose results involve a different bit pattern, which we shall learn about shortly.

The first two logical operators evaluate the results of two expressions:

expressionA boolean-operator expressionB

The result of logical AND is TRUE only if both expressionA and expressionB are TRUE; it is FALSE if either or both are FALSE.

The result of logical OR is FALSE only if both expressionA and expressionB are FALSE; it is TRUE if either or both are TRUE.

In this Boolean expression, it would seem that both expressionA and expressionB are both always evaluated. This is not the case; we must consider what is known as short-circuit evaluation or minimal evaluation of Boolean expressions. When there are two or more Boolean expressions, only those expressions are evaluated with results that satisfy the Boolean comparison. Once satisfied, the rest of the expression is ignored.

When the operator is logical AND (&&), and if expressionA evaluates to false, then expressionB is not evaluated. It does not need to be since regardless of what the second result is, the AND condition will still be false. However, when expressionA is true, expressionB must then be evaluated.

When the operator is logical OR (||), if expressionA evaluates to true, then expressionB is not evaluated. It does not need to be since regardless of what the second result is, the OR condition will still be true. However, when expressionA is false, expressionB must then be evaluated.

The unary logical NOT (!) operator is employed, therefore, as !expressionC.

It takes the result of expressionC, implicitly converting it into a Boolean result and evaluating it with its opposite Boolean result. Therefore, !true becomes false, and !false becomes true.

In the logical.c program, three tables are printed to show how the logical operators work. They are known as truth tables. The values are printed as either 1 or 0 decimals, but they are really Boolean values. The first truth table is produced with the printLogicalAND() function, as follows:

void printLogicalAND( bool z, bool o )  {
  bool zero_zero = z && z ;
  bool zero_one  = z && o ;
  bool one_zero  = o && z ;
  bool one_one   = o && o ;
  printf( "AND | %1d | %1d
"     , z , o );
  printf( " %1d | %1d | %1d 
"   , z , zero_zero , 
          zero_one );
  printf( " %1d | %1d | %1d 

" , o , zero_one  , 
          one_one );
}

The next truth table is produced with the printLogicalOR() function, as follows:

void printLogicalOR( bool z, bool o )  {
  bool zero_zero = z || z ;
  bool zero_one  = z || o ;
  bool one_zero  = o || z ;
  bool one_one   = o || o ;
  printf( "OR | %1d | %1d
"      , z , o );
  printf( " %1d | %1d | %1d 
"   , z , zero_zero , 
          zero_one );
  printf( " %1d | %1d | %1d 

" , o , zero_one  , 
           one_one );
}

The next truth table is produced with the printLogicalXOR() function, as follows:

void printLogicalXOR( bool z, bool o )  {
  bool zero_zero = z ^ z ;
  bool zero_one  = z ^ o ;
  bool one_zero  = o ^ z ;
  bool one_one   = o ^ o ;
  
  printf( "XOR | %1d | %1d
"     , z , o );
  printf( "  %1d | %1d | %1d
"   , z, zero_zero , 
         zero_one );
  printf( "  %1d | %1d | %1d

" , o , one_zero , 
         one_one  );
  printf( "
" );
}

Finally, the printLogicalNOT() function prints the NOT truth table, as follows:

void printLogicalNOT( bool z, bool o )  {
  bool not_zero = !z ;
  bool not_one = !o ;
  
  printf( "NOT 
" );
  printf( " %1d | %1d 
"   , z , not_zero );
  printf( " %1d | %1d 

" , o , not_one );
}

Create the logicals.c file and enter the three truth table functions. Then, add the following program code to complete logicals.c:

#include <stdio.h>
#include <stdbool.h>
void printLogicalAND( bool z , bool o );
void printLogicalOR(  bool z , bool o );
void printLogicalXOR( bool z , bool o );
void printLogicalNOT( bool z  , bool o );
int main( void )  {
  bool one = 1;
  bool zero = 0;
  printLogicalAND( zero , one );
  printLogicalOR( zero , one );
  PrintLogicalXOR( zero , one );
  printLogicalNOT( zero , one );
  
  return 0;
}

Save, compile, and run logicals.c. You should see the following output:

Figure 5.11 – Screenshot of logicals.c output

Figure 5.11 – Screenshot of logicals.c output

These are known as truth tables. When you perform the AND, OR, XOR, or NOT operations on a value in the top row and a value in the left column, the intersecting cell is the result. So, 1 AND 1 yields 1, 1 OR 0 yields 1, and NOT 0 yields 1.

Not all operations can be simply expressed as strictly Boolean values. In these cases, there are the relational operators that produce results that are convenient to regard as true and false. Statements such as if and while, as we shall learn, test those results.

Relational operators

Relational operators involve the comparison of the result of one expression with the result of a second expression. They have the same form as the binary logical operators shown previously. Each of them gives a Boolean result. They are as follows:

  • > (greater than operator): true if expressionA is greater than expressionB
  • >= (greater than or equal operator): true if expressionA is greater than or equal to expressionB
  • < (less than operator): true if expressionA is less than expressionB
  • <= (less than or equal operator): true if expressionA is less than or equal to expressionB
  • == (equal operator (note that this is different from the = assignment operator)): true if expressionA is equal to expressionB
  • != (not equal operator): true if expressionA is not equal to expressionB

We will defer exploring these operators in depth until we get to the if, for, and while statements in upcoming chapters.

Bitwise operators

Bitwise operators manipulate bit patterns in useful ways. The bitwise AND (&), OR (|), and XOR (^) operators compare the two operands bit by bit. The bitwise shifting operators shift all of the bits of the first operand left or right. The bitwise complement changes each bit in a bit pattern to its opposite bit value.

Each bit in a bit field (8, 16, or 32 bits) could be used as if it was a switch, or flag, determining whether some feature or characteristic of the program was off (0) or on (1). The main drawback of using bit fields in this manner is that the meaning of the bit positions can only be known by reading the source code, assuming that the proper source code is both available and well commented!

Bitwise operations are less valuable today, not only because memory and CPU registers are cheap and plentiful but because they are now expensive operations computationally. They do, occasionally, find a useful place in some programs but not often.

The bitwise operators are as follows:

  • &: Bitwise AND – for example, 1 if both bits are 1.
  • |: Bitwise OR – for example, 1 if either bit is 1.
  • ^: Bitwise XOR – for example, 1 if either but not both are 1.
  • <<: Bitwise shift left. Each bit is moved over to the left (the larger bit position). It is equivalent to value * 2 – for example, 0010 becomes 0100.
  • >>: Bitwise shift right. Each bit is moved over to the right (the smaller bit position). It is equivalent to value / 2 – for example, 0010 becomes 0001.
  • ~: Bitwise complement. Change each bit to its other – for example, 1 to 0, and 0 to 1.

The following is an example of bitwise operators and flags:

                    /* flag name */   /* bit pattern */ 
const unsigned char lowercase 1;      /* 0000 0001 */ 
const unsigned char bold 2;           /* 0000 0010 */
const unsigned char italic 4;         /* 0000 0100 */
const unsigned char underline 8;      /* 0000 1000 */
unsigned char flags = 0;
flags = flags | bold; /* switch on bold */
flags = flags & ~italic; /* switch off italic; */
if((flags & underline) == underline) ... /* test for underline bit 1/on? */
if( flags & underline ) ... /* test for underline */ 

Instead of using bitwise fields, custom data types called enumerations and more explicit data structures, such as hash tables, are often preferred.

The conditional operator

This is also known as the ternary operator. This operator has three expressions – testExpression, ifTrueExpression, and ifFalseExpression. It looks like this:

testExpression ? ifTrueExpression : ifFalseExpression

In this expression, testExpression is evaluated. If the testExpression result is true, or non-zero, then ifTrueExpression is evaluated and its result becomes the expression result. If the testExpression result is false, or exactly zero, then ifFalseExpression is evaluated and its result becomes the expression result. Either ifTrueExpression or ifFalseExpression is evaluated – never both.

This operator is useful in odd places, such as setting switches, building string values, and printing out various messages. In the following example, we'll use it to add pluralization to a word if it makes sense in the text string:

printf( "Length = %d meter%c
" , len, len == 1 ? '' : 's' );

Or, we can use it to print out whole words:

printf( "Length = %d %s
" , len, len == 1 ? "foot" : "feet" );

The following program uses these statements:

#include <stdio.h>
const double inchesPerFoot = 12.0;
const double inchesPerMeter = 39.67;
void printLength( double meters );
int main( void )  {
  printLength( 0.0 );
  printLength( 1.0 );
  printLength( inchesPerFoot / inchesPerMeter );
  printLength( 2.5 );
  
  return 0;
}
void printLength(  double meters )  {
  double feet = meters * inchesPerMeter / inchesPerFoot;
  printf( "Length = %f meter%c
" , 
          meters, 
          meters == 1.0 ? ' ' : 's'  );
  printf( "Length = %f %s

" , 
          feet, 
          0.99995 < feet && feet < 1.00005 ? "foot" : "feet" );
}

In the preceding program, you might be wondering why the statement for determining "foot" or "feet" has become so much more complex. The reason is that the feet variable is a computed value. Furthermore, because of the precision of the double data type, it is extremely unlikely that any computation will be exactly 1.0000…, especially when division is involved. Therefore, we need to consider values of feet that are reasonably close to zero but might never be exactly zero. For our simple example, four significant digits will suffice.

When you type in the printLength.c program, save it, compile it, and run it, you should see the following output:

Figure 5.12 – Screenshot of printLength.c output

Figure 5.12 – Screenshot of printLength.c output

Be careful, however, not to overuse the ternary operator for anything other than simple value replacements. In the next chapter, we'll explore how more explicit solutions are commonly used for general conditional executions.

The sequence operator

Sometimes, it makes sense to perform a sequence of expressions as though they were a single statement. This would rarely be used or make sense in a normal statement.

We can string multiple expressions together in sequence using the , operator. Each expression is evaluated from left to right in the order they appear. The value of the entire expression is the resultant value of the rightmost expression.

For instance, consider the following:

int x = 0, y = 0, z = 0;  // declare and initialize. 
...
...
x = 3 , y = 4 , z = 5;
...
...
x = 4; y = 3; z = 5;
...
...
x = 5; 
y = 12;
z = 13;

The single line assigning all three variables is perfectly valid. However, in this case, there is little value in doing it. The three variables are either loosely related or not related at all from what we can tell in this snippet. Note that this use of the comma is not a sequence operator but is used to declare a list of variables. Furthermore, commas in function parameter lists are also not sequence operators.

The next line shows the comma as a sequence operator. It makes each assignment its own expression and condenses the code from three lines to one. While it is also valid, there is seldom a need for this.

The sequence operator, however, does make sense in the context of iterative statements, such as while() …, for() …, and do … while(). They will be explored in Chapter 7, Exploring Loops and Iterations.

Compound assignment operators

As we have already seen, expressions can be combined in many ways to form compound expressions. There are some compound expressions that recur so often that C has a set of operators that make them shorter. In each case, the result is formed by taking the variable on the left of the operator, performing the operation on it with the value of the expression on the right, and assigning it back to the variable on the left.

Compound operations are of the variable operator= expression form.

The most common of these is incrementation with an assignment:

counter = counter + 1;

With the += compound operator, this just becomes the following:

counter += 1 ;

The full set of compound operators is as follows:

  • +=: assignment with addition to a variable
  • -=: assignment with subtraction to a variable
  • *=: assignment with multiplication to a variable
  • /=: assignment with division (integer or real) to a variable
  • %=: assignment with an integer remaindering to a variable
  • <<=: assignment with bitwise shift left
  • >>=: assignment with bitwise shift right
  • &=: assignment with bitwise AND
  • ^=: assignment with bitwise XOR (exclusive OR)
  • |=: assignment with bitwise OR

These operators help to make your computations a bit more condensed and somewhat clearer.

Multiple assignments in a single expression

We have learned how to combine expressions to make compound expressions. We can also do this with assignments. For example, we could initialize variables, as follows:

int height, width, length;
height = width = length = 0;

The expressions are evaluated from right to left, and the final result of the expression is the value of the last assignment. Each variable is assigned a value of 0.

Another way to put multiple assignments in a single statement is to use the , sequence operator. We could write a simple swap statement in one line with three variables, as follows:

int first, second, temp;
 // Swap first & second variables.
temp = first, first = second, second = temp;

The sequence of three assignments is performed from left to right. This would be equivalent to the following three statements:

temp = first;
first = second;
second = temp;

Either way is correct. Some might argue that the three assignments are logically associated because of their commonality to being swapped, so the first way is preferred. Ultimately, which method you choose is a matter of taste and appropriateness in the given context. Always choose clarity over obfuscation.

Incremental operators

C provides even shorter shorthand (shortest shorthand?) operators that make the code even smaller and clearer. These are the autoincrement and autodecrement operators.

Writing the counter = counter + 1; statement is equivalent to a shorter version, counter += 1;, as we have already learned. However, this simple expression happens so often, especially when incrementing or decrementing a counter or index, that there is an even shorter shorthand way to do it. For this, there is the unary increment operator of counter++; or ++counter;.

In each case, the result of the statement is that the value of the counter has been incremented by one.

Here are the unary operators:

  • ++: autoincrement by 1, prefix or postfix
  • --: autodecrement by 1, prefix or postfix

Postfix versus prefix incrementation

There are subtle differences between how that value of the counter is incremented when it is prefixed (++ comes before the expression is evaluated) or postfixed (++ comes after the expression).

In prefix notation, ++ is applied to the result of the expression before its value is considered. In postfix notations, the result of the expression is applied to any other evaluation and then the ++ operation is performed.

Here, an example will be useful.

In this example, we set a value and then print that value using both the prefix and postfix notations. Finally, the program shows a more predictable method, that is, perform either method of incrementation as a single statement. The result will always be what we expect:

int main( void )  {
  int aValue = 5;
    // Demonstrate prefix incrementation.
  printf( "Initial: %d
" , aValue );
  printf( " Prefix: %d
" , ++aValue );  // Prefix incrementation.
  printf( "  Final: %
"   , aValue );
  aValue = 5;   // Reset aValue.
    // Demonstrate postfix incrementation.
  printf( "Initial: %d
" , aValue );
  printf( " Prefix: %d
" , aValue++ );  // Postfix incrementation.
  printf( "  Final: %
"  , aValue );
    // A more predictable result: increment in isolation.
  aValue = 5;
  ++aValue;
  printf( "++aValue (alone) == %d
" , aValue );
  
  aValue = 5;
  aValue++;
  printf( "aValue++ (alone) == %d
" , aValue );
  return 0;
}

Enter, compile, and run prefixpostfix.c. You should see the following output:

Figure 5.13 – Screenshot of prefixPostfix.c output

Figure 5.13 – Screenshot of prefixPostfix.c output

In the output, you can see how the prefix and postfix notations affect (or not) the value passed to printf(). In prefix autoincrement, the value is first incremented and then passed to printf(). In postfix autoincrement, the value is passed to printf() as is, and after printf() is evaluated, the value is then incremented. Additionally, note that when these are single, simple statements, both results are identical.

Some C programmers relish jamming together as many expressions and operators as possible. There is really no good reason to do this. I often go cross-eyed when looking at such code. In fact, because of the subtle differences in compilers and the possible confusion if and when such expressions need to be modified, this practice is discouraged. Therefore, to avoid the possible side effects of the prefix or postfix incrementations, a better practice is to put the incrementation on a line by itself, when possible, and use grouping (we will discuss this in the next section).

Order of operations and grouping

When an expression contains two or more operators, it is essential to know which operation will be performed first, next, and so on. This is known as the order of evaluation. Not all operations are evaluated from left to right.

Consider 3 + 4 * 5. Does this evaluate to 353 + 4 = 7 * 5 = 35? Or does this evaluate to 234 * 5 = 20 + 3 = 23?

If, on the other hand, we explicitly group these operations in the manner desired, we remove all doubt. Either 3 + (4 * 5) or (3 + 4) * 5 is what we actually intend.

C has built-in precedence and associativity of operations that determine how and in what order operations are performed. Precedence determines which operations have a higher priority and are, therefore, performed before those with a lower priority. Associativity refers to how operators of the same precedence are evaluated – from left to right or from right to left.

The following table shows all the operators we have already encountered along with some that we will encounter in later chapters (such as postfix [] . -> and unary * &). The highest precedence is the postfix group at the top and the lowest precedence is the sequence operator at the bottom:

Table 5.1 – Operator precedence table

Table 5.1 – Operator precedence table

What is most interesting here is that (1) grouping happens first, and (2) assignment typically happens last in a statement. Well, this is not quite true. Sequencing happens after everything else. Typically, though, sequencing is not often used in a single statement. It can, however, be quite useful as a part of the for complex statement, as we shall learn in Chapter 7, Exploring Loops and Iterations.

While it is important to know precedence and associativity, I would encourage you to be very explicit in your expressions and use grouping to make your intentions clear and unambiguous. As we encounter additional operators in subsequent chapters, we will revisit this operator precedence table.

Summary

Expressions provide a way of computing a value. Expressions are often constructed from constants, variables, or function results combined together by operators.

We have explored C's rich set of operators. We have seen how arithmetic operators (such as addition, subtraction, multiplication, division, and remainders) can apply to different data types – integers, real numbers, and characters. We touched on character operations; we will learn much more about these in Chapter 15, Working with Strings. We have learned about implicit and explicit type conversions. We learned about C Boolean values, created truth tables for logical operators, and learned how relational operations evaluate to Boolean values.

We have explored C's shorthand operators when used with assignments and explored C's shortest shorthand operators for autoincrement and autodecrement. Finally, we learned about C's operator precedence and how to avoid reliance on it with the grouping operator. Throughout the chapter, we have written programs to demonstrate how these operators work. Expressions and operators are the core building blocks of computational statements. Everything we have learned in this chapter will be an essential part of any programs we create going forward.

In the next two chapters, Chapter 6, Exploring Conditional Program Flow, and Chapter 7, Exploring Loops and Iterations, not only will we use these expressions for computations, but we will also learn how expressions are essential parts of other complex C statements (if()… else…, for()…, while()…, and do…while()). Our programs can then become more interesting and more useful.

Questions

  1. Are all operations available for all data types? Why or why not?
  2. What are the arithmetic operators?
  3. What are the logical operators?
  4. Why might you need to use type casting?
  5. Should we rely on built-in operator precedence? How can we ensure proper order of evaluation?
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset