Lecture 11

2023-05-08 | Week 6 | edited by Matt Wang

(originally written 2022-10-24 by Boyan Ding, Matt Wang, and Siddarth Krishnamoorthy)

Heya! Matt here. This lecture note covers parameters, return values, and errors from Function Palooza.

Table of Contents

Function Palooza

In Function Palooza, we will be doing a deep dive into functions. We’ll understand how functions pass arguments and receive parameters, how languages return value(s) from functions, how languages communicate errors across functions, how functions can be passed as arguments, returned and stored in variables, and how to design functions to operate on a variety of different types of inputs.

Parameters

Actual and Formal Parameters

The arguments in the definition of a function are called the formal parameters of the function. The arguments we pass to a function when we call it are called the actual parameters of the function. For example

double net_worth(double assets,  // formal parameters
                 double debt) {
  return assets - debt;
}

int main() {
 cout << "Your net worth is: " <<
   net_worth(10000, 3500); // actual parameters
}

Positional and Named Parameters

In languages that support positional parameters, the order of the arguments must match the order of the formal parameters. For example,

bool sum(float arr[], int n) {
  float sum = 0;
  for (int i=0; i < n; ++i) {
    sum += arr[i];
  }
  return sum;
}

int main() {
 float arr[3] = {78,99,65};

 cout << "The sum is: " << sum(arr, 3);
}

In languages that support named parameters, the call can explicitly specify the name of each formal parameter (called an argument label) for each argument. For example,

def net_worth(assets,debt):
    return assets - debt

print("Your net worth is: ", net_worth(assets=10000, debt=3500))

print("Their net worth is: ", net_worth(debt=45000, assets=19000))

Many languages (like C++) support a combination of positional and named parameters.

print("Your net worth is: ", net_worth(10000, debt=3500))

Positional parameters allow for a less wordy syntax since we don’t need to specify an argument label for each argument. The disadvantage is that we have to pass the arguments in the same order as the formal parameters, and this can lead to bugs when we pass arguments in an incorrect order. With named parameters, you can add parameters and shift their order around more easily since each parameter is named. A change in order won’t cause a bug. It also makes code more readable, if a bit more verbose, since you know what each argument is.

Default Parameters

Most languages let you specify default values for specified formal parameters, making these parameters optional in the function call. For example,

double net_worth(double assets, double debt = 0) {
  return assets - debt;
}

int main() {
 cout << "Your net worth is: " << net_worth(10000, 3500);
}

One or more formal parameters may have a default value. This makes passing the argument optional. If you decide to omit the associated argument to a formal parameter, the provided default value will be used. In languages (like C++ or Python) which do not have mandatory argument labels, default parameters must all be place at the end of the parameter list. This means that a definition like the following would be illegal in a language like Python.

double net_worth(double assets, double debt = 0, double inheritance = 0) {
  return assets - debt + inheritance;
}

However, in languages with mandatory argument labels (like Swift), default values can be used for any parameter.

// Swift optional parameters
func net_worth(assets: Double, debt: Double=0,
               inheritance: Double) -> Double
  { return assets-debt+inheritance }

func main() {
 print(net_worth(assets: 10000, inheritance: 500))
}

Some languages like Python or FORTRAN allow to have optional parameters without default values! A function can check if a given argument was present when the function was called and act accordingly. Here is an example in Python.

# Python optional parameters
def net_worth(assets, debt,**my_optionals):
    total_worth = assets - debt
    if "inheritance" in my_optionals: // checking if argument exists
        total_worth = total_worth +
                      my_optionals["inheritance"]
    return total_worth

print("Net-worth: ", net_worth(10000, 2000))
print("Net-worth: ",
      net_worth(10000, 2000, inheritance=50000))

These types of optionals can make code more terse, but harder to understand – looking at the function prototype gives you incomplete information on what the parameters mean!

Here is another example, now in FORTRAN.

! Fortran function with an optional parameter
real function net_worth(assets,debt,inheritance)
    real :: assets
    real :: debt
    real, optional :: inheritance

    real :: total_worth

    total_worth = assets - debt
    if (present(inheritance)) THEN
        total_worth = total_worth + inheritance
    end if
    net_worth = total_worth  ! specify return value
end function net_worth

Variadic functions

A variadic function can receive an arbitrary number of arguments. The most common example is a language’s print function:

