8.5. C-Strings and Scope

Time: 00:12:01 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PDF)
Review

Please review the following as needed:

During my career as a software engineer, I periodically interviewed for new positions. Interviews, especially second interviews, often include engineers who ask technical questions. During an interview, a software engineer wrote the following code fragment on the blackboard. He wrote a few distracting statements in place of the ellipses and then asked, "What's wrong with this code?"

char* get_name()
{
	char	name[100];
	cin.getline(name, 100);
		.
		.
		.
	return name;
}
A logical scope error. The function is syntactically correct but does have a logical error. The error makes the function's behavior unpredictable. When studying a problem like this, focus on what is given rather than speculating on possible omissions. Let's begin with a few hints:

Give yet? Here's the problem: the function get_name returns the address of the local variable name, which the program deallocates when the function returns. What becomes of that deallocated memory depends on the program and the computer running it. The program may reallocate the memory and overwrite it very quickly. However, the program that calls get_name is likely still using (or, more accurately, trying to use) the data stored in name. If the program runs correctly once, there's no guarantee that the next call will succeed. Even if the program consistently runs correctly on one computer, there's no guarantee it will work on another. The following figures illustrate three solutions.

Understand: scope != allocation

Although they are sometimes tightly coupled, scope and memory allocation are distinct concepts and mechanisms. Programs allocate and deallocate memory for local or automatic variables when they come into and go out of scope, masking the distinction between the two mechanisms and making the above appear to be a scoping problem. The distinction between scope and memory allocation is easier to see if we make name a static variable, which is the first of three possible solutions. Study each solution carefully to understand the interplay between scope and memory allocation.

Solutions And Consequences

There is no one right solution to solve the problem of a function returning a local C-string. There are several possible solutions, each with advantages and disadvantages. So, we can only choose an appropriate solution in the context of a specific problem. As a practicing computer scientist, one of your tasks is weighing the advantages against the disadvantages so that your chosen solution is the best for a given problem.

static Data

char* get_name()
{
	static	char	name[100];
	cin.getline(name, 100);
		.
		.
		.
	return name;
}
static data. Programs allocate memory for static variables when the operating system loads them into memory, and that memory remains allocated until the program ends. The variable name does go out of scope when the function ends, but its memory remains allocated and unusable by any other part of the program. Since the function returns the address of name, other parts of the program can access the memory indirectly through that address. For example:
char* line = get_line();
cout << line[3] << endl;
This solution should help us see the distinction between scope and memory allocation.

The static data solution is straightforward to use, and because it only allocates data once, it's also fast. Nevertheless, static data does have some disadvantages, albeit mostly minor ones. Early programming languages like FORTRAN didn't have automatic variables; the program allocated the memory for all variables at load time. Modern programming languages that support automatic variables run the same programs using far less memory than those older languages. Making a few variables static won't greatly increase the program's memory requirements, but making all variables static certainly would.

Functions that have static variables cannot be recursive or reentrant. We briefly covered recursive functions in Chapter 6, and reentrancy is a property needed by specialized functions most often seen in operating systems and is beyond this course's scope. Both issues only concern a few specialized functions and are easily ignored. But there is one aspect of static data that we must understand.

void client()
{
	char* data;

	data = get_name(data);
		. . .
	data = get_name(data);
		. . .
}
 
void client()
{
	char* data[10];

	data[0] = get_name(data);
	data[1] = get_name(data);
		. . .
}
A picture of a character array. The first two elements point to the same C-string.
(a) (b)
Consequences of static data. Each call to get_name returns the same address, the address of the static C-string name. Therefore, each call to get_name overwrites the data stored in name with new data. Depending on how the client (the program that calls get_name) uses the data, this version of get_name may work correctly, or it may have a logical error.
  1. A client function calls get_name and uses the C-string that it returns. When the client is done processing the returned C-string and doesn't need it any longer, it can call get_name again. This scenario is the most common one for using static variables and is correct.
  2. A less common scenario, and a logical error I made as a novice software engineer, is storing rather than processing the returned C-strings. In this example, the client creates an array of character pointers and stores the C-strings that get_name returns in the array. The problem is that each element of the array points to the same static memory location, the variable name defined in get_name. So, each element in the array points to name, which only stores the last name the user enters.
