13.3 SAVE/OPEN

If you are practically inclined and you like to learn from simple examples rather than to study the documentation, look at the 3 sets of examples in directory orgc\test: testa* testb* and testc*. The description of how to compile and run them is toward the end of file orgc/test/readme.txt. This information is only in DOL Ver.5.9 and higher. If you have an older version, download these examples as a zip file.

The prime purpose of the save() and open() is, in a single command, to save and retrieve the entire data space to/from the disk. This includes all the objects and the relations they form. Later on, we will discuss advanced features such as saving individual objects, saving data in several pieces, saving the entire data space and updating a few objects later, plus the version control of both your program and of DOL. Let's start with the basic use of save() and open().

In practically any program, objects and their relations form a network, which connects all the objects together. Also, there is usually one or a few root objects -- we call them 'key entries' -- which have the property that if we start from them and follow the data structure links, we will reach all the objects the program is using. This does not mean that we can reach all the objects from any key entry; rather any object can be reached from at least one key entry.

Sometimes, there are a few additional objects which play some central or key role in the data structures, and you may want to know how to access these important objects after you download the data from the disk. In that case, include also these objects as 'key entries'.

When you want to save your data to disk, you call void save() and give it the name of the file where you want the data to store, number of key entries, key entries as (void*) pointers, and names of the classes of the key entries. For example:


class A {
    ZZ_EXT_A
    ...
};
class B {
    ZZ_EXT_B
    ...
};
class C {
    ZZ_EXT_C
    ...
};
... many other classes ...
... many ZZ_HYPER statements ...
ZZ_HYPER_UTILITIES(util);

A *a; B *b; C *c; ...
void *v[2]; char *t[2];    // two key entries a,b

...
v[0]=a; t[0]="A";
v[1]=b; t[1]="B";
util.save("myFile",2,v,t); // saves the entire data space to the disk

Function open() uses the same syntax, but in reverse: It moves the data from the given file to memory, updates all the pointers, and returns the given number of key entries. Naturally, the number of key entries must be the same when saving and retrieving the data.


util.open("myFile",2,v,t); // retrieves the entire data space from the disk
a=(A*)(v[0]); // first key entry, must cast
b=(B*)(v[1]); // second key entry, must cast

Function clear() as similar parameters as save() and open(), except that no file name is given. This function traverses the memory data and destroys it. There are also two ways to check that the save/open operations worked correctly. If util.error() returns a non-zero value, and error occured. Also, after open(), no returned key entry should be NULL, and the type names should agree with those used when saving the data. Here a sample of the code which demonstrates these features, this time using only a single key entry. Note that save() and open() can be used multiple times, using the same or different file names:


class A {
    ZZ_EXT_A
    ...
};
... other classes
... ZZ_HYPER statements
ZZ_HYPER_UTILITIES(util);

A *a1, *a2, *a3; void *v[1]; char *t[1];

....
v[0]=a1; t[0]="A";
util.save("file1",1,v,t);
if(util.error())... // error recovery

util.clear(1,v,t); // destroy all the data in memory
...

v[0]=NULL; // good for error detection
util.open("file1",,v,t); // retrieves the entire data space from the disk
if(util.error())... // error recovery
if(!(v[0]) || !(t[0]) || strcmp("A",t[0]))... another check for correctness
a2=(A*)(v[0]); // key entry for the new data

// Note: If clear() were not used, we would now have two copies of the entire
//       data space in memory, one starting from a1, one from a2
// Note: clear() works only for the memory blasting mode - see the
//       "List of functions and their syntax" at the end of this Chapter.

... calculation which changes the data 
v[0]=a2; t[0]="A";
util.save("file2",1,v,t); // saving into a different file
if(util.error())... // error recovery

...
if(someProblem){  // return to the original data from file1 
    util.open("file1",,v,t); // retrieves the entire data space from the disk
    a3=(A*)(v[0]); // key entry for the new data
    if(util.error())... // error recovery

    v[0]=a2; t[0]="A";
    util.clear(1,v,t); // destroy the old data space

    a2=a3; // replace it by the one from file1
}  

Important: If you plan to save/retrieve your organization from disk, it is imperative that you use the NAME organization for text strings. NAME is the DOL equivalent of the String class. Without using NAME, text strings will not be properly retrieved from the disk. Examples:


class A {
    char *tName; // general advice: avoid using this
    ZZ_EXT_A
public:
    void prt();
};

ZZ_HYPER_NAME(aName,A); // each A object is associated with string aName

void A::prt(){
    cout << tName << "\n"; // print the temporary, non-persistent name
    cout << fwd.aName(this) << "\n"; // print the persistent name
}

Important:

  1. Read about ZZinheritAll, ZZ_INHERIT and the use in of virtual function, Chap.8.1.8.

Other things to remember:

  1. Organizations GENERAL_LINK and SINGLE_LINK will cause problems in save() if the target class does not have ZZ_EXT. This shows up as a run-time error. The DOL code generator (zzprep) cannot check for this condition.
  2. If you use arrays, avoid referencing into an array by pointer. That includes, for example, an array of objects which also form a linked list. Even though DOL can handle such situations (the array must not re-allocated itself - must have declared as having a fixed size), this is a potentially dangerous practice that should be avoided. Try to always reference arrays by indices.
  3. If you plan to remove all the data by calling clear(), do not use automatically allocated objects. clear() will include such objects in the set of all objects, and will attempt to deallocate them, which will result in a crash.
  4. When ZZ_INHERIT is defined in the environ.h file, which happens in most C++ programs, the function util.clear() works only in the memory blasting mode. It is internally disabled (bypassed) in the other two modes - see the syntax section.
  5. Memory blasting works only in environments where sizeof(char*)==sizeof(int).

Examples:


class Apple {
    ZZ_EXT_Apple
public:
    int cIndex;
    Cherry *cPtr;
    char colour;
};
class Plum {
public:
    float size;
};
class Cherry {
    ZZ_EXT_Cherry
public:
    int weight;
};
class Holder {
    ZZ_EXT_Holder
};
ZZ_HYPER_ARRAY(arr,Holder,Cherry);
ZZ_HYPER_GENERAL_LINK(gLink,Apple);
ZZ_HYPER_SINGLE_LINK(cLink,Apple,Cherry);
Apple a,*aa;
Plum p,*pp;
Cherry c,*cc;
Holder h,*hh;
// CASE (1)
gLink.add(&a,&p); // is correct, but a and p cannot be saved unless ZZ_EXT_Plum is used 
// CASE (2) 
cc=arr.ind(&h,8);
a.cIndex=8; // correct reference into array
cLink.add(&a,cc); // incorrect reference into array
a.cPtr=cc; // incorrect reference outside OrgC
cLink.add(&a,&c); // correct, not into array
a.cPtr=(&c); // correct, but save will not include this 
// CASE (3)
hh=new Holder;
// util.save() can save both &h and hh, but
// util.clear() can be called only on hh 