# Python's print function is variadic
# You can pass any # of arguments to it!
print(1)
print(1,"a")
print(1,3.14159,"c",4,"foobar")

To implement variadic functions, most languages gather variadic arguments and add them to a container (e.g., to an array, dictionary or tuple) and pass that container to the function for processing.

A notable exception is C++ which requires use of convoluted library functions to access variadic arguments!

Here are some examples of variadic functions in various languages.

Variadics in Java

In Java, you may have zero or more fixed parameters. For the variadic parameters, Java creates an array containing every variadic argument and passes it to the variadic parameter.

// Java variadics use an array-like approach
public class VariadicExample {
 private void f(String regular, int... vparams) {
   System.out.println(regular);
   for (int i = 0; i < vparams.length; i++)
     System.out.println(vparams[i]);
 }

 public void someFunc() {
   f("Four #s",2,4,6,8);
 }
}

In Java, all variadic parameters must have the same type. To deal with variadic parameters of differing types in Java, we can take advantage of the fact that all boxed primitives are derived from the Object class. So we could make a variadic of type Object, and then use type reflection (instanceof) to differentiate between types and specialise behaviour by argument.

Variadics in JavaScript

In JavaScript we don’t specify variadic parameters in the function declaration, you just specify fixed parameters. JavaScript provides a builtin arguments array which is populated with all arguments (fixed and variadic).

// JavaScript variadics

function f(fixed1, fixed2) {
  console.log("Fixed: " + fixed1 + " " + fixed2)
  console.log("Varargs:")
  for (var i=2; i<arguments.length; i++)
    console.log(arguments[i])
}

f(1,"two","three",4.01);
  // Fixed: 1 two
  // Varargs: three 4.01

Python

Python created a tuple containing each variadic argument and then passes it to the function. You can then enumerate over the tuple as desired.

# Python variadics
def f(fixed1, *args):
  print("First param: ", fixed1)
  print("Everything after first arg:")
  for arg in args:
    print(arg)

f(1,"two","three",4.0)

C++

Variadics in C++ are a little more tricky. Variadic arguments are identified by the formal parameter of .... C++ doesn’t have any way of determining the number of variable arguments, so we have to somehow specify this ourselves (i.e. by passing a fixed parameter with the number of arguments).

// C++ variadics are janky ☺
#include <stdarg.h>

void vprint(int count, ...) {
    va_list args;
    va_start(args, count);
    while (count--) {
      cout << va_arg(args, double) << " ";
    }
    va_end(args);
}

int main() {
  vprint(3, 3.14159, 2.718, 32.0);
}

To start processing variadic arguments, you have to create a va_list argument and then you have to call the va_start function with the name of the last fixed parameter. You can then call the va_arg function to get to each argument, and also advance to the next one. You finally call va_end to finish processing the variable arguments. You also have to specify the type of each value in C++, it won’t know it otherwise (this is because C++ doesn’t have type reflection).

Return Values and Error Handling

In the yonder years (when Carey was still young), error handling was the wild west - there was no standardized way of catching errors! For instance, functions used enums (success, failure) or sentinel values (-1, null, or false) to report results/errors.

This created a hodge-podge of different error-handling approaches, and introduced its own set of bugs and issues. So as languages have evolved, they’ve started providing explicit mechanisms for dealing with results and errors.

Since then, languages have innovated and improved to make error handling more consistent and effective. Throughout this section, we’ll learn about each of these innovations!

Definitions: Bugs, Errors, Results

Before we discuss how languages handle bugs, errors, and results – let’s define these terms.

A bug is a flaw in a program’s logic - the only solution is stopping execution and fixing the bug. The examples of a bug can include:

  • out of bounds array access
  • derefrencing a nullptr
  • dividng by zero
  • illegal casts
  • unmet pre/post-conditions (ex: a factorial function returning a negative number!)

Other than bugs, there also exist unrecoverable errors. These are non-bug errors where recovery is impossible, and the program must shut down. Examples include:

  • out of memory error
  • network host not found
  • full disk

Other less severe errors are recoverable errors. Under this kind of error, the program may continue the execution. Some examples of this type include:

  • file not found
  • network service (temporarily) overloaded
  • malformed email

Finally, when there is no bug or error, the program will produce a result, which is an indication of the outcome/status of an operation.

