EthicsComputerCourses
Join training +91 90532 10052

Legacy C++: Raw Memory, C Arrays & Casts

C++ · Lesson 30 of 46 Intermediate 8 min read Updated

Many Indian IT jobs in C++ involve existing code: banking systems, telecom software, embedded devices and game engines that have been maintained for ten or twenty years. Much of that code was written before C++11. It owns memory through raw pointers, passes C arrays with a separate length, uses char* strings, C-style casts and macros. You need to read it confidently, change it without breaking it, and know which modern tool replaces each old pattern.

From C++98 to C++23 gave you the big picture. This lesson goes deeper into the patterns that cause real bugs in old code, and shows how to make them safe step by step.

Raw ownership and the rule of three

Before smart pointers, a class that owned heap memory had to manage it by hand. Here is a typical C++98 class:

C++
#include <cstring>

class Name {                                   // C++98 style
public:
    explicit Name(const char* text) : data_(new char[std::strlen(text) + 1]) {
        std::strcpy(data_, text);
    }
    ~Name() { delete[] data_; }
    const char* c_str() const { return data_; }
private:
    char* data_;
};

int main() {
    Name a("Ananya");
    Name b = a;          // copies the POINTER: a and b share one buffer
}                        // both destructors delete[] the same memory: double free

The compiler-generated copy constructor copies each member, so b.data_ points at the same memory as a.data_. This is a shallow copy. When main ends, both destructors free the same block, which is undefined behaviour. The old fix is the rule of three: a class that needs a destructor almost always needs its own copy constructor and copy assignment operator too.

C++
#include <cstring>
#include <print>
#include <utility>

class Name {
public:
    explicit Name(const char* text) : data_(new char[std::strlen(text) + 1]) {
        std::strcpy(data_, text);
    }
    Name(const Name& other) : Name(other.data_) {}     // deep copy
    Name& operator=(Name other) {                       // copy-and-swap
        std::swap(data_, other.data_);
        return *this;
    }
    ~Name() { delete[] data_; }
    const char* c_str() const { return data_; }
private:
    char* data_;
};

int main() {
    Name a("Ananya");
    Name b("Ishaan");
    b = a;
    std::println("{} {}", a.c_str(), b.c_str());
}
Output
Ananya Ananya

The assignment takes its argument by value (that makes the copy), swaps buffers with it, and lets the parameter's destructor free the old buffer. This copy-and-swap idiom is common in older code because it is safe even if copying throws. The modern answer is much shorter: store a std::string, write no special functions at all, and follow the rule of zero from Destructors & RAII.

Also check every delete in old code against its new:

Allocated withMust be freed withModern replacement
new Tdelete pstd::make_unique<T>()
new T[n]delete[] pstd::vector<T> or std::make_unique<T[]>(n)
std::mallocstd::freeContainers; malloc does not run constructors

Mixing the forms (delete on an array, free on new memory) is undefined behaviour, and AddressSanitizer reports it as an alloc-dealloc mismatch.

C arrays, pointers and lengths

Old functions usually take a pointer to the first element and a separate count. Inside such a function the array's size is lost, and the famous sizeof trick silently gives the wrong answer:

C++
#include <print>

void show(int marks[]) {                    // really: int* marks
    std::println("inside: {}", sizeof(marks) / sizeof(marks[0]));
}

int main() {
    int marks[5] = {78, 92, 65, 88, 71};
    std::println("outside: {}", sizeof(marks) / sizeof(marks[0]));
    show(marks);
}
Output
outside: 5
inside: 2

Inside show, marks is a pointer, so sizeof(marks) is the size of a pointer (8 bytes on a typical 64-bit system) divided by the size of an int (4 bytes). GCC warns about this with -Wall. When you cannot change an old function's signature, wrap the pointer and count in a std::span as soon as they enter new code. A span keeps them together, works with range-for, and can be bounds-checked by hardened libraries:

C++
#include <span>

int total(const int* marks, int count) {                    // old interface stays
    std::span<const int> view(marks, static_cast<std::size_t>(count));
    int sum = 0;
    for (int m : view) {
        sum += m;
    }
    return sum;
}

C strings

Legacy code stores text in char arrays ending with a zero byte ('\0'). Functions such as strcpy and sprintf write past the end of the array if the text is too long, a classic buffer overflow. If you must fill a fixed buffer, use std::snprintf, which never writes more than the size you give it and returns the length it wanted to write, so you can detect truncation:

C++
#include <cstdio>
#include <cstring>
#include <print>
#include <string>

int main() {
    char city[8];
    int needed = std::snprintf(city, sizeof city, "%s", "Thiruvananthapuram");
    std::println("needed {} chars, kept: {} ({} chars)", needed, city, std::strlen(city));

    std::string full = "Thiruvananthapuram";
    std::puts(full.c_str());        // hand a std::string to a C function
}
Output
needed 18 chars, kept: Thiruva (7 chars)
Thiruvananthapuram