Note that when saving in binary format (none of these examples saves in only(!) binary), ZZ_FORMAT() statements are not required.

Three formats of saving data to disk

DOL provides three optional formats for saving data to disk:

  1. The binary format is the default. It is the easiest one to incorporate into your program. It runs faster and takes less disk space than the ASCII format.
  2. The ASCII format is the only format portable across different platforms and compilers. It also supports version control and schema migration. For example, you can have the same program running on a Sun workstation (under UNIX), and on an IBM PC (under WinNT). If you save() the program data on the Sun, and open() the file on the PC.
  3. Memory blasting far outperforms the other two methods, but it is trickier to use. Also, destroyed object are not removed from the memory, so it may be necessary to combine this method with the use of built-in free lists.

Examples:

Almost every test program in the orgc/test directory demonstrates saving to disk.

  1. test0n.c shows netlist processing with ASCII saving, test0m.c is another variation of the same problem with both binary and ASCII saving in the same program.
  2. test23a.c demonstrates the saving of objects which contain virtual functions.
  3. test16c.c saves arrays, selfID, timeStamp, and properties.
  4. test23f demonstrates saving with memory blasting.

Method 1: Saving in binary format

If you don't invoke any special features and use save() and open() just as we showed above, the storage is performed in the default mode, using a 'binary format'. This means that the data space is stored in a binary form, as opposite to the ASCII - human readable format.

When you call the DOL code generator zzprep prior to the compilation, zzprep analyzes all the class definitions marked with ZZ_EXT (this is equivalent to a partial compilation), and generates the serialization functions. Note that this happens automatically, and it guarantees a match between the output and input serialization function. If you have used a system where serialization functions have to be manually coded, you know that it is a lot of work, and the result is highly error prone.

zzprep stores the definitions of the serialization functions under ZZ_EXT_.. statements, and their implementation in one of the files it generates, zzfunc.c. Also, under the ZZ_EXT_.. statements, zzprep stores all the pointers which form the data structures. Since DOL knows where these pointers are, the save() command can traverse all objects, starting from the key entries, and following these pointers from object to object.

The disk file starts with a header, which describes details of how all your classes are constructed: Their sizes, inheritance, member objects and their types, where are the data structure pointers, and what are their names. This information is essential for making sure that the data on the file are compatible with the version of the program you are using. Also, as you will learn later (see "Managing Changes" below) DOL provides an automatic version control, which allows to read old data files even if your classes or relations change. We are very proud of this feature, and believe that no other persistent system or OODBS have it, at least not in this extent.

The first objects recorded on the file are the key entries, then follow other objects. Each object is written to disk in two records: The first record describes the class of the object, whether it is a single object or an array, and its memory location. The second record is a plain copy of the object, byte-by-byte, as it is in the memory - including the internal pointers.

