05. Function Palooza
written by Siddarth Krishnamoorthy, Matt Wang, Einar Balan, Boyan Ding
This lecture note covers the Function Palooza deck.
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 values from functions
- how languages communicate errors across functions
- how functions can be passed as arguments, returned and stored in variables
- how to design functions to operate on a variety of different types of inputs.
Parameter Passing Semantics
Parameter Passing Semantics is the term we use to describe the underlying mechanisms that languages use to pass arguments (e.g., x+y
) to functions (e.g., f(x+y)
).
First a review of some parameter passing terminology via example:
double net_worth(double assets, double debt) { // formal parameters
return assets – debt;
}
int main() {
cout << "Your net worth is: " <<
net_worth(10000, 3500); // actual parameters aka args
}
Formal parameters refer to the variables that are bound to values while actual arguments/args are the values that we pass in!
The four most common parameter passing semantics are:
- Pass by value: The formal parameter gets a copy of the argument’s value/object
- Pass by reference: The formal parameter is an alias for the argument’s value/object
- Pass by object reference: The formal parameter is a pointer to the argument’s value/object
- Pass by name: The parameter points to an expression graph that represents the argument
As it turns out, these are closely related to binding semantics we learned!
We’ll also discuss two more strategies which are a bit more esoteric:
- Pass by value-result: A copy of the argument gets passed in and changes are copied back to the argument on the way out
- Pass by macro expansion: Every use of the parameter in the function is replaced with the exact text passed in as the argument
Pass by Value
Approach: Each argument is first evaluated to get a value, and a copy of the value is then passed to the function for local use.
Take a look at the following C++ program, the parameter n
in stinkify
is passed by value:
void stinkify(string n) {
string t = n + " stinks!";
n = t;
}
int main() {
string s = "Devan";
stinkify(s);
cout << s; // Prints "Devan"
}
When stinkify(s)
is executed, the n
in stinkify
’s activation record is initially copied from s
from main
. When n
is modified at the end of the function, it only affects the local copy without affecting the original argument s
in main.
Pass by Reference
Approach: Secretly pass the address of each argument to the function. In the called function, all reads/writes to the parameter are directed to the data at the original address.
Let’s looked at a slightly altered version of above program, where the parameter n
is passed by reference instead (notice the “&
” before n
):
void stinkify(string &n) {
string t = n + " stinks!";
n = t;
}
int main() {
string s = "Devan";
stinkify(s);
cout << s; // Prints "Devan stinks!"
}
This time when stinkify(s)
is executed, the n
in stinkify
’s activation record points to the s
in main
’s activation record. This enables the formal parameter to act as an alias for the original value/object. Thus, each read/write of the formal parameter is directed to the original variable’s storage. So the modification of n
changes the s
in main
.
One byproduct that can be caused by pass by reference is aliasing. It occurs when two parameters unknowingly refer to the same value/object and unintentionally modify it. It can cause subtle and difficult bugs.
void filter(set<int> &in,
set<int> &out) {
out.clear();
for (auto x: in)
if (is_prime(x)) out.insert(x);
}
int main() {
set<int> a;
// ... fill up a with #s
filter(a, a);
}
The function filter
is supposed copy all prime numbers from in
into out
. Although it seems to do the job perfectly, problem will occur when in
and out
refer to the same variable, as demonstrated with the call filter(a, a)
above.
In the example, when in
and out
refer to a
, out.clear()
will clear the input a
before it gets processed. This causes the wrong result to be generated.
Pass by Object Reference
Approach: All values/objects are passed by (copy of the) pointer to the called function. The called function can use the pointer to read/mutate the pointed-to argument.
class Nerd:
def __init__(self, name, iq):
self.name = name
self.iq = iq
def study(self):
self.iq = self.iq + 50
# ...
def be_a_nerd(n):
n.study();
def main():
a = Nerd("Alwyn", 150)
be_a_nerd(a);
print(a)
In the python code, the call be_a_nerd(a)
copies the object reference a
into the formal parameter n
of function be_a_nerd
. So within be_a_nerd
, the local variable n points to our original object, which can be mutated through the object reference.
But there is one thing to remember: when we pass by object reference, we can’t use assignment to change the value of the original value/object. Let’s see the following example:
def stinkify(n):
t = n + " stinks!";
n = t
def main():
s = "Devan";
stinkify(s);
print(s); # Prints "Devan"
We might expect the assignment n = t
to change the original variable s
in the calling function. But in fact, the assignment only changes the local object reference n
to point to the storage of t
in Heap Memory. It has no imact on the original object reference s
or the object s
points to.
The takeaway is that assignments of object references never change the passed-in value/object. They just change where the local object reference points to. It contrasts with passing by reference, where assignment can change the original value.
So, in a language that uses Object References like Java or Python, your function can either return a new object with relevant changes (e.g., x = stinkify(x)
), or use mutator functions.
Pass by Name/Need
Approach: Each parameter is bound to a pointer that points to an expression graph (a “thunk”) which can be used to compute the passed-in argument’s value.
Here, a thunk is typically implemented as a lambda function, which can be called to evaluate the full expression graph and produce a concrete result when it is needed.
Haskell is a notable example of pass by need.
func2 y =
y^2+7
func1 x =
func2 (3 + x)
main = do
let z = func1 5
print z
In pass-by-need, once an expression graph is evaluated, the computed result is memoized (cached) to prevent repeat computation. For example, in the code above, if we do another print z
after the existing print, the value of z
will be cached without the need for another evaluation.
Pass by Value-Result
Pass by Value Result is a less common approach to passing, but we’ll still take a look at it here. In Pass by Value Result, we pass a copy of the argument in and once we return from our function we copy over all changes made to that parameter to the original argument. Here’s an example in Ada:
-- Ada pass by value result example
procedure Negate( val: IN OUT integer )
is
begin
val := val * (-1); // this updates the local parameter variable
end Negate; // when we return, we copy the value of val back into the original arguments storage
-- main body
v : integer;
begin
v := 42;
Negate(v);
Put_Line(natural'image(v)); // upon return -42 is copied in!
end
The important thing to realize is that, changes are only made to the original argument after the function returns. The parameter variable and argument are still distinct!
Pass by Macro Expansion
Here’s another less common approach, mainly used in C and C++. You may have heard of macros: essentially these are directives to the compiler to “replace” a snippet of code with anothe snippet. Here’s an example:
// Define a C++ macro that computes the
// square of a number
#define square(a) a * a
int main() {
int x = 10;
int result;
result = square(++x);
cout << result;
}
In this example, we’re indicating to the compiler that we want to replace every instance of the macro function square
with a*a
. The cool thing about this is we can also pass in arguments and it will replace those too! After all macro expansion is performed, we get the following program:
int main() {
int x = 10;
int result;
result = ++x * ++x;
cout << result;
}
Clearly this code behaves differently than if we had used a traditional function! Here we increment the value of x twice, rather than just once. Pretty cool!
Parameter Passing by Language
Let’s look at some common language and the parameter passing scheme they use:
- C++: Pass by value/reference/object reference (pointer)/macro expansion
- Go: Pass by value (primitives)/object reference
- Haskell: Pass by need
- Java: Pass by value (primitives)/object reference
- JavaScript: Pass by value (primitives)/object reference
- Python: Pass by object reference
Practice: Classify That Language: Parameter Passing
Consider the following program, which prints:
q is 110 x is 20, y is 60
What parameter passing strategy is this language using?
struct Record {
x:i32,
y:i32
}
fn change_value(v: &mut i32) {
*v += 100;
}
fn change_struct(r: &mut Record) {
r.x *= 2;
r.y *= 3;
}
fn main() {
let mut q:i32 = 10;
change_value(&mut q);
println!("q is {}", q);
let mut rec = Record{x:10,y:20};
change_struct(&mut rec);
println!("x is {}, y is {}", rec.x, rec.y);
}
Answer: This language is using a hybrid of pass-by-reference and pass-by-pointer. (This is rust)
def addIfFirstEven(a: => Int, b: => Int): Int =
var sum = a
if (a % 2 == 0)
sum += b
return sum
def triple(x: Int): Int =
var trip: Int = x*3
println("3*"+x+" is: "+trip)
return trip
object Main {
def main(args: Array[String]): Unit =
var v1 = 1
var v2 = 2
var result = addIfFirstEven(v1,triple(v2))
println("The result is: "+result) //prints "The result is: 1"
}
In this case, this is doing pass-by-name since the second argument of addIfFirstEven (triple(v2)
) is only evaluated if the first argument (v1
) is even. Since it’s odd in this instance, the triple
function never runs!
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 Python) 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 = 0, double debt, 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 benullptr
,n
must be>= 0
- postconditions: For all
i
,1 <= i < n
,arr[i] > arr[i-1]
- invariants: At the end of iteration
j
, the firstj
items ofarr
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:
- A block of code that “tries” to complete one or more operations that might result in an unexpected error.
- 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 the 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 functionh
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 caught 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.
Exceptions and memory safety
Exceptions can create all sorts of bugs if not used appropriately. To see how that can happen, consider the following example in C++.
void h() {
if (some_op() == failure)
throw runtime_error("deets");
do_other_stuff();
}
void g() {
int* arr = new int[100];
h();
// code uses array here...
delete[] arr;
}
void f() {
try {
g();
}
catch (Exception &e) {
deal_with_issue(e);
}
}
What happens if h
throws a runtime_error
? Notice that in g
, the memory allocated using new
is never freed, thus causing a memory leak! So you should be wary of using exceptions in languages without automatic memory management (like C++).
In languages like C++, you can still use exceptions without worrying about memory leaks if you use Smart pointers.
Some languages like Java force you to annotate functions which throw exceptions with a special token (in the case of Java, you must annotate functions that throw with the throws
keyword). This way, you will know if you need to catch an exception when you call a function.
// Java functions must explicitly
// declare their exceptions!
public class MyClass {
void h() throws IOException {
if (some_op() == fail_to_open_file)
throw new IOException("File mising!");
}
void g() throws IOException {
h();
// other code
}
public void f() {
try {
g();
}
catch(IOException e) {
deal_with_issue(e);
}
}
}
Another big drawback of exceptions is that they are extremely slow when they are thrown!
Here are a few more examples of exceptions in other languages.
- Swift
// Swift exception handling example
enum VendErr: Error {
case invalidSelection(selection: String)
case invalidBill(billValue: Int)
}
class VendingMachine {
var dollarsInserted: Int
func insertMoney(dollars: Int) throws {
if (dollars != 1 && dollars != 5 && dollars != 10)
{ throw VendErr.invalidBill(billValue: dollars) }
dollarsInserted += dollars
}
func buyItem(itemCode: String) throws -> String {
if itemCode == "A1" && dollarsInserted >= 2 {
dollarsInserted -= 2
return "KitKat"
}
// We got nuthin left!
throw VendErr.invalidSelection(selection: itemCode)
}
}
...
var vm = VendingMachine()
do {
try vm.insertMoney(dollars:10);
let item = try vm.buyItem(itemCode: "b3");
print("Ate \(item). Yum!")
} catch VendErr.invalidSelection {
print("Invalid Selection.")
} catch VendErr.invalidBill(let billSize) {
print("Counterfeit $\(billSize) bill!")
}
- Python
# python exception handling example
def getNumbers(howMany):
if howMany < 0:
raise ValueError("howMany was < 0")
return [str(i) for i in range(howMany)]
def saveNums(filename, howMany):
try:
f = open(filename, "w")
try:
nums = getNumbers(howMany)
f.write(' '.join(nums))
except ValueError as ve:
print("Had a value error:",ve)
except IOError:
print("Badness saving to file")
finally:
f.close()
except OSError:
print("Badness opening file")
Exception handling guarantees
As we’ve seen, exception handling can be tricky and difficult to understand. As such, engineers have produced some rules of thumb to ensure exceptions are used in a safe manner. When writing a function, always make sure you meet one of the following guarantees:
- No throw guarantee: A function guarantees it will not throw an exception. If an exception occurs in/below the function, it will handle it internally, and not throw. For example, this is required of destructors, functions that deallocate memory, swapping functions, etc.
- Strong exception guarantee: If a function throws an exception, it guarantees that the program’s state will be “rolled-back” to the state just before the function call. For example,
vec.push_back()
ensures the vector doesn’t change if thepush_back()
operation fails. - Basic exception guarantee: If a function throws an exception, it leaves the program in a valid state (no resources are leaked, and all invariants are intact). Here, state may not be rolled back, but at least the program can continue running.
Panics
Like an exception, a panic is used to abort execution due to an exceptional situation which cannot be recovered from (e.g., an unrecoverable error). Essentially, you can think of a panic as an exception which is never caught, and thus which causes the program to terminate. Panics contain both an error message and a stack trace to provide the programmer with context as to why the software failed.
Here is an example of a panic in C#.
// C# FailFast panic
class WorstCaseScenario
{
public void someFunc()
{
if (somethingUnrecoverableHappens())
Environment.FailFast("A catastrophic failure has occurred.");
}
}
Classify that language
Consider the following program, which computes a/b
given two values a
and b
. What result or error handling approach does this language use?
use std::io::{Error, ErrorKind};
fn my_div(a:f64, b:f64) -> Result<f64, Error> {
if b != 0.0 { Ok(a/b) }
else { Err(Error::new(ErrorKind::Other,
"You divided a by zero!")) }
}
fn main() {
match my_div(50.0,20.0) {
Ok(number) => println!("Got {}",number),
Err(e) => println!("Got an error {}",e),
};
match my_div(10.0,0.0) {
Ok(number) => println!("Got {}",number),
Err(e) => println!("Got an error {}",e),
};
}
This is an example of a language using result variables for error handling. The language lets you specify either a value or an error type/message for a failure.
The language is actually Rust!
First Class Functions
In languages with First Class Functions:
- Functions can be passed/returned to/from other functions
- Variables can be assigned to functions
- Functions can be stored in data structures
- Functions can be compared for equality
- Functions can be expressed as anonymous, literal values
Functions here are called “first-class citizens” because they are data objects and can be maniupulated like any other data in a program.
Here are some examples of first class functions in some languages:
- C++
In C++, first-class functions are implemented with function pointers.
int square(int val) { return val * val; }
int fivex(int val) { return val * 5; }
using IntToIntFuncPtr = int (*)(int val);
void apply(IntToIntFuncPtr f, int val) {
cout << "f(" << val << ") is " << f(val) << endl;
}
IntToIntFuncPtr pickAFunc(int r) {
if (r == 0) return square;
else return fivex;
}
int main() {
IntToIntFuncPtr f = pickAFunc(rand() % 2);
if (f == square) cout << "Picked square\n";
else cout << "Picked fivex\n";
apply(f, 10);
}
- Go
func square(val int) int { return val * val }
func fivex(val int) int { return val * 5 }
type IntToIntFuncPtr func(int) int
func apply(f IntToIntFuncPtr, val int) {
fmt.Println("f(", val, ") is ", f(val))
}
func pickAFunc(r int) IntToIntFuncPtr {
if r == 0 {
return square
} else {
return fivex
}
}
func main() {
var f = pickAFunc(rand.Intn(2))
if f != nil {
apply(f, 10)
}
}
Anonymous (Lambda) Functions
A lambda function is a function that does not have a function name – it’s anonymous. Typically we pass a lambda function as an argument to another function or store it in a variable. Lambdas are used when a short, temporary function is needed just once in a program, and it doesn’t make sense to define a general-purpose named function.
Every lambda has three different parts:
- Free Variables to Capture - What variables defined outside the lambda function should be available for use in the lambda function when it is later called.
- Parameters & Return Type - What parameters does the lambda function take and what type of data does it return.
- Function Body - The body of the lambda function that performs its operations.
Capture by Value
As the name suggests, in capture by value, only the values of the free variables are captured by the lambda. This means that any changes made to the captured variable inside the lambda will not be reflected outside it. Here is an example of capture by value in C++.
// C++ lambda function
auto create_lambda_func() {
int m = 5;
int b = 3;
return [m,b](int x) -> int { return m*x + b; };
}
int main() {
auto slope_intercept = create_lambda_func();
cout << "5*100 + 3 is: " << slope_intercept(100);
}
Capture by Reference
C++ (and some other languages) can also capture values by reference. Here is an example.
// C++ lambda function – capture by reference
auto fun_with_lambdas() {
int q = 0;
auto changer = [&q](int x) { q = x; };
changer(5);
cout << "q is now: " << q; // Outputs "q is now: 5"
return changer;
}
int main() {
auto f = fun_with_lambdas();
}
Capture by Environment
In capture by environment, an object reference to the lexical environment where the lambda was created is added to the closure. The lexical environment is a data structure that holds a mapping between every in-scope variable and its value. That includes all variables in the current activation record (locals, statics), and all global variables. Python uses capture by environment semantics. Here is an example.
def foo():
q = 5
f = lambda x: print("q*x is: ", q*x)
f(10)
When you define the lambda, it creates a closure containing:
- the lambda function itself
- an object reference to the current lexical environment
When running the lambda, it looks up each free variable in the lexical environments to obtain its value. Now consider this slightly modified code.
def foo():
q = 5
f = lambda x: print("q*x is: ", q*x)
f(10) # outputs "q*x is: 50"
q = q + 1
f(10) # outputs "q*x is: 60"
This outputs both 50
and 60
. This is because even though the lambda was defined when q = 5
, when the lambda runs, it looks up the latest value of the variable in the lexical environment, and therefore outputs 60
.
Classify that language
Consider the following program which prints out 7
. What capturing strategy does this language employ?
function make_lambda() {
let puppies = 4;
// define lambda function with 1 param
temp = function (kittens) {
s = "pup"
s = s + "pies + kit"
s = s + "tens"
// eval(s) interprets s as if it were a
// regular statement in the program
return eval(s)
}
return temp
}
f = make_lambda()
console.log(f(3))
Notice that our lambda function never explicitly references puppies
! So the language can’t be capturing by value or reference, so it must use capture by environment. The lambda then constructs the string "puppies + kittens"
. Then the eval
function takes it as a parameter and treats it as code, and runs it! So running puppies+kittens
computes 4+3
and the code outputs 7
.
The language is actually JavaScript!
Polymorphism
Polymorphism is the process of defining a function or class that can operate on many different types of values. Our goal is to express algorithms in their most abstract sense - making our code more interoperable and generalizable. While this might sound complicated, you’ve already used many polymorphism features in your CS journey - and in this class!
One common example is a vector. A vector<int>
, vector<str>
, and any other vector
share the same operations - how can we reduce code duplication (making a VectorInt
, VectorStr
, …) without taking a huge performance hit? By the end of this class, you’ll be able to answer that quesiton!
We’ll explore a few polymorphism approaches, focusing on ad-hoc and parametric polymorphism in statically-typed languages. We’ll then talk about subtype polymorphism in OOP palooza!
Ad-hoc Polymorphism
Ad-hoc polymorphism is when we (as the programmer) define specialized versions of a function for each type of object we wish it to support. The language decides which version of the function to call based on the types of the arguments.
You’ve likely seen this with operator overloading:
bool greater(Dog a, Dog b) {
return a.bark() > b.bark();
}
bool greater(Student a, Student b) {
return a.gpa() > b.gpa();
}
int main() {
Dog spot, penny;
if (greater(spot, penny))
cout << "Spot wins!\n";
Student carey, david;
if (greater(carey, david))
cout << "Carey wins!\n";
}
We call it “ad-hoc” since there’s no formal structure in the language to support it.
Ad-hoc polymorphism isn’t possible in dynamically typed languages. This is because we usually don’t specify types for formal parameters in dynamic languages, so there’s no way to define multiple versions of a function with different parameter types. Instead, such languages can specialise behaviour using type reflection.
Parametric Polymorphism
With parametric polymorphism, we define a single, parameterized version of a class or function that can operate on many, potentially unrelated types. Parameteric polymorphism is implemented in two major ways, templates or generics. The syntax for the two may look similar, but they’re implemented in entirely different ways - with big implications!
Templates
Templates do almost all of the work at compile-time. In languages that use templates (like C++ or Rust), the compiler will see all the types that are used in each template. For each invocation, the compiler will do some type-checking. Then, it will generate a concrete version of the function/class by substituting in the type parameter. Finally, it’ll continue compiling the code “as normal”. After template substitution, the code is “normal” C++ - there’s nothing special about it!
Here’s one such example:
template <typename T>
void takeANap(T &x) {
x.sleep();
}
class Dog {
void sleep() { /* ... */ }
};
class Person {
void sleep() { /* ... */ }
};
int main() {
Dog puppers;
takeANap(puppers); // OK!
Person carey;
takeANap(carey); // OK!
string val;
takeANap(val); // error: no member named 'sleep' in 'string'
}
In the above example, since takeANap
uses the sleep()
method internally, only types that support sleep()
can be used. If you try to use a type that doesn’t support sleep()
, you’ll get a compile time error.
When you create a new template, e.g., vector
Answer
Yes - since the compiler basically generates a concrete version of the function/class with the specified type, and then compiles it as it would any other class, type safety is guaranteed!Is templated code more or less run-time efficient than an equivalent function that doesn’t use templates, but otherwise has the same logic? Why?
Answer
Both implementations have the same runtime efficiency since a custom version of the function/class is generated for each distinct type, and it can be optimized just as if you wrote a dedicated function for that type.C-style macros are sort of the OG templates. The pre-processor would basically do a textual search-and-replace of the arguments. But, the compiler doesn’t generate a new function for each parameterized type.
// C macros used to implement template-like functionality
#define swap(T,a,b) { T temp; temp = a; a = b; b = temp; }
int main() {
int p = 5, q = 6;
swap(int, p, q);
std::string s = "foo", t = "bar";
swap(std::string, s, t);
}
In Eggert’s 131, we would have learned quite a bit more about macros (especially in the context of Scheme). If you’re interested in macros, take a look at the concept of Hygienic macros, which demonstrate why C macros are not the exact same as templating.
Generics
Generics take a different approach. Instead of generating a new function for each parametrized type, we compile just one “version” of the generic function or class - independent of the types that actually use our generics.
Because of this, the generic can’t make assumptions about what types might be using it! So, you can only do “generic operations” - hence the name.
Here’s an example with C#; note how similar it looks to templates:
// C# generic container
class HoldItems<T> {
public void add(T val)
{ arr_[size_++] = val; }
public T get(int j)
{ return arr_[j]; }
public void beADuck(int j)
{ arr_[j].quack(); } // ILLEGAL!!
T[] arr_ = new T[10];
int size_ = 0;
}
HoldItems<Duck> ducky =
new HoldItems<Duck>();
ducky.add(new Duck("Daffy"));
Duck d = ducky.get(0);
Calling quack()
on the generic type would be illegal, even though we only ever use HoldItems
with Duck
(which implicitly has a .quack()
method).
Later, code that uses the generic is then checked to make sure that the generic’s interfaces are used correctly.
Alone, this seems useless - there are very few things you can do on all types. That’s why we can bound types: adding restrictions on what types are allowed.
Here is an example of bounding in C#:
interface DuckLike {
public void quack();
public void swim();
}
class HoldItems<T> where T: DuckLike {
public void add(T val)
{ arr_[size_++] = val; }
public T get(int j)
{ return arr_[j]; }
public void beADuck(int j)
{ arr_[j].quack(); } // LEGAL!!
T[] arr_ = new T[10];
int size_ = 0;
}
Here’s another example in Haskell!
qsort :: (Ord t) => [t] -> [t]
qsort [] = []
qsort (x:xs) =
let leq = qsort [j | j <- xs, j <= x]
geq = qsort [k | K <- xs, k > x]
in leq ++ [x] ++ geq
Haskell type classes, such as Ord
and Eq
, place bounds on what types the t
type variable can take on.
Why Parametric Polymorphism?
This all seems unnecessarily complicated. Why do we need this? Couldn’t we just use inheritance?
Let’s take a look at an instance where just inheritance fails us. For context, languages like C# and Java have an Object
class that superclasses all other classes. Let’s assume we can do polymorphism just with subclassing:
class Duck : Object {
public void quack() {
Console.WriteLine("Quack");
}
public void swim() {
Console.WriteLine("Paddle");
}
}
class HoldItems {
public void add(Object val)
{ arr_[size_++] = val; }
public Object get(int j)
{ return arr_[j]; }
Object[] arr_ = new Object[10];
int size_ = 0;
}
Any object can be stored in HoldItems
! So, we could add both a string
and a Duck
to a container, since both subclass Object
. So, this code would be possible…
HoldItems items = new HoldItems();
items.add(new Duck("Daffy"));
items.add("more stuff");
string s = (string)items.get(0); // Should be illegal!
Which is really bad! And, there’s no way for us (or C#, at compile-time) to detect a bug! So, we can’t allow behaviour like this.
With generics, we tell the compiler which types our generics will use:
HoldItems<Dog> items = new HoldItems();
items.add(new Duck("Daffy")); // type error!!
Parametric Polymorphism in Dynamically Typed Languages
We can’t really have “type-parameterized” classes or functions, since variables don’t specify types!
But, duck typing occupies the same feature space. Here is an example in Python:
def qsort(lst):
if len(lst) <= 1: # base case
return lst
pivot = lst[0]
rest = lst[1:]
lessEq = [x for x in rest if x <= pivot]
greater = [x for x in rest if x > pivot]
return qsort(lessEq) + [pivot] + qsort(greater)
strs = ["b","c","a"]
ints = [1,5,3,2,4]
qsort(strs) # ["a","b","c"]
qsort(ints) # [1,2,3,4,5]
But, note that this isn’t as safe - we have no idea at compile-time if it’ll work!
Classify That Langauge
Consider the following parameterized class and some code that uses it below.
class StudyList<T> {
private values: T[] = [];
public constructor (values: T[]) {
this.values = values;
}
public add(value: T): void {
this.values.push(value);
}
public get(index: number): T {
return this.values[index];
}
public studySession(): void {
for (var v in this.values)
v.study();
}
}
...
// The Nerd class has a study() method.
const nerds = new StudyList<Nerd>([]);
nerds.add(new Nerd("Carey"));
nerds.add(new Nerd("Paul"));
nerds.studySession();
The code results in a compiler error
TypeError: v.study is not a function
Is the languages using templates or generics?