Project 3: Infinite Streams #
The skeleton tarball for this project can be found here.
out of 100 points, 110 points hard cap
- TOC {:toc}
Hard work pays off later. Laziness pays off now! –Every Haskell tutorial ever
Description #
In this lab, you will implement one of the most common data structures in functional languages: a lazy infinite list. You will also implement several transformers on this list, allowing you to do things like (potentially) have every prime number stored in a list, or generate a new Hamming number on-demand.
Note that this lab is unlike the others in that a lot of what you need to understand is on this project page and not in the headers. While it’s still important to read the headers, you should at least skim this first few sections of this document first, especially if you are unfamiliar with functional programming languages. 1
Overview of Streams #
A stream is a special way to represent a sequence of data. It is special in the sense that the only way to access the elements of the sequence is to get an element from the front of the stream: imagine a linked-list where the only valid operation is to read the front of the list, whereupon that element disappears and everything shifts up.
An example of a network stream is a TCP connection: one must grab the packets
one at a time, and the packets flow unidirectionally (due to the in-order
delivery guarantees the TCP protocol provides). Reading files from the disk
can also be thought of as reading from a stream of individual bytes or
characters, and that is probably one important reason why the C++ I/O library
is called iostream
. Strictly speaking, the I/O operations that are
available in most programming languages today are more than just streams, but
we often think of them as streams (with a few bonus capabilities) because the
abstraction is a useful one.
At first glance, streams might seem like a completely kneecapped data structure.
What benefit do we get by implementing a sequence with a stream
rather than an array or a vector? In a vector, we can access the 9999th element
just by saying vector[9999]
. In a stream, we have to do stream.take()
9998
times just to see the 9999th element. Even worse, if we don’t record the values
of the first 9998 calls to take
, the stream won’t hang on to them for
us—those elements are gone for good.
It turns out the lack of random-access support is exactly where the power of stream lies2. The main idea is that if one cannot seek into the middle of a stream, there’s no point in actually storing the entire sequence. Only the first element of the sequence, which can be pulled directly by the user, needs to be stored. The rest of the stream can be generated on demand: the second element gets computed only after the first element is pulled out of the stream, and the third one gets computed only after the second one is pulled out, and so on. In other words, instead of computing and storing all elements of a sequence eagerly in advance, what streams allow us to do is to compute and store those elements lazily on-the-fly 3. If used correctly, stream can be a huge savings in both time and memory consumption.
Laziness also makes it possible to represent certain structures that are not representable with eager lists. One such example is the representation of infinite sequences: if you try to store a vector consisting of all natural numbers, your program will (rightfully) crash after attempting to allocate infinite memory. Even if you somehow had infinite memory, trying to store all the natural numbers would cause your program to immediately enter an infinite loop.
On the other hand, modelling infinite sequences with streams is not a problem because generating the elements of the sequences only happens when those elements are accessed. Of course, a stream will not prevent infinite loops from happening if the programmer tries to iterate over all elements in an infinite sequence. But most of the time what the programmer really cares is only a subset of elements in that sequence, and it is often cleaner to get them from a stream.
Consider how you would write a program to calculate the partial sums of the first
n
fibonacci numbers in C++. Now consider the elegance of the following solution in
Haskell:
fibs = 1 : 1 : zipWith (+) fibs (tail fibs) --All fibonacci numbers
sumFirstFibs n = sum $ take n fibs
The first definition says that the list of all fibonacci numbers starts with
[1,1]
and is then generated by shifting the list and adding it to itself. Don’t
worry if you don’t get it, but trust me when I say that it generates an (infinite)
stream of fibonacci numbers. The second line simply says that to sum the first
n
fibonacci numbers, we take the first n
fibonacci numbers from the front
of the stream and add them all together. Simple, elegant, and quite easy to
read once you know what’s going on.
In this assignment, you will implement a library of streams in C++. To make the library more usable, the stream class will be polymorphic: you can have a stream of any type, as long as the types are all the same. To do this, you will need to get comfy with C++ templates.
Directions and Hints #
Download the skeleton project #
Download the the assignment file project3.tar.gz
to the lab
computers (physically or using scp
). Unpack the tarball:
> tar -xvf project3.tar.gz
This will create a new directory called project3
in the current
directory with the tarball unpacked under it. Take a look at the
skeleton code. There are several headers + code files, as well as a Makefile
that encodes rules for how to compile the project. You can invoke the makefile
by typing make
in the project directory (though this will fail at first
because the implementations are missing).
Note that this project will be slightly different from the others. Because we
are using templated types, implementation will need to be done in header
files. The only cpp file will be main.cpp
. You will also need to add additional
header files to complete this assignment–make sure to #include
them in
an appropriate location (often Stream.h
, but occasionally elsewhere).
Finally, note that template errors are absolutely the worst thing to debug, and
that heavily templated code like our Stream
class often results in weird,
inscrutable compiler errors. Don’t be afraid to post Piazza questions (though
I will probably need you to post your code in a private question to help you).
What to write #
You will need to implement the following functions–some of the functions may need new classes backing the implementation, but the last three should be doable with no additional classes (helper functions are useful though).
once()
chain()
take()
filter()
map()
prime()
hamming()
pi()
Overview of Code #
Optional #
{:.no_toc}
std::optional<T>
was added into the C++ standard with C++17. It will form a
core part of our Stream API, so let’s take a closer look at it. If you are
familiar with other languages, this is sometimes referred to as an Option
, a
Maybe
, or a Nullable
.
Variables of type std::optional<T>
may or may not hold a value of type T
.
The default constructor creates an std::optional<T>
variable with no value
in it. To put a value into an std::optional<T>
variable, initialize it or
assign it a value of type T
. To test whether a variable holds a value
or not, convert it into a boolean.
std::optional<int> opInt; // Create an optional with no value
bool hasValue = opInt; // hasValue is false
opInt = 3; // opInt now holds the value of 3
bool hasValue2 = opInt; // hasValue2 is true
std::optional<std::string> opStr = "abc"; // Create an optional with string "abc"
bool hasValue3 = opStr; // hasValue3 is true
If a std::optional<T>
variable holds a value, you can read that value using
the dereferencing operator *
. Be careful! You do not want to
dereference any std::optional<T>
variable that holds no value, as it
behaves like null-pointer dereference and will result in undefined
behavior.
std::optional<int> opInt = 42;
if (opInt) {
int theInt = *opInt;
// do something with theInt
} else {
// do NOT dereference opInt!
}
In general, the std::optional<T>
class is very useful for representing the
result of an operation that may fail. For example, it can be the return
type of a function that does some table lookup: if the element being
looked up is not found in the table, an empty std::optional
variable gets
returned; otherwise, an std::optional
variables that holds the lookup
result gets returned. In our Stream
, the next()
function
will return an empty std::optional
variable if the stream is empty, and an
std::optional
variable that holds the next element if it is not empty.
Streams #
{:.no_toc}
The Stream
class is implemented as a handle class, i.e. it is a
wrapper around a pointer to the real implementation of streams. Because manual
memory management would be awful in these scenarios, we choose to use an
std::shared_ptr
instead of a raw pointer. This means that, in pretty much
all cases, we do not need to worry about memory management: the streams will
free themselves as appropriate.4
To represent a stream of any type, the Stream
class is templated
over an value type. That value type is the type of elements that can be
pulled out from the stream. Users of the Stream
class must specify
what type of values they want the stream to hold. For example,
Stream<int>
represents a stream of integers, and Stream<std::string>
represents a stream of strings.
The interface of our Stream
class is very simple: it exposes only one
member function, next()
. This function models the “pulling” from a
stream. It has two possible outcomes: either the stream is empty, in
which case nothing gets pulled out, or the stream is not empty, in which
case an item is pulled out and the stream updates itself. To represent
both outcomes with one return value, we use std::optional<T>
.
An empty stream #
{:.no_toc}
The simplest stream one can have is a stream that has nothing in it:
calling next()
on the stream always produce an empty optional.
Implementing such a stream is extremely easy and has already been done
for you. Take a look at EmptyStreamImpl.h
. The empty()
function,
which is defined in Stream.h
, is a helper function that creates an
empty stream directly:
using namespace stream;
auto s0 = empty<int>(); // Creates an empty stream of integer
for (int i = 0; i < 100; ++i){
assert(!s0.next()); // Invoking next() always yields empty value
}
A stream of one value #
{:.no_toc}
A slightly more sophisticated stream we can write is a singleton stream.
The first time one calls next()
on it, it yields a certain value, and
all subsequent next()
calls yield empty value. To create such a
stream, use the helper function once()
:
using namespace stream;
// Creates a singleton stream that contains 42 only
// Note that we do not need to specify the type of the stream,
// as it can be deduced from the value we passed to the once() function
auto s0 = once(42);
auto opInt = s0.next();
// The first element pulled from s0 holds 42
assert(opInt && *opInt == 42);
// Subsequent next() invocation always yield empty value
for (int i = 0; i < 100; ++i){
assert(!s0.next());
}
The body of the once()
function is left unfinished. Filling it in is
part of the assignment.
Stream concatenation #
{:.no_toc}
Now that we have two ways to create streams, one common task we can do
with those created streams is to chain them together. The chain()
function takes two streams s0, s1
that shares the same value
type, and return a new stream. Pulling from this returned stream will
first yield elements from s_0
, and after s_0
is exhausted it
starts to pull elements from s_1
. For example,
// Concatenate two singleton streams
// Produce a stream with two elements
auto s0 = chain(once(3), once(4));
auto first = s0.next();
assert(first && *first == 3); // Note: must check variable for nothing-ness!
auto second = s0.next();
assert(second && *second == 4);
// No further values in the stream
assert(!s0.next());
And of course, with once()
and chain()
we are able to build streams
with arbitrary (but finite) length:
// Produce a stream with three elements
auto s0 = chain(once(1.1), chain(once(1.2), once(1.3)));
// Produce a stream with size 100
auto s1 = once(0);
for (int i = 1; i < 99; ++i)
s1 = chain(s1, once(i));
The body of the chain()
function is left unfinished. Filling it in is
part of the assignment.
An infinite stream (first attempt) #
{:.no_toc}
Up until now, everything we’ve done with streams can also be achieved with arrays or vectors. What makes streams exceptional is the ability to build sequences of potentially infinite size.
The most elegant way of constructing an infinite stream makes heavy use of recursion. Here is an example:
Stream<int> repeat() {
return chain(once(1), repeat());
}
My claim is that invoking the repeat()
function should yield a stream
from which one can pull out integer 1 indefinitely. Why?
The answer to the “why” is in the linked footnote. Before following it, think about it yourself for 60 seconds and see if you can come up with an answer.5
Unfortunately, at this point, we must invoke the famous computer scientist Donald Knuth, who once said: “Beware of bugs in the above code; I have only proved it correct, not tried it.” In fact, the analysis in the footnote is provably correct and mathematically sound, but if we try to compile and run the code, we get a segfault.
Delayed function call #
{:.no_toc}
Why did our implementation of repeat()
fail? Unfortunately, the problem
lies not in our code, but in the C++ language. C++ does what’s called eager
evaluation (sometimes called applicative-order).
What this means is that a function’s arguments are evaluated before the function.
Let’s see what that means for our function:
Stream<int> repeat() {
return chain(once(1), repeat());
}
When we call repeat()
, we need to evaluate chain
. To do that, we first need
to evaluate the arguments to chain
, which in this case, are once(1)
and
repeat()
. Evaluating once(1)
is pretty simple. To evaluate repeat()
, we
need to evaluate chain
. To do that, we first need to evaluate the arguments
to chain
, which in this case, are once(1)
and repeat()
. Evaluating once(1)
is pretty simple. To evaluate repeat()
, we need to evaluate chain
…..
…well hopefully you can see where this is going.
What we want is some way of telling C++ to delay the (inner) evaluation of
repeat
until it’s actually needed. For that, we’ve written a helper function
called delay()
. You pass it a function (usually a
lambda, but
callable objects)
are also allowed) that, when evaluated, generates a new stream. Note that the
delay()
function itself makes no attempt to track the parameters of this
input function–you need to do this yourself.
Due to its intricacy of implementation, the delay()
function is given
to you as part of the skeleton.
An infinite stream (second attempt) #
{:.no_toc}
Armed with delay()
, we are ready to give the correct implementation of
the repeat()
function:
Stream<int> repeat() {
return chain(once(1), delay(repeat));
}
Since the repeat()
function takes no argument, we could just pass its
function pointer to delay()
. For functions with non-empty parameter
list, we need to use callables.
For example:
Stream<int> counter(int a) {
return chain(once(a),
delay([a] () { return counter(a + 1); } )
);
}
This creates a function that captures a
at the time the function is created,
then evaluates the function body at a later time. Note that your lambdas
must always have no arguments, but they can capture whatever they need to.
This unit of deferred evaluation is referred to in many languages as a
thunk. If you ask for help on this project,
you’ll probably hear me talk about thunks a fair bit–it’s my preferred
terminology. Just remember that in our case, a thunk is an unevaluated stream
that is being protected by a delay()
.
Infinite Counters with delay()
#
{:.no_toc}
The counter()
function takes an integer a
as a parameter and
returns a stream that contains an increasing sequence of integers,
starting from a
. Note that the lambda we passed to the delay()
function needs to capture variable a
by value. Another more
interesting example is a function fib()
that returns a stream of all
fibonacci numbers:
Stream<int> fibgen(int a, int b) {
return chain(once(a),
delay([a, b] () { return fibgen(b, a + b); } )
);
}
Stream<int> fib() { return fibgen(0, 1); }
Implement the counter()
function in your code.
More helper functions #
{:.no_toc}
The four functions we mentioned so far, namely empty()
, once()
,
chain()
and delay()
, are all the basic building blocks we need to
construct all kinds of sophisticated streams. However, to make our
streams easier to work with, we want more helper functions:
take #
{:.no_toc}
The take()
function takes a stream s
and an unsigned number
n
as parameters, and produce another stream of size n
that
contains only the first n
elements of s
.
auto s0 = counter(1); // s0 = { 1, 2, 3, ... }
auto s1 = take(s0, 2); // s1 = { 1, 2 }
filter #
{:.no_toc}
The filter()
function takes a stream s
of type T
and a
callable f
as parameters. The callable f
should take a parameter of type T
and return bool
. The result
of filter()
is another stream that only contains elements in s
on which f
returns true
.
auto s0 = counter(1); // s0 = { 1, 2, 3, ... }
auto s1 = filter(s0,
// filter out all odd numbers
[] (int num) { return num % 2 == 0; }
); // s1 = { 2, 4, 6, ... }
map #
{:.no_toc}
The map()
function takes a stream s
of type S
and a callable f
as
parameters. The callable f
should take a parameter of type S
and return
a value of type T
.
The result of map()
is another stream of type T
which contains the elements that are the results of calling f
on the
corresponding elements from stream s
.
Conceptually, if stream s
consists of {s0, s1, s2, ...}
, then the output
of map(s, f)
should be {f(s0), f(s1), f(s2), ...}
.
auto s0 = counter(1); // s0 = { 1, 2, 3, ... }
auto s1 = map(s0,
// increase each element by 3
[] (int num) { return num + 3; }
); // s1 = { 4, 5, 6, ... }
IMPORTANT: The return type of map
does not have to match the input type,
e.g. I can take in a Stream<string>
and return a Stream<std::optional<int>>
.
Make sure you write a test for this, as the compiler cannot detect if your
code is broken for S != T
without a test case (see the Testing section for more
details).
A stream of primes #
Now that we have our basic stream library ready, it’s time to build some interesting streams!
Your next job in this assignment is to write a function prime()
that
returns a stream consisting of all prime numbers, in ascending order.
Of course, the easy way to do it is to take the stream counter(2)
and
filter out all numbers that are not prime:
bool isPrime(int n) { ... }
Stream<int> prime() {
return filter(counter(2), isPrime);
}
But constructing a prime stream this way is very inefficient: the prime
test has to be carried out form every number, and for each number the
isPrime()
function has to conduct a division test on all potential
divisors. A more efficient approach is to build this stream using
Sieve of Eratosthenes.
We start from the stream counter(2)
. The first element in that stream
is the first prime. To get the rest of the primes, filtering out the
multiples of 2 from the rest of the stream. This leaves us with a stream
beginning with 3, which is the next prime. To get the next prime, simply
filter out the multiples of 3 from the rest of the stream. This leaves
us with a stream beginning with 5, which is the next prime, and so on.
Your prime()
function should look like this:
Stream<size_t> sieve(Stream<size_t> s) { ... }
Stream<size_t> prime() {
return sieve(counter(2));
}
The sieve()
function should be a recursive function that performs the
sieving procedure described above. It takes a stream s
and use
filter()
to return another stream that contains no multiple of its
first element.
To test whether your sieve()
function is correct or not, use the
following codes to print out the first 20 elements of the stream:
auto p = take(prime(), 20);
while (auto elem = p.next())
std::cout << *elem << '\n';
NB: Attempting to use the Haskell-based definition of a fibonacci sequence presented
earlier will not work in this project, because we have not implemented the
zipWith
function.
A stream of Hamming numbers #
Your next job is to write a function hamming()
that returns a stream
of all hamming numbers, in ascending order.
Hamming numbers are numbers whose only prime divisors are 2, 3 and 5. Again, there is a rather inefficient way of computing the Hamming number stream:
bool isHamming(int n) { ... }
Stream<int> hamming() {
return filter(counter(2), isHamming);
}
But we could do better than this: notice that the Hamming number stream
S
has the following properties:
-
S
begins with 1 -
Multiplying each element of
S
by 2, and the resulting elements are also inS
-
Multiplying each element of
S
by 3, and the resulting elements are also inS
-
Multiplying each element of
S
by 5, and the resulting elements are also inS
-
These are all elements in
S
Suppose we have a helper function mergeUnique()
that takes two
increasing streams and combines the two into one increasing stream,
eliminating repetitions. Then the hamming stream S
can be
implemented by concatenate the singleton stream { 1 }
with the
mergeUnique()
of S
multiplied by 2, S
multiplied by 3, and
S
multiplied by 5. Multiplications of S
can be easily
implemented through the map()
helper function.
mergeUnique()
will be very similar to the standard merge
function that is
used in mergesort.
A stream of pi #
Our final task is to write a stream of double
that converge to
$$\pi$$. The value of pi can be approximated with the following series:
$$ \pi = 4\times\sum_{i=1}^{\infty}\frac{(-1)^{i-1}}{2i - 1} = 4\times \left(1 - \frac{1}{3} + \frac{1}{5} - \frac{1}{7} + \frac{1}{9} - \cdots\right) $$
Write a function pi()
that returns a stream, whose i
-th element is
the result of the above series cut from the i
-th term. In other
words, the first element in that stream is going to be $$4.0
= (4\times 1)$$, the second being $$ 2.66667 = (4\times(1-\frac{1}{3}))$$,
the third one being $$ 3.46667 (4\times(1-\frac{1}{3} + \frac{1}{5}))$$,
and so on.
To aid your programming, you probably want to write a helper function
partialSum()
that takes a stream {a0, a1, a2, ... }
and
return another stream {a0, a0+a1, a0+a1+a2, ... }
.
Testing #
Testing with header-based programs (like our Stream
) is a very different beast
from testing typical C++ code. In particular, because code is generated on-demand,
the compiler sometimes cannot even typecheck simple header code. Consider the
following code:
template <typename U, typename V, typename W>
W add(U u, V v){ return u + v; }
int main(void){
add<int,int,int>(2,3);
}
Does this code work? Yes, it compiles and runs just fine. However, hopefully you can see that this will not work for all template types:
template <typename U, typename V, typename W>
W add(U u, V v){ return u + v; }
int main(void){
add<int, std::string, void*>(3,"steve");
}
This little bundle of insanity will fail to compile, throwing up almost 100 lines of error output.
Note that the only change between these two snippets is how we used the template, not how the template was defined. This leads to a rather disturbing conclusion about template code: the compiler cannot help us typecheck our template code.
This means that when you do testing, you will need to be extra vigilant to suss
out hidden type bugs. A common mistake is to implement map()
for functions
that do not change the type (e.g. f(int x){return x+1;}
), and then forget to
test it for functions that do change the type. When I try to compile against
functions that do change the type (e.g f(int x){return x.to_string(); })
),
the program fails to compile, because the new types don’t work.
Another common error is to implement the stream using the same types for the stream type and an argument, e.g.
template <typename U>
some_stream_function(stream<U> s, U num_elements)
If you only test with U = int
, this works just fine! But if we set U = string
,
suddenly num_elements
has the type string
, which is unlikely to end well.
I recommend testing all your generic stream functions (map
,take
, etc.) with
at least three different types in the stream: int
, std::string
, and void*
.
This will likely catch most type incompatibilities. Make sure you write your
own additional test cases–a failure to compile is still zero points, even if
the failure to compile is because I called your function with types you didn’t
think of.6
Finally, note that this is the first time we are dealing with explicit rvalues and lvalues in this class. You must make sure your code works appropriately with both types of values, where it makes sense.
For example, if the following code works:
auto stream = map(counter(), [](int x){return x+1;});
then the following code should also work:
auto addOne = [](int x){return x + 1;};
auto stream = map(counter, addOne);
Having one or the other fail is definitely a bug, and should be addressed.
Submission and Grading #
Make sure to read the general project grading rules.
You will submit a single tarball called project3.tar.gz
which contains a
folder inside called project3
. I must be able to do the following in an
automated manner:
tar -xf project3.tar.gz
cd project3
make clean
make
./stream
There are a few additional things to check for this project:
-
Make sure the only headers you include in
main.cpp
areStream.h
,<cassert>
, and<iostream>
. I will be testing your files with my ownmain
functions, and they will only include these three files. -
Make sure that you haven’t changed any existing function signatures. This is especially important for this project, as a changed function signature will lead to a failure to compile, which is a zero.
-
Do not compile header files.
If you have any questions about the assignment, please ask on Piazza.
Stretch Objectives #
If you finish the project early and have time to burn, you might consider trying one of these stretch goals. They will give you small amounts of bonus points, capped at 15 points.
Fair warning: these stretch goals are not easy, and are more designed so that those of you who want to learn a little more about the weird fringes of C++ can cut your teeth. You will get many more points from implementing the main project well and testing it thoroughly than you will get from these bonuses.
If you attempt these, you must make sure your code still compiles on the lab machines and does not interfere with the correctness of the results. Make sure you document your changes in the README!
Stretch A: Folding (3pts) #
As useful as map
and filter
are, they are not considered to be the foundations
of working with lists in many functional languages. That honor goes to a family of
functions known as the folds.
Implement the left fold (foldl
) and right fold (foldr
) in this code, then
use them to re-implement map
and filter
. Hint: only one of these functions
will work correctly on infinite streams. Choose carefully.
Stretch B: Pythagorean Triples (3pts) #
In April of 2019, ranges
were
introduced to the C++ standard. Unfortunately, in the blog post announcing the
feature, the author chose to demonstrate how you could use ranges to generate
Pythagorean triples.
It turns out that ranges
had some detractors, and they seized on the fact that
you could write pythagorean triples without using ranges
to conclude that the
feature was unnecessary and represented bloat. For about two weeks, meaningful
discussion about C++ and its ecosystem ground to a halt under the crushing
weight of a bajillion blog posts and pointless nitpicking debates over the
CoRrEcT™ way to write pythagorean triples code.
While we can no longer contribute to this debate (mostly because people realized it was dumb and decided to move on to better things), we can still show them up by writing not just a pythagorean triple generator, but an infinite pythagorean triple generator.
Write a stream that returns a list of all pythagorean triples. The stream should
be ordered in the following sense: if a1 + b1 + c1 < a2 + b2 + c2
, the the
first triple should appear before the second triple in the stream.
You can represent a triple either as a custom class, or as an
std::tuple
.
Stretch C: Experimental Compiler Shenanigans (4pts) #
One thing you might have noticed while implementing this project is that template errors, are, scientifically speaking, butts.
Part of the problem is due to a C++ rule called SFINAE, which, very roughly speaking, says that a template usage is not invalid unless all possible overloads are incorrect. This forces the compiler to try all the possible overloads of a function, and to print them out to aid in debugging.
C++ 20 was very recently standardized, and one of the features that was included
in the standard is the concept.
This allows us to place constraints on what types are allowed in a function–for
example, we can say that only types that support the +
operation are allowed
in a sum
function, or that only types that support being printed with cout
are allowed in a custom output stream.
To do this stretch, you will need a compiler with experimental support for
C++20. The latest version of g++
has this, available on the CS machines as
g++-9
. Newer versions of clang will also have this. Sometimes, C++20 support
is under the name c++2a
,the name of the new standard before it was finalized.
Write a three stream functions that assume the ability to do certain things–for
example, a partialSum()
stream function (you may already have this from writing
pi()
), which assumes the ability to add elements, or a duplicate()
stream
which duplicates each element.7
You should also modify your implementations of map
and filter
to restrict the
types that can be passed for F
: right now, attempting to pass e.g. F = int
will fail spectacularly, with messages going all over the place.
Record the difference between error messages when using/not using concepts, and demonstrate that your functions work correctly for that concept.
-
Examples of such languages include Haskell, OCaml, Scala, or the Lisps ↩︎
-
While this may be surprising at first, restricting the power of constructs is a time-honored technique for making more powerful abstractions. ↩︎
-
This is why people with functional language background sometimes refer to streams as lazy lists. ↩︎
-
Note that using
shared_ptr
means that we cannot have self-loops in our streams. To allow self-referencing, we would need to use a tracing garbage collector. If only we had one lying around somewhere… ↩︎ -
Here is the reason:
repeat()
is constructed by concatenating a singleton stream{ 1 }
with another stream, which means that if you callnext()
on it for the first time, the singleton stream gets pulled and you will get 1 as a result. The second timenext()
is invoked, the singleton stream becomes exhausted and we start to pull out the first element from the second part of the concatenation. However, the second part is arepeat()
function itself, whose first element is another 1. So we will get a 1 for the second time. Similarly, we will get another 1 the third timenext()
is invoked, and another 1 for the fourth time, and so on. ↩︎ -
Fortunately, I’ll be compiling test cases separately this time, so you’ll only get a zero for that particular test case. Still, a zero on a test is something you should try to avoid. ↩︎
-
Some elements cannot be safely duplicated: for example, if you are using a mutex to control exclusive access to a device, cloning the lock results in two locks, which allows two writers to access the device at once…not good! In C++, non-copyable elements can be implemented by deleting the copy constructor. ↩︎