Handling Techniques: Overview

Here are the major “handling” paradigms provided by various languages:

  • roll your own: The programmer must “roll their own” handling, like defining enumerated types (success,error) to communicate results.
  • error objects: Error objects are used to return an explicit error result from a function to its caller, independent of any valid return value.
  • optional objects: An “Optional” object can be used by a function to return a single result that can represent either a valid value or a generic failure condition.
  • result objects: A “Result” object can be used by a function to return a single result that can represent either a valid value or a specific Error Object.
  • assertions/conditions: An assertion clause checks whether a required condition is true, and immediately exits the program if it is not.
  • exceptions and panics: f() may “throw an exception” which exits f() and all calling functions until the exception is explicitly “caught” and handled by a calling function or the program terminates.

Many languages blend multiple of these approaches, though some are more opinionated than others!

Error Objects

Error objects are language-specific objects used to return an explicit error result from a function to its caller, independent of any valid return value.

Languages with error objects provide a built-in error class to handle common errors; error objects are returned along with a function’s result as a separate return value.

Let’s look at a real example written in Go:

func circArea(rad float32) (float32, error) {
  if rad >= 0 {
    return math.Pi*rad*rad, nil
  } else {
    return 0, errors.New("Negative radius")
  }
}

func cost(rad float32, cost_per_sqft float32)
         (float32, error) {
  area, err := circArea(rad)
  if err != nil { return 0, err }
  return cost_per_sqft * area, nil
}

The function circArea returns both a double and an error result, error is a built-in type in the second place of result tuple.

  • if the radius is valid, then we return the circle’s area and nil for the error result, meaning no error occurs.
  • otherwise, we return 0 for th area and an error object. We specify what went wrong with the message passed into the constructor error.New

The function cost gets bot the area and error result from circArea. When an error occurs, it propages the error up, and does normal computation otherwise.

Besides using the built-in error type, you can define custom error classes with fields that are specific to your error condition, and even wrap (nested) errors to provide more context.

Optionals

An “Optional” object can be used by a function to return a single result that can represent either a valid value or a generic failure.

You can think of an Optional as a struct that holds two items: a value and a Boolean indicating whether the value is valid.

Alternatively, you can think of an optional as an ADT, which contains either “nothing”, or “someting” (with a value). This is closer to how they are thought of in programming language theory.

Since you only have a simple Boolean to indicate success or failure (not a detailed error description), you only want to use Optional if there’s an obvious, single failure mode.

Let’s first see an example in C++ (C++17)

std::optional<float> divide(float a, float b) {
  if (b == 0)
    return std::nullopt;  // error result!
  else
    return std::optional(a/b);
}

int main() {
 auto result = divide(10.0,0);
 if (result)
    cout << "a/b is equal to " << *result;
 else
    cout << "Error during division!";
}

The return type std::optional<float> of function divide indicates that this function returns a float value if it’s successful.

  • if the function can’t compute a valid result, it can return nullopt which explicitly indicates an error.
  • otherwise, we construct and return an optional object containing our valid floating-point value.

C++ allows us to treat the optional object like a Boolean when checking it. If it contains a valid result, it’ll evaluate to true. Then we can then use the overloaded * operator to get the actual float value embedded in the optional object.

You can only use the * operator on C++ optional object when the result is valid. Otherwise, it will result in unspecified behavior (because there is no result when error occurs).

In some languages, optionals are a built-in part of the language with dedicated syntax! Let’s look at an equivalent example in Swift.

func divide(a: Float, b: Float) -> Float? {
    if b == 0 {
      return nil;
    }
    return a/b;
}

var opt: Float?;
opt = divide(a:10, b:20);

if opt != nil {
  let a_div_b: Float = opt!;
  print("Result was: ", a_div_b);
} else {
  print ("Error result!")
}

As we can see, the ? as in Float? is used for optional return value.

  • when returning:
  • nil is used for error result
  • any other value directly creates the optional that represents a valid value.
  • when using: ! is used to extract the value from the optional if it is valid.

Result Object

A “Result” object can be used by a function to return a single result that can represent either a valid value or a distinct error.

You can think of a Result as a struct that holds two items: a value and an Error object with details about the nature of the error.

Or, like an ADT with a value variant, and an error variant :)

enum ArithmeticError: Error {
  case divisionByZero
  // add other error types here as necessary
}