Seven characters plus the terminating zero fill the 8-byte buffer. In new code keep text in std::string and call .c_str() (or .data()) only at the moment you call an old C function. Going the other way is easy too: std::string s = some_c_string; copies the text.

C-style casts and the named casts

A C-style cast (T)x tries several kinds of conversion until one compiles, including ones that remove const or reinterpret raw memory. It hides dangerous conversions inside harmless-looking syntax. C++ has four named casts, each allowed to do one kind of job, and each easy to search for:

CastUse it for
static_cast<T>(x)Ordinary conversions: double to int, enum to number, base to derived when you are sure
dynamic_cast<T*>(p)Checked downcast in a class hierarchy; returns nullptr if the object is not a T
const_cast<T*>(p)Removing const to call an old API that forgot it but does not write
reinterpret_cast<T*>(p)Viewing memory as an unrelated type; low-level code only
C++
const int seats = 72;
int* p1 = (int*)&seats;                    // compiles: const silently removed
*p1 = 80;                                  // undefined behaviour: seats is really const

// int* p2 = static_cast<int*>(&seats);    // does not compile: static_cast keeps const

When you modernise, replace each C-style cast with the named cast that states its intent. If only reinterpret_cast or const_cast will compile, you have found code that deserves a closer look.

Macros

Old code uses #define for constants and small "functions". A macro is pure text replacement, so arguments can be evaluated twice:

C++
#include <algorithm>
#include <print>

#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main() {
    int tickets = 5;
    int biggest = MAX(++tickets, 4);           // ++tickets appears twice after expansion
    std::println("macro:    biggest = {}, tickets = {}", biggest, tickets);

    int seats = 5;
    int largest = std::max(++seats, 4);        // a real function: evaluated once
    std::println("std::max: biggest = {}, tickets = {}", largest, seats);
}
Output
macro:    biggest = 7, tickets = 7
std::max: biggest = 6, tickets = 6

The macro expands to ((++tickets) > (4) ? (++tickets) : (4)), so tickets is increased once for the comparison and again for the result. Replace function-like macros with constexpr or template functions and constant macros with constexpr variables. Macros are still the right tool for include guards and for code that must differ per platform (#ifdef _WIN32).

Wrapping C APIs with smart pointers

Old and C libraries return handles that you must release with a special function. A std::unique_ptr with a custom deleter turns such a handle into an RAII object without writing a whole class:

C++
#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(std::FILE* f) const { std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

int main() {
    FilePtr log{std::fopen("backup-log.txt", "w")};
    if (log) {
        std::fputs("Backup finished\n", log.get());   // get(): raw pointer for the C function
    }
}   // fclose runs automatically, even if an exception is thrown

unique_ptr only calls the deleter when it holds a non-null pointer, so a failed fopen is handled correctly.

Common mistakes

  • Copying a class that owns a raw pointer without a user-written copy constructor: double free.
  • Using delete for new[] memory, or free for new memory.
  • Trusting sizeof(arr) / sizeof(arr[0]) inside a function. The array has already become a pointer.
  • Using strcpy or sprintf into fixed buffers. Use std::string, or std::snprintf with the buffer size.
  • Silencing a compiler error with a C-style cast. The error was probably right.

Key points

  • Pre-C++11 classes that own memory need the rule of three; modern classes use members such as std::string and need none.
  • Match new with delete, new[] with delete[], malloc with free, or better, avoid all of them.
  • C arrays decay to pointers and lose their size; wrap pointer and count in std::span.
  • Use std::string and .c_str() at C boundaries; never strcpy into a fixed buffer.
  • Replace C-style casts with named casts and function-like macros with constexpr functions.

Exercise

An old billing module has char* make_invoice_id(int number) that returns new char[16] filled with sprintf(buf, "INV-%d", number), and callers must remember delete[]. Write a modern replacement that cannot leak or overflow, and show it with invoice number 2026.

Show solution
C++
#include <format>
#include <print>
#include <string>

std::string make_invoice_id(int number) {
    return std::format("INV-{}", number);
}

int main() {
    std::string id = make_invoice_id(2026);
    std::println("{} has {} characters", id, id.size());
}
Output
INV-2026 has 8 characters

std::string owns its memory, so there is nothing to delete, and std::format sizes the text itself, so there is no fixed buffer to overflow. If an old C function still needs a const char*, pass id.c_str().

Quick Quiz

1. A C++98 class has a destructor that calls delete[] on a member pointer, but no copy constructor. What happens when an object is copied?

2. Inside void f(int a[]), what is sizeof(a)?

3. Which cast should you use to convert a double price to int paise?

4. Why is #define MAX(a, b) ((a) > (b) ? (a) : (b)) risky?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call