Scenario (a) is more common than scenario (b), making static data a good choice for solving the initial memory deallocation problem.

Dynamic Data

char* get_name(int size)
{
	char*	name = new char[size];
	cin.getline(name, size);
		.
		.
		.
	return name;
}
A pointer variable called 'name' points to a character array allocated on the heap with the new operator.
(a)(b)
Dynamic data. The new operator allocates memory from the heap rather than from the stack, and the memory remains allocated until the program explicitly deallocates it with the delete operator. In this version of get_name, the memory allocated by new is not deallocated when the function ends and remains available to the client calling get_name.

Dynamically allocating memory with the new operator allows a more general solution (at least for one-dimensional arrays). The function allocates memory based on the size parameter, providing a more "customized" fit for the anticipated data whenever the program calls get_name.

Dynamic data, just like static data, has advantages and disadvantages. The new operator allocates a new block of memory for each C-string that get_name reads and returns. Therefore, each returned C-string has a unique memory location.

void client()
{
	char* data;

	data = get_name(100);
		. . .
	data = get_name(100);
		. . .
}
 
void client()
{
	char* data[10];

	data[0] = get_name(100);
	data[1] = get_name(100);
		. . .
}
A picture of a character array. The first two elements point to two distinct C-strings.
(a) (b)
Consequences of dynamic data. Each call to get_name returns a different, unique address: the address of memory newly allocated by the new operator. Therefore, subsequent calls to get_name do not overwrite the data returned by the previous call. This behavior solves the problem with static data presented above but creates a different opportunity for a logical error. The code fragments of parts (a) and (b) are identical to Figure 3, but the picture illustrates the difference between the static and the dynamic implementations of get_name.
  1. Each call to get_name returns the address of a different C-string whose memory the function allocates on the heap. To avoid creating a memory leak, the program must delete or save the address before calling get_name again.
  2. The client may store the returned C-strings without copying them because they are each a new C-string allocated on the heap.
It takes more time to manage dynamic memory than stack memory, but the difference is small and generally worth the increased flexibility. So, the only disadvantage of the dynamic solution is the need to explicitly delete the returned C-strings to prevent them from becoming unrecoverable garbage.

Calling-Scope Data

void client()
{
	char data[100];
		.
		.
	get_name(data, 100);
	// use data
}
char* get_name(int size, char* name)
{
	cin.getline(name, size);
		.
		.
		.
	return name;
}
(a)(b)
The picture illustrates the parameter 'name' as a pointer variable pointing to the character array named 'data'.
(c)
Calling-scope data. Programs allocate memory for non-static local variables when they call a function and only deallocate it when the function ends. When one function calls another, the first function remains active (see ways of viewing a function call), and its local variables remain allocated. This observation suggests another solution to our initial problem. This solution solves the problem by changing the scope where the program defines the array and allocates memory.
  1. The client code, the code that calls get_name, defines a character array on the stack and passes it to get_name.
  2. The variable named data is inaccessible in get_name, but name's address is passed to the parameter name (remember that the name of an array is the array's address). So name points to data.
  3. The picture illustrates the relationship between data and name: name is a pointer that points to (i.e., stores the address of) data.
Pass-by-pointer is an INOUT passing mechanism. So, it isn't necessary for get_name to return name, but it allows programmers to use the function call as an expression:
cout << get_name(data) << endl;
We can find examples of this convenience technique in the C-string API (see the standard versions of strcpy and strcat).
char* get_name(int size, char* name = nullptr)
{
	if (name == nullptr)
		name = new char[size];

	cin.getline(name, size);
		. . .
	return name;
}
void client()
{
	char* data = get_name(100);
	// use data
		.
		.
		.
		.
}
(a)(b)
Combining calling-scope and dynamic data. At the expense of a modest increase in complexity, we can make get_name even more flexible. The client must specify the array's size, but the default argument makes passing an array optional.
  1. If the client allocates memory and passes it to get_name, the function will use it. But conveniently, the client can allow get_name to allocate and return the memory. Note that the parameter order is significant (see Default Argument Rule 1).
  2. By accepting the default argument, the client signals that get_name should allocate memory with new.