func my_div(x: Double, y: Double) ->
       Result<Double, ArithmeticError> {
  if y == 0  { return .failure(.divisionByZero) }
  else       { return .success(x / y) }
}

let result = my_div(x:10, y: 0)
switch result {
  case .success(let number):
    print("Successful division: ", number)
  case .failure(let error):
    dealWithTheError(error)
}

Different from optional objects that we discussed earlier, the error result of result type carries detailed information using error object. You can use it when there are multiple distinct failure modes that need to be distinguished and handled differently.

Assertions

An assertion is a statement/clause inserted into a program that verifies assumptions about your program’s state (its variables) that must be true for correct execution.

We typically use assertions to verify:

  • preconditions: something that must be true at the start of a function for it to work correctly.
  • postconditions: something that the function guarantees is true once it finishes.
  • invariants: An invariant is a condition that is expected to be true across a function or class’s execution.

Consider the states to verify in selection sort: void selection_sort(int *arr, int n);.

  • preconditions: arr must not be nullptr, n must be >= 0
  • postconditions: For all i, 1 <= i < n, arr[i] > arr[i-1]
  • invariants: At the end of iteration j, the first j items of arr are in ascending order

An assertion tests a particular condition and terminates the program if it’s not met. An assertion states what you expect to be true. Your program aborts if it’s not true. Here are a few examples:

// C++ assertions
void selection_sort(int *arr, int n) {
  assert(arr != nullptr);
  assert(n >= 0);
  ...
}

Some languages enable you to provide a message to explain what went wrong.

// Java assertions
public class SelectionSort {
  public void sort(int arr[]) {
    assert arr != null : "Invalid arr";
    ...
  }
}

A few languages (Eiffel, Ada) let you explicitly specify pre- and post-conditions for each function.

-- Eiffel pre and post conditions
selection_sort (arr: ARRAY [G]): ARRAY [G]
  require
    arr_not_void: arr /= Void
  local
    -- locals go here ...
  do
    -- sorting code here sorts the numbers
    -- and stores results in out_arr variable
  ensure
    result_is_set: out_array /= Void
    result_sorted: is_ascend(out_array) = True
  end

While Eiffel is not commonly used, this idea - encoding pre and post-conditions within the type system / compiler - is becoming really popular. Google “dependent typing”!

Exception Handling

With other error handling approaches, error checking is woven directly into the code, making it harder to understand the core business logic.

With exception handling, we separate the handling of exceptional situations/unexpected errors from the core problem-solving logic of our program. Thus, errors are communicated and handled independently of the mainline logic. This allows us to create more readable code that focuses on the problem we’re trying to solve and isn’t littered with extraneous error checks that complicate the code.

Participants of Exception Handling

There are two participants with exception handling: a catcher and a thrower.

A catcher has two parts:

  1. A block of code that “tries” to complete one or more operations that might result in an unexpected error.
  2. An “exception handler” that runs if (and only if) an error occurs during the tried operations, and deals with the error.
void f() {
  try {
    g();
    h();   // Might have an unexpected error
    i();
  }
  catch (Exception &e) {
    deal_with_issue(e);   // Deals with the error if one occurs
  }
}

In the C++ code above. The block of code within try belongs to the first part of the catcher, which contains the call to function h, which might result in an error. On the other hand, the following catch block is the second part. It contains the error handler, and it will only be executed in case an error happens.

The thrower is a function that performs an operation that might result in an error. If an error occurs, the thrower creates an exception object containing details about the error, and “throws” it to the exception handler to deal with.

A C++ example might look like the following:

void h() {
  // Next command might fail!
  if (some_operation() == failure)
    throw runtime_error("details..");

  op_succeeded_so_do_other_stuff();
  do_even_more_stuff();
  finish_with_more_stuff();
}

If any operation performed in the try block results in a thrown exception, the exception is immediately passed to the exception handler (in catch block) for processing.

Let’s say in the function f, an failure occurs inside function h as shown in the code, it throws an exception at the throw in the code above.

  • when the exception is thrown within h, it is immediately exited, and the remaining statements are skipped. It acts as if the function h immediately returns.
  • all remaining statements in the try block within f are also skipped.
  • the execution flow goes into the exception handler within the catch block instead. The exception handler processes the exception and figures out how to proceed.
  • finally, when the exception handler completes, execution continues normally.