Saving to disk is relatively fast, because there is no formatting or massaging of data. Objects are stored as blocks of bytes. When restoring the objects from the disk (the open() function), objects are allocated in new places in memory, while keeping a table of their old and new address. After all objects are read to memory, DOL walks through them, and resets the internal pointers to new values (remember, DOL knows where the data structure pointers are, because zzprep created them. Replacement of the pointers is again quite fast, using hashing and only occassionally binary search.

Method 2: Saving in ASCII format

The use of save() and open() is the same as for the binary format. Also the internal algorithm is the same, but the storage to disk is a human-readable format, using ASCII. The main advantage of the ASCII format is that it is portable among different platforms. Note a potential problem with the interpretation of class members:


class X {
    ZZ_EXT_X
    char c;
    int i;
    char t[20];
    float f;
    ...
};

Is c a printable character or a short integer, and if latter, is it signed of unsigned? Is t a NULL ending string, or always 20 characters? What if t represents a name which includes blanks, such as "John Scrivens", how could we record that? Should f be printed in the decimal or exponencial format? Because only the program designer knows the meaning of the data members, it is much safer to let the programmer to specify this. When using the ASCII mode, the programmer must, at least in one *.h file, declare


    #define ZZascii

Also, the programmer must, for every class, provide a ZZ_FORMAT() statement. This statement describes, in the style used for printf(), the format for writing out all the object members. The format does not include the hidden (internal) data structure pointers. DOL knows about them, and knows how to interpret them in the serialization functions. Note that it is not necessary to store all members - temporary variables may be missing in the ZZ_FORMAT() statement and also in the disk image, resulting in a saving of the disk space.

DOL writes out each object as three ASCII records: The first record is a header similar to the header used in the binary saving, except that the values are in ASCII (readable numbers). The second record contains all the internal pointers, printed as integers. The third record contains all the members, printed in the format specified in the ZZ_FORMAT() statement.

The small inconvenience of writing the ZZ_FORMAT() statements gives is compensated by a great flexibility of this method. Since zzprep generates both the input/output serialization functions from the same ZZ_FORMAT(), they are guaranteed to match. It is much safer than to code serialization functions by hand. example:


#define ZZascii
#define ZZmain
  ...
class X {
    ZZ_EXT_X
    unsigned char c;
    int i,flg;
    char t[20];
    char *temp; // non-persistent pointer
    float f;
    ...
};
ZZ_FORMAT(X,"%d %o %s %f,c,i,t,f");
In the previous example, temp and flg are not stored on disk. Note the syntax slightly different from printf: Both the format and the parameter names are passed as a single text string. Also note that the order of the members does not have to be the same as in class X. For example, the following format provides the same functionality:


ZZ_FORMAT(X,"%s %d %o %f,t,c,i,f");

Exaple of using ZZ_FORMAT():

class Obj1 {
    ZZ_EXT_Obj1
    int a,b;
    float x;
    char c;
};
ZZ_FORMAT(Obj1,"%d %d %f %a,a,b,x,c");
class Obj2 {
    long int a;
    ZZ_EXT_Obj1
public:
    char *temp;
    float x;
};
ZZ_FORMAT(Obj2,"%e %lu,x,a");
class Obj3 {
    ZZ_EXT_Obj3
};
ZZ_FORMAT(Obj3,"");
ZZ_HYPER_SINGLE_RING(ring1,Obj1);
ZZ_HYPER_SINGLE_TRIANGLE(ring1,Obj1,Obj2);
ZZ_HYPER_SINGLE_LINK(ring1,Obj1,Obj3);

Note: When the object is used only as a part of the data structure, and has not attributes to be saved on the disk, an empty ZZ_FORMAT() still has to be provided. For example: ZZ_FORMAT(myClass,"");

Method 3: Memory blasting

This method of saving to disk is dramatically (order of magnitude) faster than the binary/ASCII formats. However, it requires a more careful use, and setting of some parameters. When designing new software, we recommend to use the binary or ASCII method of save first, and only when the program is debugged, switch to memory blasting by changing a few parameters.

In memory blasting, objects are allocated from pages of memory. A small portion of each page (about 3%) is used as for a binary map, which marks internal locations of the data structure pointers inside of all objects on the given page. These bits are set already when individual objects are allocated.

When saving data to disk, entire pages are directly copied (blasted) to disk, without looking at individual pages. This is extremely fast. Automatically allocated objects do not transfer to the disk, only objects allocated from the heap, using the operator new.

When retrieving data from the disk, entire page are copied back to memory. After that, DOL walks through the bit maps, and identifies data structure pointers without paying attention to in what objects they are. When the size of the pages is a power of 2, the conversion of the pointers requires only several computer instructions - no search or hashing is used. Detailed of this smart algorithm are described in Chapter 8 of "Taming C++: Pattern Classes and Persistence for Large Projects" by Jiri Soukup, published by Addison-Wesley 1994, ISBN 0-201-52826-6.

In order to invoke memory blasting, you just add two lines before the first call to new, usually in the begining of main:


util.mode(0,0,0,0);
util.blkAlloc(sizeEstimate,pageSzBits);

For more details about the mode() function, see below. sizeEstimate gives the maximum size of memory your data structures may use. As explained, the size of pages must be a power of 2. For this reason, pageSzBits provides the power. For example, if you want pages 1024 bytes each, and your overall data storage will not exceed 1,000,000 bytes, you invoke:


util.mode(0,0,0,0);
util.blkAlloc(1000000,10);  // 2**10 = 1024

WARNING: If you cannot estimate the memory needed for your data, you can specify sizeEstimate=0. In this case, DOL will assume that you plan to use all the address space (2**32). For example, if your page size is 2**10, this means that you can have up to 2**22 pages. Memory blasting reserves an array with one pointer (4 bytes) for each page, so unless your pages are large, this internal array can be quite large and block, unnecessarily, too much of your resources (e.g. an array with 2**22 entries, 4 bytes each, takes about 17MB).

NOTE: With memory blasting, key entries can even point inside of objects (to a base-object), which is not permitted in other saving methods.

For examples of memory blasting, see test23f.c, test23g.c, test34b.c and test34c.c.

Changing the mode of saving

Test programs test34a.c, test34b.c, test34c.c, and test34d.c demonstrate how - within one program run - you can open data in one format, and save it in another. All you have to do is to reset it by calling util.mode(...) with appropriate parameters before the next save() or open(). If one of the storage modes is ASCII, then #define ZZascii and ZZ_FORMAT() statements must be present.


// the following two lines invoke memory blasting
util.mode(0,0,0,0);
util.blkAlloc(sizeEstimate, pageSzBits);  
...
util.mode(1,0,0,0); // invokes ASCII mode
...
util.mode(0,1,0,0); // invokes binary mode
...
util.blkFree(1);   // destroys (cleans up) pages used by memory blasting
util.clear(n,v,t); // cleans up the memory space used by the other two modes

Functions mode(), blkAlloc(), and blkFree() are useful in advanced storage of data, and their full syntax and meaning of individual parameters will be described later.

Saving individual objects

Except for the "List of functions and their syntax" at the end of this chapter, everything which will follow now can be considered the advanced use of the disk storage utilities. If you are a new user, read this part as an overview of what DOL can do, but unless you are experienced in using save() and open() with all the three basic saving (binary, ASCII, memory blasting), experimenting with the advanced features will bring you only frustration and a false impression that the library does not work.

There are situations, where it may be beneficial to save explicitely individual objects rather than relying on DOL to traverse all your data. For example, if all your data form a linked list, traversing this list and storing the objects one-by-one is faster than the sophisticated DOL algorithm which must cover some general, tricky cases. You also need less memory to perform this. The internal algorithm uses an array of pointers, one for each object. For large data sets, this can be a very large array.

Another situation where this may be useful is for saving updates of a small number of objects. We can take the advantage of the fact that both the binary and ASCII formats accept multiple copies of the same object on the file. If this happens, the latest copy is retrieved when you 'open' the file. A call to save() records each object on the file exactly once, except perhaps for the key entries, but consider the following situation. Assume we recently 'saved' the entire data image and, within the same run, a few objects change. If we record the new versions of those objects at the end of the file, we can avoid another call to save() - a significant performance enhancement.

If you alread called save(), the class tables and the key entries are already recorded at the beginning of the file. The only problem is that, in the default mode, the save() commend closes the output file. Since we want to add object at the end of the file, we must arrange that save() does not close the file, and then when we are finished with adding the few objects that changed, we must close the file explicitely.


util.mode(0,0,0,2); // last parameter specifies not to close the file
util.save("myFile",...);     // calling as usual
... update some objects, and record the update ...
util.close("myFile");

If you you are storing all the object individually, you have start with the key objects. When you record the first key object, DOL automatically initiates the file with the description of all your classes. In order to register key entries, call ZZ_KEY_SAVE(). For all other objects, call ZZ_STORE() or util.deep(). ZZ_STORE() stores a single object (shallow copy), while util.deep() stores all other objects that can be reached from it - through class membership, inheritance, or data structure pointers (more than a deep copy). At the end you have to close the file by calling util.close(fileName).

ZZ_KEY_SAVE() and ZZ_STORE() are macros, and should be encapsulated under the classes that use them. For example:


class myObj {
    ZZ_EXT_myObj
public:
    void saveObj(char *fileName){ZZ_STORE(myObj,fileName);}
    void saveKey(char *fileName){ZZ_KEY_SAVE(myObj,fileName,this);}
};
    
myObj *p,*r;
r->saveKey("myFile");
p->saveObj("myFile");

The order in which the objects are saved is irrelevant, except that the key entries must be written out first. If you have only one key entry, or if all key entries are simple objects (no inheritance, no member objects), no calls to ZZ_KEY_SAVE() are required. The first object or objects automatically become key entries.

All objects must be written out; if you forget even a single object, util.open() will detect the error when restoring the data from disk. A missing object means that some pointers will lead to unrecognized memory locations.

As explained above, storing any object several times does not cause an error, even if the copies differ. The program eliminates duplications automatically (the last version written out is accepted as valid). However, multiple copies increase storage and increase the required CPU time. The option of being able to write out multiple copies is convenient, but should not be overused.

When saving a text string, use

char *fileName, *ptr; int n;
ZZ_OBJECT_SAVE(char,fileName,ptr,0); /* for NULL ending string */
ZZ_OBJECT_SAVE(char,fileName,ptr,n); /* for buffer of n bytes */

If your data has a single root, a call to util.deep() has essentially the same effect as util.save(), except that you must record the key entries first, then call deep(), and finally close the file.

Important: Retrieving data from disk is always done in a single command, util.open(), regardless of whether you stored individual objects with ZZ_STORE() or with util.save().

Multiple files, private labels

In rare situations, the programmer may need more control in how the data is saved, for example splitting the data into several files, each with its own identification label or other information stored in the beginning of the file. The main steps involved in doing this are:

  1. Call util.mode() with the last parameter cntrl=2, before the first command which writes to disk, and open the file.
  2. Write your label or private information to the disk. Make sure you know where you are positioned on the disk.
  3. Save the data using either save() or by ZZ_STORE().
  4. Close the file by util.close().
  5. Repeat these steps for other files.
  6. When reading the data from the disk, use again util.mode() with the last parameter cntrl=2, open each file yourself, read the label or your private information, and then call open().
  7. After reading all the data back from the disk, pointers between the data stored in different files can be restored by calling util.bind(). This pointer conversion must be carefully planned, because util.bind() has the following limitation: If you are restoring data from the files file1, file2, and file3 (in this order), pointers between file1 and file2, and between file2 and file3 can be recovered, but not pointers between file1 and file3.

Example:

The following code demonstrates how to add your own label at the beginning of the file, or/and some private data at its end. Note that util.save(), ZZ_OBJECT_SAVE(), ZZ_STORE(), ZZ_KEY_SAVE(), and util.open() must not be given file name, but rather a file pointer instead. The code below assumes the ASCII format. For the binary format, there would be mode(0,1,0,1), and fwrite(..,fp) would be used instead of fprintf(fp,...):

Saving data to disk:

util.mode(1,0,0,1);
FILE* fp=fopen("myfile","w");
fprintf(fp, ...); // adding label at the beginning of the file
util.save((char*)fp,...);
fprintf(fp, ...); // private data at the end of the file
...
fprintf(fp, ...);
fclose(fp);

Retrieving data from disk:

util.mode(...,1);
FILE* fp=fopen("myfile","w");
fgets(buff,BSIZE,fp); // read your label
sscanf(buff,...); // decode the line
util.open((char*)fp,...);
fgets(buff,BSIZE,fp); // read private data
sscanf(buff,...); // decode it
... // must be synchronized with how the data was created
fgets(buff,BSIZE,fp);
sscanf(buff,...);
fclose(fp);

Another example: Assume your data consists of several parts connected only by GENERAL_LINK's, and you want to save it in the binary format. You first call util.mode(0.1.0.2), then call util.save() several times (one call for each part). Close the output file with util.close():

util.mode(0,1,0,2);
util.save(...);
util.save(...);
util.close(...);

Active and passive blocks

The mechanism of storing data to disk is closely related to how the data is allocated. This applies in particular to memory blasting, where objects are allocated from pages of memory, which then are directly copied to the disk. The bit map which marks the locations of all pointers on these pages is set right when each object is allocated.

On the other hand, if different data sets are allocated from different pages, when we do not need one particular set any more, we can destroy its pages without looking at the individual objects inside them - assuming no other objects point into these pages. The destruction of objects in C++ can be quite expensive, and this simple technique can significantly improve the program performance. For example, when we applied this idea to the core software of a telephone switch, the overall speed of the switch improved three times!

Consider how we invoke the memory blasting mode:


util.mode(0,0,0,0);
util.blkAlloc(sizeEstimate,pageSzBits);

The call to util.blkAlloc() creates an 'active' block of pages. Word 'active' means that any call to new() will allocate the object from these pages. However, if we then call


util.blkActive("blk1",0);

It makes the block 'passive', and gives it the refence name, blk1. In order to allocate more objects, you need an active block. You either have to establish a new block of pages by calling util.blkAlloc() again, possibly with different parameters, or you can activate one of the passive blocks


util.blkActive("blk3",1); // blk3 must be a passive block we created earlier

Another way to activate a block is to call blkActive(ptr,2) where ptr is a pointer into one of its pages:


char *ptr;
...
util.blkActive(ptr,2); 

In order to free the currently active block, call util.blkFree(0). Other uses of this command: blkFree(1) switches from the reserved pages to the normal C++ allocation, and should be used for temporary objects you don't need to store to the disk. When you want to switch from the normal allocation back to the currently active block, use mblkFree(2).

Any call to util.blkActive() brings you back to memory blasting mode, even if the previous call to util.blkFree() reset allocation to 'normal'.

When allocating objects from the active block pages, use either new() or ZZ_OBJECT_NEW() which also provides, on top of memory paging, a list of free objects by type.

Use new() and constructors as usual, but never place ZZ_OBJECT_NEW() into a constructor. Look at test23b.c for the correct way to use ZZ_OBJECT_NEW().

Sometimes it is useful to remember an important object (or the key entry) for each memory block (block of pages). An example is a situation, where you use several blocks, one for each design view. Function blkUtil() provides the mechanism to do this: it allows you to store, for each block, one utility hook (void *hook).

Coding your own serialization functions

When you declare #define ZZascii and, at the same time, a class has the ZZ_FORMAT() statement, zzprep automatically generates serialization functions for that class, and deposits the code into file zzfunc.c. In fact, it generates two input functions, and two output functions. Always, one function writes/reads the internal data structure pointers (you normally do not have no access to them, and may not even be aware they are in your objects), while the other function writes/reads the attributes which arem under your control. It is the latter function which is generated from the ZZ_FORMAT() statement.

If you declare #define ZZascii, but a class does not have any ZZ_FORMAT() statement, zzprep generates only the functions which write/read the internal pointers, but not the functions which handle your attributes. Since DOL needs these functions to perform saving and opening of the data, you have to code them yourself. These functions are called zz_inp_...() and zz_out_...(), where the name of the class replaces the dots.

class Apple {
    ZZ_EXT_Apple
public:
    char colour;
    int numSeeds;
};
struct Plum {
    ZZ_EXT_Plum
public:
    float weight;
};
#define BSIZE 80
char buff[BSIZE];

Functions you have to code in this case are:

int Apple::zz_out_Apple(FILE *fp,Apple *p){
    fprintf(fp,"%c %d\n",p->colour,p->numSeeds);
    return(0);
}
int Plum::zz_out_Plum(FILE *fp,Plum *p){
    fprintf(fp,"%f\n",p->>weight);
    return(0);
}
int Apple::zz_inp_Apple(FILE *fp,Apple *p){
    if(!fgets(buff,BSIZE,fp))return(1);
    sscanf(buff,"%c %d",&(p->colour), &(p->numSeeds));
    return(0);
}
int Plum::zz_inp_Plum(FILE *fp,Plum *p){
    if(!fgets(buff,BSIZE,fp))return(1);
    sscanf(buff,"%f",&(p->>weight));
    return(0);
}

Rules for writing these functions:

Saving a union

If your class includes a union member, then ZZ_FORMAT() does not have enough flexibility to handle the situation, and you have to code your own zz_inp_...() and zz_out_...() for that class:


    class  A {
        ZZ_EXT_A
        union {     // persistent, to be stored on the disk
            int k;
            char c[4];
        }uni;
        int ss;
        float ff;
        ...
    };

int A::zz_out_A(FILE *fp,A *p){
    fprintf(fp,"%d %d %f\n",p->uni.k;p->ss,p->ff);
    return(0);
}
int A::zz_inp_A(FILE *fp,A *p){
    if(!fgets(buff,BSIZE,fp))return(1);
    sscanf(buff,"%d %d %f",&(p->uni.k), &(p->ss), &(p->ff));
    return(0);
}

The union to be saved must not include a pointer to a persistent object.

If the union handles only temporary (non-persistent) values, ZZ_FORMAT() can be used as normal:


    class  A {
        ZZ_EXT_A
        union {    // temporary, non-persistent values
            int k;
            char c[4];
        }uni;
        int ss;
        float ff;
        ...
    };
    ZZ_FORMAT(A,"%d %f,ss,ff");

Format of the ASCII file

We encourage users neither to modify the format under which the ASCII data is stored, nor attempt to read it directly. The disk saving operation is more complex than it may appear on the surface. We include the format description mostly for curious, advanced users. If you are just starting with DOL, skip to the next section.

,

Before you continue reading, run one of the tests that save in ASCII format, for example test23c.c (includes inheritance) or test0m.c (no inheritance), and have a printout of the ASCII file ready when reading this explanation.

The ASCII file starts with the type table similar to the ZZstrList[] array which appears in your zzincl.h file. The difference is that in the ASCII file, all values are correctly set, while in zzincl.h, some of the values are just defaults that are overwritten by correct values at the beginning of the run. If you are curious about the meaning of individual fields in this table, look at the structure ZZstrLST in file lib/bind.h.

The record of the type table on the disk ends with the line starting ZZendMark ...

The second section describes the transparent pointers used by DOL organizations. These are pointers hidden under ZZ_EXT_.. statements, and you can find their names in the zzincl.h file which zzprep creates in your working directory. In the ASCII file, the name of each pointer is preceded by a special character, indicating its type (usually a for "automatic pointer"), and is appended by the character [ and a number indicating the size of the array (1 for single objects). The size of this section is determined by the third last field on the line with ZZendMark above.

The third section describes the inheritance lattice of the types, and ends with a ZZendMark record. The content of each line is described in the structure ZZtypeHier, as shown in lib/bind.h. Note that each type in the type table has an index into this table (inhInd). If neither inheritance nor object members are used, and (#define ZZ_INHERIT is not present in environ.h), this section is missing (it is not used).

The fourth section contains, object by object, 2 or 3 records per object, depending on its type and content. The same object can appear several times, the last copy is considered valid when opening the file.

The first object record is a header which always contains 4 values: starting address, type index, size in bytes, array size (usually 1).

The second object record contains the transparent pointers, in the order shown under the ZZ_EXT_ statement. This record is produced by the function zz_opt_.. from your zzfunc.c file, and may be missing if there are no pointers.

The third object record contains integers, floats, and other members that you specified under the ZZ_FORMAT statement, or in your zz_out_...() function. If you coded the function, it can be in any file. If zzprep generated it from ZZ_FORMAT(), it was deposited into file zzfunc.c. This record again may be missing, if there are no other values to be stored than the transparent pointers.

In the case of an array, there is one header, and then pairs of lines corresponding to each object in the array.

The file ends with the header record (0 -1 -1 0).

Storing foreign objects

Another problem occurring in practice is that, in addition to regular classes and objects, the programmer wants to store objects from standard/private libraries. Classes for these objects cannot be modified by inserting pointers, or putting the ZZ_EXT_.. statement into them. If these objects are not involved in any organizations except, perhaps, being a target for a SINGLE_LINK or GENERAL_LINK, then this can be done easily using the following method:

// ———— in the other library ———— 
struct foreign { 
    int i,k; 
};
// —— in your code —— 
ZZ_EXT_foreign 
typedef struct foreign foreign;
ZZ_FORMAT(foreign,"%d %d,i,k");

How does this work? The presence of ZZ_EXT.. forces the code generator to register foreign as a recognized class. The class is not involved in any data organization, therefore it has no internal pointers or variables, ZZ_EXT_foreign is empty, and can be anywhere, even outside the class. ZZ_FORMAT() automatically triggers the generation of appropriate IO functions, and the program can save this class like any other class:

foreign *f;
...
ZZ_OBJECT_SAVE(foreign,"myFile",f,1); // saves the object

User-controlled diskIO

When saving binary data to disk, the library is normally using fast binary IO - functions open(), read(), write(), and close(). In exceptional situations, you may want to replace these functions by some other functions, for example when storing backup data to memory instead of to disk.

In order to do this, you have to do two things:

  1. Code your own functions for diskIO, and pass them to the library through a call to ZZinstallUserIO(). Your functions must have types identical to the functions they are replacing. You may replace only open&close or only read&write, and use NULL for the remaining two functions (default will be used), but always the two functions which form a pair must be provided.
  2. Transfer the control to your functions by calling util.mode(0,2,0,0) - this works for binary format only. If you want to return to the default library IO, call util.mode(0,diskIO,0,0) , with diskIO=0 or 1.

Example:

.....
FILE *myOpen(char *fileName,char *mode){ ... }
int myRead(FILE *fp,char *buff,int n){ ... }
int myWrite(FILE *fp,char *buff,int n){ ... }
int myClose(FILE *fp){ ... }
.....
ZZinstallUserIO(myOpen,myRead,myWrite,myClose);
util.mode(0,2,0,0);

Managing changes

As your program evolves, you may encounter two types of changes which may affect your ability to read your old data files:

  1. New version of DOL. As DOL grows and improves, we try our best not to change the format of the disk storage. Over the 12 years of the DOL existence, it happened only twice but, theoretically, the possiblity is there.
  2. Different data structures. New data organizations are added or some of the old data organizations are removed. The types or names of some data organizations change.
  3. Changes of classes, adding or removing attributes, possibly adding new classes or discontinue old ones. Inheritance changes.

A change of DOL version is easy to handle. Function mode() allows you to set input and output format to two selected version of DOL.

When a new data organization is added, DOL handles the conversion. After retreiving the old data, the new organization is set as disconnected (or better to say: not connected yet).

When a data organization is removed, DOL again handles the conversion. Since the internal pointers which originally formed the organization do not exit any more, the organization naturally disappears.

Important: If any of the type parameters in a ZZ_HYPER_..(id,type1,...) statement change, id also must change. Essentially, the situation is handled as one data organization being cancelled, and another one introduced. The data organization disconnects, and is not properly copied into the new environment.

The number of key entries may be reduced. For example, if the data was saved with 3 key entries that correspond to types (Instance, Net, Instance), when reading the new data from disk only two key entries are returned (Instance, Instance). For more details see C examples in test22a.c and test22b.c.

Note that when the new organization does not include the object type originally specified as the key entry, open() returns NULL for the corresonding key entry

.

Netlist example - changing members and the organizations
(see also C-code examples in test7c.c and test7i.c):

This example describes connectivity of an electric circuit such as a VLSI chip, or a printed circuit board. Assume that the old data had 3 classes with the following data structures:

class Instance{
    float current;
    ZZ_EXT_Instance
};
class Net{
    ZZ_EXT_Net
};
class ActTerm{
    ZZ_EXT_ActTerm
    int x,y;
};
ZZ_HYPER_SINGLE_RING(iRing,Instance);
ZZ_HYPER_SINGLE_RING(nRing,Net);
ZZ_HYPER_SINGLE_TRIANGLE(byInst,Instance,ActTerm);
ZZ_HYPER_SINGLE_TRIANGLE(byNet,Net,ActTerm);
ZZ_HYPER_NAME(iName,Instance);
ZZ_HYPER_NAME(nName,Net);
ZZ_HYPER_TIME_STAMP(Net);

In the new arrangement, we decided to drop class Net with all organizations related to it. However, we want to add a doubly-linked ring for all ActTerms, and also keep a name for ActTerm. This is a major change of the architecture. Note that the attribute current has been removed from type Instance, and a new attribute shape  has been added to type ActTerm.

class Instance{
    ZZ_EXT_Instance
};
class ActTerm{
    ZZ_EXT_ActTerm
    int x,y;
    int shape;
};
ZZ_HYPER_SINGLE_RING(iRing,Instance);
ZZ_HYPER_SINGLE_TRIANGLE(byInst,Instance,ActTerm);
ZZ_HYPER_NAME(iName,Instance);
ZZ_HYPER_NAME(tName,ActTerm);
ZZ_HYPER_DOUBLE_RING(aRing,ActTerm);

If you use the ASCII mode, DOL will be able to read the old data file into the new environment. It will generate the proper RING of Instances, the TRIANGLE of ActTerms by Instance, and the Instance name. Net related organizations will automatically be deleted. The RING aRing and the NAME tName will be initialized as unused. The value of current will be discarded, x,y will be re-generated correctly, and shape will be left uninitialized.

When you add members such as int, float, or char (for example shape in class ActTerm), the new members are not initialized, and are either 0 filled, or contain random values.

The order of members can be changed arbitrarily, but the first part of the ZZ_FORMAT statement (the actual format) must remain the same between the program that saved the data and the program which opens it later on.

When eliminating some data members, the action differs depending on whether you can anticipate the change already at the time when you save the data:

  1. If you know about the change at the time you create the data file (planned format conversion), simply drop the particular members from the ZZ_FORMAT() statement.
  2. If you don't know about the change beforehand (handling older data without an access to the program which generated the file), give DOL a dummy variable into which it can read the members that are being discarded.

Example, not anticipating a change:


class myClass { // the original class
    int i,k,s;
    ZZ_EXT_myClass
    ...
};
ZZ_FORMAT(myClass,"%d %d %d,i,k,s");

// changed class, i is missing
class myClass {
    int s,k;
    ZZ_EXT_myClass
    ...
};
ZZ_FORMAT(myClass,"%d %d %d,k,k,s");
// reading the old i value into k, then overwriting it by the true k

Example, not anticipating a change:


class myClass { // the original class
    int i,k,s;
    float f;
    ZZ_EXT_myClass
    ...
};
ZZ_FORMAT(myClass,"%d %d %d %s,i,k,s,f");
// changed class, k and s are missing
union uni {
    int i;
    float f;
};
class myClass {
    int k;
    union uni u; // overhead to keep all garbage
    ZZ_EXT_myClass
    ...
};
ZZ_FORMAT(myClass,"%d %d %d %s,u.i,k,u.i,u.f");

For another example of this technique, see test40a.c and test40b.c. test7e.c shows another example of changing classes and their relations (schema migration).

Even though the main format used for managing changes is the ASCII format, some smaller changes are also automatically absorbed even for the binary format. The condition is that the order of attributes before and after the ZZ_EXT_.. statement must remain the same. You may drop attributes only at the end of either section; new attributes must be added at their ends. For example, compare the following class definitions:


class ActTerm{  // original class
    ZZ_EXT_ActTerm
    int x,y;
    int termID;
};
  
class ActTerm{  // new class
    int shape;  // OK, new member at the end of the section before ZZ_EXT
    ZZ_EXT_ActTerm
    int y,x;    // problem, values of x,y will switch around
    // int termId; // OK, end of the section removed
};
 
class ActTerm{  // new class
    int shape;  // OK, added at the end of the section before ZZ_EXT
    ZZ_EXT_ActTerm
    int x,y;    // no problem
    int termID;
    int numOfPins; // OK, after the end of the section after ZZ_EXT_...
public:
    ActTerm(){shape=rotation=0;}
    int rotation; // OK, after the end of the section after ZZ_EXT_...
};

Note that when using the ASCII format, you control the read/write operation of attributes with the ZZ_FORMAT statement. The ZZ_FORMAT() statement or the custom coded zz_inp_...() and zz_out_...() refer to attributes by name; their order is irrelevant. When changing the organization, either code zz_inp_.. and zz_out_.. functions by hand, or use a different ZZ_FORMAT() for each version. DOL takes care of transparent pointers and other registered variables.
For example, in the situation above, when reading the old file and creating a new one, you need the following two functions. You are free to choose the order of attributes in zz_out_ActTerm(); the order in zz_int_ActTerm() must be the same as it was in zz_out_ActTerm() for the old data.


int ActTerm::zz_out_ActTerm(FILE *fp,ActTerm *p){
    fprintf(fp,"%d %d %d\n",p->x,p->y,p->shape);
    return(0);
}
int ActTerm::zz_inp_ActTerm(FILE *fp,ActTerm *p){
    if(!fgets(buff,BSIZE,fp))return(1);
    sscanf(buff,"%d %d",&(p->x),&(p->y));
    return(0);
}

Note that if the change involves a deletion of arrays or properties, dummy (unused objects) may be created when opening the old file. However, when saving the new organization, the dummy objects will disappear.

Version changes

The last section of this User's Guide, called Revision history lists new features and improvements since DOL Ver.5.0. It also indicates the compatibility of disk files with previous versions.

Only in two cases did the disk format change (Ver.1.65 and 2.0). No other changes in disk file format are planned in the foreseeable future.

DOL utility is most flexible. For example, if you can read data created under Ver.1.62 which is in the binary format, and write it to the disk in the ASCII format, using the current DOL version. All this is accomplished just by two mode() calls:

#define ZZascii
ZZ_HYPER_UTILITIES(util);
util.mode(0,1,162,0); // binary, buffered IO, ver.1.62
util.open(...);
...
util.mode(1,1,0,0); // ASCII, IO irrelevant, current version
util.save(...);

test21.c shows the situation when using the same data organization, but transferring between different versions of DOL.

Hierarchical types

This sections explains in more details the reasons for using #define ZZ_INHERIT in the environ.h file.

The algorithm for re-calculation of pointers during the open() operation is much simpler, if the pointers are guaranteed to point to the beginning of the allocated objects. Since persistent pointers in your objects can be implemented only through DOL organizations, you may ask when a pointer would not do that. There are two typical situations:

  1. pointer to a base class;
  2. pointer to a member inside an object;

      Examples:

      
          class D {
              ...
          };
      
          class C {
              ...
          };
          class B {
              ...
          };
          class A : public B {
              int ii;
              C cc;
              ...
          };
          ZZ_HYPER_SINGLE_LINK(cLink,D,C);
          ZZ_HYPER_SINGLE_LINK(bLink,D,B);
      
          D* d=new D;
          A* a=new A;
          B* b=a;
          bLink.add(d,b); // setting pointer to the base class
      
          C* c= &(a->cc);
          cLink.add(d,c); // setting pointer to the member object
      

      If we can prevent these two situations, we can remove ZZ_INHERIT from file environ.h, and even in the binary format open() will be noticeably faster. This means: lightweight objects (no inheritance), and no member objects.

      For additional information, look at Chap.8.1.8. which also explains the use of #define ZZ_INHERIT.

      Another example of the code where #define ZZ_INHERIT is definitely required:

      class Root {
          ZZ_EXT_Root
      };
      class Shape {
          ZZ_EXT_Shape
      public:
          virtual void anyFunction(); // see below
      };
      class Rectangle : public Shape {
          ZZ_EXT_Rectangle
          ...
      };
      ZZ_HYPER_SINGLE_COLLECT(col,Root,Shape);
      ZZ_UTILITIES(util);
      Root *rp; char *v[1],*t[1];
          ...
          v[0]=(char *)rp; t[0]="Root"; // set the key entry
          util.save("myFile",1,v,t); // saves all geometry

      Important: If your class inherits from another class, it is critical that the base class has at least one virtual function. If it does not have one, the DOL internal type table is not constructed correctly. But why would you have a base class, if there are no virtual functions? Here DOL tries to be space efficient. The code generator (zzprep) analyses class definitions, and never expands them by introducing hidden virtual pointers, unless virtual functions are already used.

      The same arrangement (requirement of at least one virtual function, perhaps a dummy one) applies to the automatic type detection - see Chap.11.15.

      For examples of saving hierarchical types, see test33a.c.

      Warning: If you save to disk, a class may inherit from a class, but not from a struct. For example

      class A { ... };
      class B: public A { ... }; // is fine

      but

      struct A { ... };
      class B: public A { ... }; // will not work

      Member objects

      When storing objects of class B which includes members that are instances of another class A, one of the two conditions must be satisfied in order to provide automatic persistency. Either class A is a DOL registered class:

      class A {
          ZZ_EXT_A // A is a registered class
          ...
      public:
          A(){....) // anything in default constructor
      };
      class B {
          ZZ_EXT_B // B is a registered class
          A a; // instance of A is a member
          ...
      };

      Or the default constructor for class A must do nothing:

      class A { // class without ZZ_EXT_..
          ...
      public:
          A(){} // or it must not be defined
      };
      class B {
          ZZ_EXT_B // B is a registered class
          A a; // instance of A is a member
          ...
      };

      Making existing programs persistent

      We often meet programmers who coded large projects without DOL - using their own data structures or other class libraries which either are not persistent, or are slow or have persistence which is difficult to use. Converison to DOL persistency can be done without converting all data structures:

      1. Insert the ZZ_EXT_.. statement into all your classes.
      2. Replace all member pointers in your classes by either the NAME organization (for pointers to character strings), or by SINGLE_LINK (all other pointers).
      3. Control the persistency usind DOL's save() and open().

      DOL also has macro ZZ_PTR(id) which used to simplify the conversion of older programs to DOL, especially when using the C language. This macro was used to replace all occurences of pointers in the original code. The idea is simple, but has not been used for several years. All recent conversions were done from scratch, because the programmers wanted to benefit not only from DOL persistency, but also from its fast data structures.

      Example of converting a legacy code. Here is the original program:

      
      class Dept {
          Dept *next;
          char *name;
      public:
          char *nextName(void){return(next->list);}
          ...
      };

      Which can be converted to DOL, and then saved with util.save() or ZZ_STORE(). Here is the version saving individual objects:

      class Dept {
          ZZ_EXT_Dept
      public:
          char *nextName(void){return(ZZ_PTR(next)->ZZ_PTR(name));}
          void save(char *fileName){ZZ_STORE(Dept,fileName);}
          ...
      };
      ZZ_HYPER_SINGLE_LINK(next,Dept,Dept);
      ZZ_HYPER_NAME(name,Dept); 
      
      int main(void){
          Dept* d=new Dept;
          ...
          d->save("myFile");


      List of functions and their syntax:

      Note: Commands with names entirely in capital letters are formally macros, usually hiding one or a few function calls. Remaining commands are methods of the UTILITIES class. The definition of this class hides under ZZ_HYPER_UTILITIES().

      ZZ_HYPER_UTILITIES(id); declaration of the UTILITIES class, creates also its instance, id
      void id.close(char *fileName) writes out an end-record, and closes the output file (several files may be open simultaneously).
      void id.save(char *fileName,int num,char *keyEntries[],char *keyTypes[]) starts from the given key entries [total of num key entries is given], and saves the entire data set to the file.
      void id.deep(char *fileName,void *obj,char *type) saves one object with all connected objects, and can be thought of as a simplified case of save(). However, this command is different in how it opens and closes the output file, and should be used in special situations only.
      ZZ_STORE(TYPE,char *fileName) is a macro which can be encapsulated in a function to save a single object.
      ZZ_OBJECT_SAVE(TYPE,char *fileName,char *obj,int num) is a more general macro for the same purpose, which allows you to write out arrays of objects or text strings. num is the number of objects (size of the array). If TYPE=char and num=0, the object is treated as a NULL ending string. If TYPE=char and num0, the object is treated as a block of num bytes.
      void id.open(char *fileName,int num,char *keyEntries[],char *keyTypes[]) retrieves the entire data set from the given file, and restores all pointers. The key entries are returned in their original order [total of num key entries is expected].
      void id.clear(int num,char *keyEntries[],char *keyTypes[]) starting from the given key entries [total of num key entries is given], this command collects all of the objects in the given organizations, and deallocates them without testing the pointers. This command is disabled (it does nothing) in the binary and ASCII modes when ZZ_INHERIT is defined in the environ.h file (most C++ programs). You have to traverse the organization and deallocate objects individually instead. This function always works in the memory blasting mode.
      void id.mode(int ascii,int diskIO,int version,int fileCntrl) resets the format for SAVE/OPEN
      ascii=0 for binary format, =1 for ASCII format,
      diskIO=0 for direct diskIO (write/read), =1 for buffered diskIO(fwrite/fread), =2 for user-controlled diskIO.
      version=version number as an integer, for example 164 for version 1.64, or 200 for version 2.0. You can also use version=0 for the current version. fileCntrl=0 automatically closes the output file, =1 for outside file opening/closing, =2 does not close the file.
      char* id.bind(char *oldPtr) converts the old pointer into the new location, after open() has been called. This command is tricky to use, and is not intended for novice users. It allows you to reset connections between data sets stored in different files.
      void id.keepTbl(void) has to be called prior to open(), if you plan to convert old-to-new pointers in a semi-automatic way. The result is that the internal conversion tables remain in memory after the program returns from open(). The tables stay in memory until you call freeTbl().
      void id.freeTbl(void) frees the internal tables used for the conversion of pointers.
      void id.blkAlloc(int size,int bits) reserves either one big block of memory of size bytes (if bits=0), or opens a set of pages that correspond to bits (for example bits=8 represents pages of 256 bytes). In the second case, size is the overall (very rough) limit on the memory to be used.
      void id.blkFree(m) for m=0 frees the currently active block of pages, for m=1 switches to regular (plain) allocation, for m=2 returns to block allocation.
      void id.blkActive(char *blkName, int aCode) For aCode=0, it makes the current block of pages passive, and stores it under the given name (in memory, not on disk). For aCode=1, it restores the given block as active. For aCode=2, it restores the block identified by the address (not by the name). In this case, blkName represents not a character string, but a pointer anywhere into the block to be activated.
      char* id.blkUtil(char *blkName,void **hook,int mode) searches for a block identified by its name or by the address anywhere inside the block (blkName). Then it sets or retrieves a utility hook to user data:
      mode=0
      sets hook, blkName represents block name,
      mode=1 sets hook, blkName represents an address,
      mode=2 gets hook, blkName represents block name,
      mode=3 gets hook, blkName represents an address.
      In all cases, the function returns the name of the identified block, NULL if the block has not been found.
      ZZ_FORMAT(myObj,aFormat) declares the ASCII format for those attributes of objType, which should be stored/restored from disk automatically. This command is an instruction for the code generator, which generates the functions zz_inp_objType() and zz_out_objType() in the file zzfunc.c. Note that aFormat is the same as the regular format used for printf() and scanf(), except that the attribute list is also within double quotes. The attributes are listed directly without any pointer (or object) reference, and can be listed in any order. Temporary variables that do not have to be stored may also be omitted; in some situations, this can save a considerable amount of disk space. If there are no attributes to be saved, use an empty string, for example, ZZ_FORMAT(myObj,"").

      If you save in ASCII format, there must be a ZZ_FORMAT() statement for every class with ZZ_EXT_... Classes without the ZZ_FORMAT() statement do not get stored!

      Comments:

      1. If you use the binary and ASCII format for saving to disk, we recommend buffered IO (util.mode() option diskIO=1). It minimizes disk access. Instead of writing individual objects to disk, the objects are written into an internal buffer, which automatically moves to disk when full or when the file is closed.
      2. aFormat in ZZ_FORMAT() must be an explicitly typed text string, not a run-time generated string.
      3. If you use single byte variables to store small integers instead of real characters, use a special format (%a) which writes/reads them as numbers. This format is DOL specific, it is not supported by C/C++ compilers.
        If you want to use %d, but cast the member as some unusual type, use %a and cast for the member, as in the example below.

      ZZ_FORMAT(myObj,"%a,(short)w");

       

      Previous Section 13.2 Memory Management Next Section 13.4 Collecting Objects