Execution Flow

Let’s use another exemple to illustrate the execution flow with exception handling.

There’s a neat graphic in the slides for this; I’d check it out.

void f() {
  do_thing0();
  try {
    do_thing1();
    do_thing2();
    do_thing3();
    do_thing4();
    do_thing5();
  }
  catch (Exception &e) {
    deal_with_issue1(e);
    deal_with_issue2(e);
  }
  do_post_thing1();
  do_post_thing2();
}

If we assume there is an exception generated in do_thing3(), the execution flow of function f is:

  • do_thing0
  • do_thing1
  • do_thing2
  • do_thing3
  • deal_with_issue1
  • deal_with_issue2
  • do_post_thing1
  • do_post_thing2

On the other hand, when no exception is generated, the execution flow will be:

  • do_thing0
  • do_thing1
  • do_thing2
  • do_thing3
  • do_thing4
  • do_thing5
  • do_post_thing1
  • do_post_thing2

What is An Exception?

An exception is an object with one or more fields which describe an exceptional situation.

At a minimum, every exception has a way to get a description of the problem that occurred, e.g., “division by zero.”

But programmers can also use subclassing to create their own exception classes and encode other relevant info. The following C++ code demonstrates the creation of a custom exception.

class WebsiteException:
           public std::exception {
public:
 WebsiteException(const string& website,
                  int http_error) {
    website_ = website;
    http_error_code_ = http_error;
 }

 string get_website_url() const
    { return website_; }
 string get_http_error_code () const
    { return http_error_code_; }
 const char* what() const noexcept
    { return "Unable to connect to URL\n";}
private:
  string website_;
  int http_error_code_; // eg, 404 access denied
};

// ...
HttpStatus s = connect(url);
if (s != OK)
  throw WebsiteException(url, s.getHTTPStatus());

The Exception Hierarchy

In fact, an exception may be thrown an arbitrary number of levels down from the catcher.

void h() {
  if (some_op() == failure)
    throw runtime_error("deets");

  do_other_stuff();
}

void g() {
  some_intermediate_code();
  h();
  some_more_code();
}

void f() {
  try {
    cout << "Before!\n";
    g();
    cout << "After!\n";
  }
  catch (Exception &e) {
    deal_with_issue(e);
  }
}

In the C++ example above, when function h throws an exception, it will automatically terminate every intervening function (g in the example) on its way back to the handler (f).

Additionally, an exception handler can specify exactly what type of exception(s) it handles. A thrown exception will be directed to the closest handler (in the call stack) that covers its exception type.

void h() {
  throw out_of_range("negative index");
}

void g() {
  try {
    h();
  }
  catch (invalid_argument &e) {
    deal_with_invalid_argument_err(e);
  }
}

void f() {
  try {
    g();
  }
  catch (overflow_error &e) {
    deal_with_arithmetic_overflow(e);
  }
  catch (out_of_range &e) {
    deal_with_out_of_range_error(e);
  }
}

In the code above, the out_of_range exception thrown in h is catched by the second catch block in f.

If an exception is thrown for which there is no compatible handler, the program will just terminate. This is the equivalent of a “panic” which basically terminates the program when an unhandle-able error occurs.

It turns out that different types of exceptions are derived from more basic types of exceptions. For example, C++ has an exception hierarchy, where std::exception is the base class of all exceptions.

So if you want to create a catch-all handler, you can have it specify a (more) basic exception class. That handler will deal with the base exception type and all of its subclassed exceptions.

Finally

Some languages, like Java, introduced a third component to a catcher to make error handling simpler. This third component is called a finally block and it’s guaranteed to run whether the try block succeeds or throws. This enables you to place your “cleanup” code in a single place, yet guarantee it runs in both error and success situations. Here is an example of some code that uses finally in Java.

// Java "finally" block
public class FileSaver {
  public void saveDataToFile(String filename,
				String data) {
    FileWriter writer = null;
    try {
      // Next 2 lines might throw IOExceptions
      writer = new FileWriter(filename);
      writer.write(data);
    }
    catch (IOException e) {
      e.printStackTrace();  // debug info
    }
    finally {
      if (writer != null)
        writer.close();
    }
  }
}

In the above example, the code in the finally block always executes, regardless of whether or not the code in the try block throws an exception.