OpenEdge Development: Progress 4GL Handbook


Table of ContentsPreviousNextIndex
Progress Programming Best Practices

Here you have arrived at the final chapter of this guide to programming in Progress. Along the way you have learned a number of factors that can improve the performance and the effectiveness of the procedures in your Progress 4GL applications. This chapter repeats and reinforces some of those points and introduces you to additional language constructs and programming techniques to help you build the best-performing applications you can.

This chapter includes the following sections:

Writing efficient procedures

There are many things you can do to maximize the efficiency of your Progress procedures and to avoid unnecessary overhead in your applications. This section discusses some of these.

Using NO-UNDO variables and temp-tables

When variables without the NO-UNDO qualifier are updated, a before-image of the previous value is generated in what amounts to a separate record buffer for all NO-UNDO variables. This is useful when you want the original value restored when you UNDO a block or transaction. When you do not need this capability, the before-image generation is needless overhead. Use of NO-UNDO also causes the Progress local before-image (.lbi) file, which is maintained for each user connected to a database, to be smaller. As noted in "Using Basic 4GL Constructs," when this guide first introduced the DEFINE VARIABLE statement, it is unusual for variables to require the UNDO support that they receive by default. So, it is a good practice to make the NO-UNDO keyword a standard part of your variable definitions unless a particular variable really needs the support.

With temp-tables the situation is not always as clear. There might be cases where you are adding records to a temp-table or changing records in a temp-table within a transaction, and you want to be able to undo those changes. If this is not the case, however, you’ll benefit significantly from defining the temp-table with the same NO-UNDO qualifier as for variables. This spares you from having Progress create a before-image of every temp-table change made within a transaction.

Grouping assignments with the ASSIGN statement

If you are assigning two or more values in a row, it is significantly faster to assign them all in a single statement using the ASSIGN keyword. Even if the values being assigned have nothing to do with one another, it is faster to do it in a single statement, as in this example:

ASSIGN THIS-PROCEDURE:PRIVATE-DATA = STRING(OrderNum)
        hSource = SOURCE-PROCEDURE
        shipDate:BGCOLOR = dateColor(PromiseDate, ShipDate).

Here the code is setting an attribute on a procedure handle, a variable, and a field attribute. Even though these are unrelated, it is still best to ASSIGN them together.

If you are assigning multiple field values within a single record buffer, the ASSIGN statement can be even more important. Progress adjusts index entries and does other work as part of each statement. If you assign two fields that participate in the same index in two separate statements, the index block is rebuilt once after each statement—a much greater overhead than doing it in one statement. In fact, because Progress assigns index entries at the end of each statement, you might even cause a temporary (but fatal) unique index violation if you assign part of a composite key in one statement and the other part in another. Don’t ever do this.

Because of the efficiency of an ASSIGN statement without multiple assignments, some Progress developers always use the ASSIGN keyword even when the statement only does one assignment. There is no advantage to this. That is, these two statements are comparably fast:

Variable-a = Variable-b + 1.
ASSIGN Variable-a = Variable-b + 1.

Using BUFFER-COPY and BUFFER-COMPARE

When copying fields from one record buffer to another, BUFFER-COPY does it more efficiently than ASSIGN. Use the EXCEPT option when some fields are not to be copied, and the ASSIGN option when some fields are renamed during the copy operation. Even doing a BUFFER-COPY of a table with 100 fields is faster than an ASSIGN statement that copies a half dozen of those fields.

In particular, the static BUFFER-COPY statement allows Progress to resolve the exact field list to copy and to identify the field mappings at compile time so that the operation can be made as fast as possible. Using the dynamic BUFFER-COPY method on a buffer handle requires Progress to evaluate the arguments during program execution, which of course is slower. As with all dynamic language, use the BUFFER-COPY method only when you truly don’t know the buffers or the fields to copy until run time.

All this applies equally well to the BUFFER-COMPARE statement or method which compares two buffers and returns a list of differences. BUFFER-COMPARE is much faster than using a series of explicit comparison statements on individual fields.

Block-related tips

This section offers some advice on creating blocks of code.

Using DO instead of REPEAT

Back in "Procedure Blocks and Data Access," you learned about the various properties of different kinds of blocks. Remember that the DO block, by default, does not provide you with many of the default services that the REPEAT block does (for example, transaction management and default frame management). The flip side of this is that a DO block is much faster than a REPEAT block if you don’t need these services. So, use a simple DO block whenever possible for any kind of iterating block that doesn’t need to manage a transaction or iterate through a DOWN frame.

Minimizing block nesting

All blocks in Progress incur some overhead. Because Progress is a 4GL that provides many services to make programming easier and to make each statement do more than you’re used to from other languages, there is a cost to all blocks, even simple DO blocks. For this reason, you should avoiding unnecessary block nesting wherever possible. Therefore, always use the form:

IF expression THEN statement.

Instead of this form:

  IF expression THEN
    DO:
       statement.
    END.

The AppBuilder always gives you a DO END block as a starting point for triggers, for instance. You should feel free to remove the block if your trigger requires only a single statement. And remember that there can be an additional benefit to combining multiple assignments into a single statement. If you turn several assignments into a single ASSIGN statement, a DO block that would otherwise have several statements can be reduced to just one statement with no block header.

Minimizing nesting of procedure calls

Procedures, whether internal or external, are blocks as well, and relatively expensive ones. Obviously, you should use procedures as necessary to structure your application properly. However, if you run a relatively small procedure or invoke a function many times in a performance-sensitive loop, you should consider moving the code directly into the procedure that calls it to execute it inline. If it’s executed many times, this can make a significant difference in performance. If a procedure of this type is invoked from multiple places, you can make it into an include file and include it each place where it would otherwise be run. In this way, the code remains reusable but it is compiled directly in place wherever it is used. This can make the code much faster.

The CASE statement

If you have a sequence of IF,THEN,ELSE clauses that all operate on different values of the same variable or expression, you can combine these into a single block using the CASE statement, which has this syntax:

CASE expression :
  { WHEN value [ OR WHEN value ] ... THEN
       { block | statement }
  } ...
  [ OTHERWISE
       { block |statement }
  ]
END [ CASE ].

The expression can be a simple field or variable or any other expression involving multiple fields or values. Part of the optimization of the CASE statement is that it evaluates the expression only once, when the CASE statement is entered. By contrast, nested IF statements evaluate the expression in each IF clause, even if the expression is the same each time.

Following the block header are a number of WHEN clauses, each of which starts with a value for the expression, followed by THEN, followed by a statement or block to execute if the expression has that value. You can combine multiple WHEN clauses with OR if the same block or statement executes on multiple values of the expression.

Finally, the CASE block can conclude with an optional OTHERWISE clause with a statement or block to execute if the expression matches none of the values in the WHEN clauses.

The CASE statement is most useful when a variable or field can have one of a small number of possible values, and the procedure needs to react differently to each value.

Here’s a simple example that uses the CASE statement to count the number of Orders of different types. There are four valid values for the OrderStatus field. The OTHERWISE clause tallies any that don’t match any of the valid values (as it turns out, there aren’t any):

DEFINE VARIABLE iOrderStatus AS INTEGER EXTENT 5     NO-UNDO.
  FOR EACH Order:
    CASE Order.OrderStatus:
        WHEN "Shipped" THEN
            iOrderStatus[1] = iOrderStatus[1] + 1.
        WHEN "Ordered" THEN
            iOrderStatus[2] = iOrderStatus[2] + 1.
        WHEN "Back Ordered" THEN
            iOrderStatus[3] = iOrderStatus[3] + 1.
        WHEN "Partially Shipped" THEN
            iOrderStatus[4] = iOrderStatus[4] + 1.
        OTHERWISE
            iOrderStatus[5] = iOrderStatus[5] + 1.
    END CASE.
  END.  /* END FOR EACH Order */
  DISPLAY iOrderStatus[1] LABEL "Shipped Orders" SKIP
          iOrderStatus[2] LABEL "Ordered Orders" SKIP
          iOrderStatus[3] LABEL "Back Ordered Orders" SKIP
          iOrderStatus[4] LABEL "Partially Shipped Orders" SKIP
          iOrderStatus[5] LABEL "Invalid Orders"
     WITH FRAME DisplayFrame SIDE-LABELS.

Figure 22–1 shows the result.

Figure 22–1: Result of CASE statement example

Using arrays instead of lists

This book cautions you against defining array fields with extents as part of your database definition, for a number of reasons. However, array variables, as well as array fields in temp-tables, can be very useful. One thing to keep in mind is that array element access is much faster than accessing an element of a list of comma-delimited strings. The CASE example above shows an example of this. The totals could have been accumulated within a single delimited list, but this would have involved scanning the list for the delimiters with each operation. The memory location of each element of an array is distinct and, therefore, quickly accessed.

Using FOR FIRST versus FIND

The FIND statement retrieves a single record in a single statement. It has its limitations, however, when compared to a FOR block. A FIND statement always uses only one index and does not allow use of field lists or word indexes. By contrast, a FOR block can use multiple indexes to evaluate a complex WHERE clause that involves fields not found in a single index. A FOR block can also use a word index and the CONTAINS keyword to locate records, and supports the use of a field list to retrieve only a subset of the fields from the selected records. Both word indexes and field lists are briefly described in the "Using indexes properly" and the "Defining efficient queries and FOR EACH statements" . You can use the form FOR FIRST when fetching just a single record, if any of the conditions apply that would give it a performance advantage. Of course, the FOR FIRST construct involves creating a block, which has an overhead of its own that the FIND statement doesn’t have. Therefore, it’s good practice not to use FOR FIRST in all cases unless you have a specific reason.

The ETIME function

You might be wondering (in fact, you should be wondering) how to tell which of two constructs is faster in your situation or how to measure the performance of a part of your application. Progress has a performance profiling tool (called Profiler) that can show you exactly how much of your processing time is going to what routines, but there is also a much simpler way to do a test of a specific part of your code: the ETIME function (for elapsed time). ETIME returns an integer value representing the number of milliseconds since the function was reset to zero. Because a millisecond is a substantial amount of time on a modern processor, you often have to repeat an action many times inside a loop to measure accurately just what its cost is.

To reset the counter that ETIME uses, you invoke the function with an argument value of yes or true. Otherwise, you invoke it with no argument and no parentheses. If you forget to reset it to zero before you start, ETIME returns some enormous integer representing the number of milliseconds since your session started.

This example shows you whether it is faster to use the FIND statement or a FOR FIRST block to find the Order with a Customer Number of 24 and an Order Date of 1/31/98.

If you look at the indexes for the Order table in the Data Dictionary, you see that there is an index on the Order Date field, and another index that has the CustNum field as its primary component, followed by the OrderNum field. The FOR FIRST construct takes advantage of both of these indexes to resolve the request in the most efficient way possible. The FIND statement can use only one of the indexes.

The code first resets ETIME, then does the same FIND in a loop 10000 times. It then saves the ETIME counter for this operation. Then it resets it again, does a FOR FIRST 10000 times, saves that value, and finally displays both values:

DEFINE VARIABLE iCount    AS INTEGER    NO-UNDO.
DEFINE VARIABLE iFindTime AS INTEGER    NO-UNDO.
DEFINE VARIABLE iForTime  AS INTEGER    NO-UNDO.
ETIME(TRUE).
DO iCount = 1 TO 10000:
    FIND Order WHERE CustNum = 24 AND OrderDate = 1/31/98.
END.
iFindTime = ETIME.
ETIME(TRUE).
DO iCount = 1 TO 10000:
    FOR FIRST Order WHERE CustNum = 24 AND Orderdate = 1/31/98:
    END.
END.
iForTime = ETIME.
MESSAGE "The FIND took " iFindTime " milliseconds " SKIP
         "The FOR FIRST took " iForTime VIEW-AS ALERT-BOX.

Figure 22–2 shows the result.

Figure 22–2: Result of ETIME function example

So, the FOR FIRST was significantly faster. The actual difference is very dependent on the actual indexes and the number of records to search.

Using the CAN-FIND function

The CAN-FIND function lets you determine whether a record with certain field values exists, for example as part of field validation. CAN-FIND takes a buffer name and a WHERE clause just as you would write for a FIND statement. It returns true if the record exists and false otherwise. Just as with the FIND statement, the CAN-FIND function can identify a unique record satisfying the WHERE clause, or you can include the FIRST (or even LAST) keyword before the buffer name to determine whether at least one record exists that satisfies the WHERE clause. The CAN-FIND function returns false if more than one record satisfies the selection criteria and you do not include the qualifier FIRST or LAST.

This simple procedure shows two uses of CAN-FIND:

FOR EACH Order:
    IF NOT CAN-FIND(Customer OF Order) THEN
        MESSAGE "Order " Order.OrderNum " has no Customer" Order.CustNum.
END.
FOR EACH Customer WHERE NOT CAN-FIND (FIRST Order OF Customer):
    DISPLAY CustNum NAME.
END.

In the first case, the code looks for Orders whose CustNum field doesn’t match any Customer record. This code would cause an error in the database’s referential integrity.

The second block looks for Customers that have no Orders. This code would probably be a valid condition, since you need to add a Customer before you start adding Orders for it.

As it turns out, the Sports2000 database doesn’t have any invalid Orders without Customers. If you add a few extra Customers to the database that aren’t in the standard database and that don’t have any Orders, the DISPLAY statement will show those, as you can see in Figure 22–3.

Figure 22–3: Result of CAN-FIND function example

Why does the Progress 4GL have this function in addition to the FIND statement? CAN-FIND can return true or false simply by looking at the index entries if the selection criteria can be resolved strictly through a single index, without having to retrieve record values. After all, the statement is only asking whether a record exists (that is, if it can be found). It is not retrieving any particular field values.

Thus, CAN-FIND is beneficial and efficient only when you use it to identify records through one or more fields in a single index. If CAN-FIND has to retrieve the database records themselves, then you have lost its performance advantage over the FIND statement.

Dynamic programming tips

This section provides some advice on programming with dynamic objects.

Dynamic versus static programming

The most basic thing to consider about programming with dynamic objects is whether and when to do it at all. Since support for dynamic objects was added to the Progress 4GL in more recent releases, some developers think that dynamic constructs are inherently an improvement over their static counterparts and are tempted to program everything using dynamic objects. This is a big mistake. Dynamic objects are intended to give you more choices when you develop an application, not to replace static references to tables and fields. There are several important considerations:

So consider the power of dynamic programming for those situations where your application is doing the same thing in many places. Rather than writing many variations on the same source procedure or creating many compiled versions of the same procedure using include files or preprocessors, you can create a single procedure that does the same general job for every part of your application that needs it. This is why dynamic constructs exist. Consider their cost and the responsibility of having to clean up after yourself when you use them. Where they are the right thing to use, they can add tremendous flexibility to your application and dramatically reduce the number of different procedures you have to maintain.

Reusing dynamic objects

The major section of this chapter discusses Progress memory management and emphasizes that you must delete any dynamic objects you create. It’s important to keep in mind, however, that if you are going to create another dynamic object of the same type, especially inside a loop that is executed many times, it is much more efficient to reuse the same dynamic object rather than deleting it and re-creating it. This is true even if you change all the attributes of the object. Then you must simply remember to delete it after you are completely done with it.

For example, the following procedure needs to generate a dynamic buffer and a dynamic query for every table in the Sports2000 database. The information about tables, fields, and indexes is actually stored as schema information in the database itself, and you can access it the same way you do any other information. There are just a few bits of information you need to know to understand this example:

For each database table, the code creates a dynamic query and a dynamic buffer, and sets the query’s buffer to the dynamic buffer handle. Then the code prepares a default query for the table, opens it, gets the first record, and closes it. It then deletes the dynamic query and the dynamic buffer. To show the effect of the performance more dramatically, this is all done inside a loop 100 times. Figure 22–4 shows how long it took for one sample run.

Figure 22–4: Result of DELETE OBJECT example

There’s a more efficient way to do this. If you move the CREATE QUERY hQuery statement before the DO block and the FOR EACH block, and you move the DELETE OBJECT hQuery statement after the end of those blocks, the procedure runs somewhat faster. Even though you are resetting the query’s buffer and re-preparing it for a completely different buffer, it is still faster to reuse the same dynamic object:

/* h-DeleteObject.p */
DEFINE VARIABLE hQuery AS HANDLE      NO-UNDO.
DEFINE VARIABLE hBuffer AS HANDLE     NO-UNDO.
DEFINE VARIABLE iCount AS INTEGER     NO-UNDO.
    CREATE QUERY hQuery.
    ETIME(TRUE).
    DO iCount = 1 TO 100:
        FOR EACH _file WHERE _file-num > 0 AND _file-num < 32000:
            CREATE BUFFER hBuffer FOR TABLE _file._file-name.
            hQuery:SET-BUFFERS(hBuffer).
            hQuery:QUERY-PREPARE("FOR EACH " + _file._file-name).
            hQuery:QUERY-OPEN().
            hQuery:GET-FIRST().
            hQuery:QUERY-CLOSE().
            DELETE OBJECT hBuffer.
        END.
    END.
    MESSAGE "100 iterations took " ETIME "milliseconds" VIEW-AS ALERT-BOX.
    DELETE OBJECT hQuery.

Figure 22–5 shows the result.

Figure 22–5: Result of more efficient DELETE OBJECT example

Just be sure you don’t forget to delete the object when you’re done with it! Unless you’re reusing the object a large number of times, the difference in performance won’t be dramatic, so it isn’t worth the risk of forgetting to delete a dynamic object unless you will be reusing it many times in succession.

Note that you can’t reuse the dynamic buffer in the same way. When you use the CREATE BUFFER statement, you must name the table the buffer will be for. This name can be an expression, as it is here, so that the buffer name can be assigned dynamically at run time, but you can’t then reuse that same dynamic buffer object for a different buffer. So you have to create it and delete it inside the loop.

Using indexes properly

A detailed discussion of index design is beyond the scope of this book. But this section offers a few guidelines that relate directly to how proper use of indexes can contribute to the performance of your application.

Using word indexes for status indicators

A word index is a special index type that you can define for a character field. It indexes not the entire field value, as a normal index would, but every individual word in the index. There are delimiters you can define to tell the database manager just what you would like to see treated as a word, what the delimiters between words are, and so forth. You can learn about word indexes in OpenEdge Development: Programming Interfaces. Word indexes can be a tremendously powerful mechanism for identifying database fields that contain particular words. In fact, there is a special CONTAINS operator for a WHERE clause, similar to BEGINS and MATCHES, which is reserved for use with word indexes.

One powerful use of word indexes is not just to provide an index on all the words in a free text field, such as a status message or customer comments, but to create special character fields in which you store strings that identify other aspects of the record. For example, you can create a character field for a table in which you store various attributes of the record that otherwise would be individual logical fields with true/false values. It’s much more efficient to use the CONTAINS operator on a word-indexed field than to evaluate a number of different indexed fields. You can also store some combination of field names and field values in a word-indexed field to make it easier and faster to find a record based on a number of different search criteria, such as customers where you have some particular bits and pieces of name and address information.

Avoiding indexes on logical values

If your application needs to identify records that satisfy some Boolean condition (such as Active vs. Inactive, Male vs. Female, or Domestic vs. Foreign), it is not a good idea to do this by means of indexes on Logical fields that represent the two conditions. The same is true of other fields that have only a handful of values, whether they are character values, such as Foreign and Domestic, or integer values representing those meanings. An index bracket is the portion of the index that the OpenEdge RDBMS must search through to identify all the records that match your selection criteria. If this is half or a large fraction of all the records, then the index is not serving its purpose and data retrieval is not efficient. Instead, you should consider encoding these kinds of values in a word-indexed character field. Under very special circumstances it might be beneficial to define an index on a logical value when, for example, 99 percent of the records are true for that value and you frequently need to identify the one percent that are false.

Using multi-component indexes

You can define an index on one or more fields in a table. Defining a multi-component index can be much more effective than defining multiple indexes on the same individual fields, but only when your application needs to access that combination of fields in the order in which they appear in the index. For example, if your application sometimes needs to select data based on the value of field A, and sometimes on A and B together, and sometimes on A, B, and C, then it makes sense to define a multi-component index with fields A, B, and C in that order.

However, if your application sometimes needs to select data based just on field B, or on C, or on B and C together, without knowing the value of A, then this index will do you no good any more than you can easily locate a word in the dictionary by knowing the second or third letter in the word.

Always evaluate the selection requirements of your application carefully as you design your database indexes.

Avoiding unneeded indexes

Another danger is simply defining too many indexes on a table. You should define an index for a table when you have most or all of these requirements:

Maintaining an index every time you create or update a record is relatively expensive. Maintaining many indexes on the same table can be very expensive. Avoid defining indexes you don’t really need.

Hiding screen contents when making changes

Various types of operations make a series of changes to the user interface of an application before the user interface arrives at a final state. This might involve calculating a succession of values that are visible on the screen or adjusting the state of objects, such as combo boxes and selection lists. If you set the objects’ (or where appropriate the frame’s) HIDDEN attribute to true while you are making the adjustments, not only will you avoid flashing on the screen as things are changing, but the changes generally execute faster because the screen does not have to refresh after each adjustment.

Remember that the browse has a special REFRESHABLE attribute that you can set to false while you are making changes to the data displayed in the browse, to also prevent flashing for that object. This avoids having the browse hidden during the calculations, which might be distracting, but effectively freezes it so that the user sees only the final result of the changes.

Defining efficient queries and FOR EACH statements

This section provides some tips for defining queries and FOR EACH statements.

Using field lists

Progress allows you to specify a reduced list of fields to retrieve when you define a query or start a FOR EACH block. This is the syntax for the DEFINE QUERY field list:

DEFINE QUERY query-name FOR buffer-name
   { FIELDS field ... | EXCEPT field ... } [, buffer-name ... ].

This is the syntax for the FOR EACH field list:

FOR EACH buffer-name
   { FIELDS field... | EXCEPT field ... } [, buffer-name ... ].

If you specify a list of FIELDS for a buffer, only those fields are retrieved. If you specify an EXCEPT list, only those fields are not retrieved.

Under some circumstances, using a field list can reduce the amount of data transferred across the network in a client/server environment. However, there are serious limitations to the field list that mean that you should have limited use for it in most modern OpenEdge applications:

The bottom line here is that in a distributed application, you control the field list through the definition of temp-tables that pass data from server to client, and the FIELDS phrase on a query definition is not needed as part of that definition.

Structuring your selection criteria in a join

When you need to retrieve data from multiple joined tables in a single query or FOR EACH statement, it is important to put the tables into the proper sequence and to specify your selection criteria as early in the retrieval process as possible.

OpenEdge does not optimize complex joins in the same way that some other database managers do, rearranging the order of tables and fields. There is a very good reason for this. Because Progress is designed to make it easy and effective to deal with individual records and multiple levels of selection, rearranging a join in a single statement is not typically an issue. For example, this kind of nesting of data retrieval blocks is very typical in Progress business logic:

FOR EACH Customer WHERE condition>:
   /* Customer processing */
   FOR EACH Order OF Customer:
      /* Order processing */
      FOR EACH OrderLine OF Order:
      /* OrderLine processing */
      END.
   /* More Order processing after all OrderLines have been handled. */
   END.
   /* Final Customer processing. */
END.

In this kind of code, the developer understands the order in which data is retrieved and is relying on that order to structure the business logic for related tables.

If you join tables in a single statement, Progress retrieves the data in the order you specify. Progress does not second-guess your selection and rearrange the retrieval for you. This means that you have to take responsibility for structuring your selection efficiently. For example, if you want to retrieve orders processed today for Customers with a CreditLimit, this kind of statement is very inefficient in Progress:

/* Less efficient selection: */
FOR EACH Customer WHERE Customer.CreditLimit NE 0,
  EACH Order OF Customer WHERE OrderDate = TODAY:

Hardly any Customers have a CreditLimit of 0, so the Customer selection is going to return nearly all Customers. On the other hand, only a few Customers have placed Orders today. It would be much more efficient to identify the Orders first, and then get the Customer for each of those Orders:

/* More efficient selection: */
FOR EACH Order WHERE OrderDate = TODAY,
  FIRST Customer OF Order WHERE CreditLimit EQ 0:

It’s especially important to place the selection criteria for each table as high up in the statement (that is, as close to the front) as possible. Always define the selection for each table as part of the phrase for that table’s buffer. That is, don’t write a statement such as this:

/* Inefficient selection: */
FOR EACH Customer,
   EACH Order OF Customer WHERE Customer.CreditLimit NE 0 AND OrderDate = TODAY:

In this case, Progress does just what you ask it to do:

  1. Retrieves each Customer in the Customer table in turn, into the Customer buffer, regardless of its CreditLimit or anything else.
  2. Retrieves each Order for each Customer in turn, into the Order buffer.
  3. Examines the CreditLimit value in the Customer buffer to see if it equals 0.
  4. If the CreditLimit does not equal zero, examines the OrderDate in the Order buffer to see if it’s equal to today’s date.

This is clearly a very inefficient way to go through the data, especially because there is an index on the OrderDate field and another index on the CustNum field in both the Order table and the Customer table that allows Progress to identify those Orders and their Customers immediately.

If you are used to working with other data retrieval languages, you might miss the optimization of complex queries that they do, but Progress gives you control over your application behavior by presenting you with the data you ask for in the way that you ask for it. When you are writing real business logic this is much more useful than having the DBMS evaluate some complex set of expressions on a WHERE clause that joins multiple tables and return a single processed result.

Copying temp-tables as parameters

"Defining and Using Temp-tables," describes how you can pass temp-tables from one procedure to another using the static TABLE parameter form, the dynamic TABLE-HANDLE parameter form, or simply by passing the HANDLE of the temp-table. It is extremely important that you pass temp-tables using the simple HANDLE parameter whenever possible, if your procedure call is within the same session. When you pass the handle to a temp-table, as with any object, you are simply passing a pointer to its location elsewhere in the session. If you pass the temp-table using either the TABLE or TABLE-HANDLE form, you are forcing Progress to perform a complete copy of the structure of the temp-table and the data in the table. You need to do this only if the procedure receiving the temp-table needs a static definition of the table. Especially when the procedure receiving the temp-table is only an intermediary, which simply passes the temp-table on to some other procedure, there is never a need to pass the table itself, only its handle. Observing this guideline whenever you use temp-tables can greatly improve your application’s performance.

Setting your Propath correctly

The Propath is the set of directories Progress looks in to locate procedure files to run. Configuring your Propath correctly is essential to good application performance. The more directories there are on the Propath, the longer it takes to search for files. Larger directories may take longer to search than smaller ones, depending on your operating system.

You should order directories by their frequency of use. Put the most frequently used directories first.

Database and AppServer-related issues

This section describes issues related to the OpenEdge database and the AppServer.

Avoiding unnecessary database connections

Connecting to a database is a relatively slow operation that should be done only once. Do not connect and disconnect over and over. Specify the databases to be connected on the command line rather than using the CONNECT statement in the 4GL. If necessary, you can specify database connections when you enter significant modules that use that database, but be aware of the cost.

Minimizing creation of subprocesses

Creating a new process is an expensive operation that can be very time consuming if done often and especially when done by multiple users on the same system. Use statements like INPUT THROUGH, OUTPUT THROUGH, and UNIX with care.

Using database sequences

"Updating Your Database and Writing Triggers," you saw how you can use database sequences to generate a sequence of unique integer values, for example, as key values for a database field such as a Customer Number or Order Number. Sequences are much faster than using an integer field in a control record stored in the database, and they do not cause lock conflicts as a control record does. To obtain unique values from a field in a database record, each user needs to get an EXCLUSIVE-LOCK on the record within a transaction, retrieve its value, increment the value, and then end the transaction and release the record. This is very time consuming, especially when you consider that it is likely that many users will be trying to do this at the same time. This is exactly the purpose that database sequences were designed for. When you execute the NEXT-VALUE function on a sequence, the OpenEdge RDBMS increments the sequence and returns the value to you in a single operation, so that there is no chance that another user can see the same value, but without any need for a transaction.

Minimizing the size and number of network messages

The two slowest operations in most systems are transmitting data over a network and accessing data on a disk. When using AppServers, sockets, or client/server database access, take care to minimize the number of network calls you make. Whenever possible, send large amounts of data at once rather than many small transmissions.

Always remember to test on slow networks. A dial-up connection is many times slower than a fast LAN connection. Network messages take considerably longer to transmit on slow connections. Sometimes an application works well during development because a fast network is used and fails miserably when deployed in the real world.

Network latency is limited and is generally beyond your control. The speed of light limits network transmission time. For example, it is 3266 miles from Boston to London. Via the Internet, it might be 5000 miles or more. A sample routing chart shows 18 hops from a PC in a Boston office to a server in a London office. It takes at least 40 milliseconds to send a message, assuming best possible conditions. Typical conditions might require 80 or 100 milliseconds. So, sending a message and getting a response takes around 160 milliseconds if the server responds quickly. At that rate, you can send and receive 6 (typical) to 12 (best case) messages per second. Nothing will ever make this turnaround time faster. Technical improvements in the Internet will only allow you to send more data in the same time.

Configuring your session using startup options

Progress supports a large number of startup options that you can use, among other things, to tune the configuration of your session, controlling memory allocation for various purposes and other important factors that can have a large effect on performance. A detailed discussion of these is beyond the scope of this book, but this section mentions a few key ones.

Using the -q startup option

The Quick Request (-q) option avoids constant file lookups to determine if a previously loaded .r file needs to be replaced in memory by a newer one. If you specify –q, then the Propath search is done only once, on the initial load of the .r and not on every invocation of it. You should always use the –q option except when you are in development mode and regularly changing and compiling application procedures.

Increasing the –Bt startup option size

The -Bt startup option sets the buffer size for temporary tables. The default value is very small, only 10 buffers. Increasing the buffer size to 100 or more can be useful.

Increasing the –Mm startup option size

In client/server environments, increasing the maximum size of the buffers used for network messages allows more data per message to be sent. This is especially beneficial when reading groups of records with NO-LOCK. The size is a maximum and smaller messages are sent when there is not enough to completely fill the buffer. The default size is 1024 bytes. Increase it to 16384. Not that the same size must be specified on both the client and server side, and that the server allocates message buffers for each client. Large buffers consume more server memory. The increase on the client side is not significant.

Memory management in Progress

The chapters on dynamic objects taught you that even in a high-level language like the Progress 4GL, you have to do your own memory management when you use dynamic objects. The basic rule is very simple: You create it, you delete it!

This section re-enforces this basic concept and gives you some examples of things you need to be aware of and techniques to use to make sure that your application doesn’t sprout memory leaks that bring it to a halt when you put it into production.

You shouldn’t treat the need for memory management as a reason to avoid programming using dynamic objects. Remember that the Progress 4GL started out as a strictly static language, where everything was defined in the procedural source code and the compiler resolved every table and field reference. Dynamic objects have been added to the language precisely because they can make your development much more effective, and allow you to reuse procedures and logic, when you need a single operation on different tables, fields, or other objects at different times. You should take full advantage of dynamic objects. You just need to remember to clean up after yourself.

The need for careful programming is especially important because many memory management problems only show up when you run your application over long periods of time in production, and the effects of even small memory leaks cause drastic problems. That is definitely not the time for you to be discovering the problems in your application! A little discipline up front protects you from this.

Cleaning up dynamically allocated memory

When you run a procedure with only static objects, these objects come and go with the procedure that defines them. Because Progress can see the individual DEFINE statements for the objects, it knows when to create them, when they go out of scope, and when to delete them. You don’t have to worry about deleting them because Progress does it automatically for you.

The same is true, incidentally, of handles for static objects. When you store the handle of a static object in a HANDLE variable, that value is just a pointer to the object. It doesn’t extend or affect the scope of the static object in any way. The variable itself, because it’s defined statically, is also deleted by Progress when its procedure goes out of scope.

When you create dynamic objects, however, Progress cannot control their scope or their lifetime. If you create a dynamic object inside a loop, Progress might have no way of knowing at compile time how many of those objects the procedure will create. If you pass the handle of an object as a parameter to another procedure, Progress has no way of knowing at compile time where the handle came from, what it will be used for, or when the object it represents can safely be deleted. This is why you are responsible for taking care of this.

To see a dramatic example of the importance of memory management:
  1. Open the h-DeleteObject.p procedure used earlier and comment out the statements that delete the dynamic query and buffer objects at the end of the loop:
  2. /* h-DeleteObject.p */
    DEFINE VARIABLE hQuery AS HANDLE      NO-UNDO.
    DEFINE VARIABLE hBuffer AS HANDLE     NO-UNDO.
    DEFINE VARIABLE iCount AS INTEGER     NO-UNDO.
        ETIME(TRUE).
        DO iCount = 1 TO 100:
            FOR EACH _file WHERE _file-num > 0 AND _file-num 32000:
                CREATE QUERY hQuery.
                 CREATE BUFFER hBuffer FOR TABLE _file._file-name.
                hQuery:SET-BUFFERS(hBuffer).
                hQuery:QUERY-PREPARE("FOR EACH " + _file._file-name).
                hQuery:QUERY-OPEN().
                hQuery:GET-FIRST().
                hQuery:QUERY-CLOSE().
    /*             DELETE OBJECT hQuery. */
    /*             DELETE OBJECT hBuffer. */
           END.
        END.
      MESSAGE "100 iterations took " ETIME "milliseconds" VIEW-AS ALERT-BOX.

  3. Bring up the Windows Task Manager, select the Processes tab, and find the running executable called prowin32.exe:
  4. Run the procedure, and watch the memory go:
  5. After just 100 iterations of this loop, which only created two dynamic objects, you’ve lost six megabytes of memory! Not only that, but even though you removed two key statements from the procedure, it ran about four times slower:

    The reason for this is that all that extra memory allocation is getting in the way of your procedure running efficiently. So you pay a heavy price all the way around.
    Use the DELETE OBJECT statement to get rid of the memory for any dynamic object, regardless of its type. To summarize, you use a CREATE QUERY statement to create a dynamic query, a CREATE BUTTON statement to create a dynamic button, and a CREATE BROWSE statement to create a dynamic browse, but you use the DELETE OBJECT statement to clean up each of them.
    You must also remember to clean up persistent procedures when you’re done with them. Every time you execute a statement of the RUN procedure-name PERSISTENT SET proc-handle form, you are also allocating memory for the procedure and all its contents. You need to delete the procedure, when you’re done with it, with the DELETE PROCEDURE statement.

Using widget pools

Deleting individual dynamic objects is a big responsibility, and a serious nuisance as well. Widget pools are designed to help you make your use of dynamic objects much simpler and more reliable.

A widget pool provides a means of treating objects you create as a set. When you delete the pool, all the objects you created in it go away together. The simplest way to group objects using widget pools is to associate all the objects in a single procedure with a widget pool. In fact, the template procedures used by the AppBuilder for windows (and for visual SmartObjects, such as SmartWindows and SmartDataViewers) help you do this by having a CREATE WIDGET-POOL statement in the template’s definitions section:

/* Create an unnamed pool to store all the widgets created
     by this procedure. This is a good default which assures
     that this procedure's triggers and internal procedures
     will execute in this procedure's storage, and that proper
     cleanup will occur on deletion of the procedure. */
CREATE WIDGET-POOL.

This means that, by default, all the dynamic objects you create that go into the window are deleted when the window is closed and its procedure deleted. This includes not just visual objects but dynamic buffers, queries, and so forth.

Remember that a simple CREATE WIDGET-POOL statement creates an unnamed widget pool. This pool automatically goes away when its procedure terminates. This might not always be the behavior you want. In fact, by passing the handles to dynamic objects around, you can easily wind up with a handle whose scope exceeds the lifetime of the dynamic object it points to. In this case, the value of the handle can become invalid.

Here’s a simple example of how this can happen. The h-MakeBuffer.p procedure has an internal procedure called getBuffer that creates a dynamic buffer and returns it to the caller. The CREATE WIDGET-POOL statement puts all such buffers into an unnamed widget pool for that procedure:

/* h-MakeBuffer.p */
CREATE WIDGET-POOL.  /* Unnamed widget pool! */
PROCEDURE getBuffer:
     DEFINE INPUT PARAMETER cTable AS CHARACTER NO-UNDO.
     EFINE OUTPUT PARAMETER hBuffer AS HANDLE NO-UNDO.
     /* The buffer will be allocated to the unnamed widget pool. */
     CREATE BUFFER hBuffer FOR TABLE cTable.
END PROCEDURE.

Another procedure, h-RunMakeBuff.p, runs h-MakeBuffer.p PERSISTENT and then runs getBuffer:

/* h-RunMakeBuff.p */
DEFINE VARIABLE hProc AS HANDLE     NO-UNDO.
DEFINE VARIABLE hMyBuf AS HANDLE     NO-UNDO.
RUN h-MakeBuffer.p PERSISTENT SET hProc.
RUN getBuffer IN hProc (INPUT "Customer", OUTPUT hMyBuf).
MESSAGE "While MakeBuffer is alive, my buffer is "
    hMyBuf:NAME VIEW-AS ALERT-BOX.
DELETE PROCEDURE hProc.
MESSAGE " After I delete it, I get nasty errors"
    hMyBuf:NAME VIEW-AS ALERT-BOX.

The first message statement, as expected, correctly displays the name of the dynamic buffer that was returned, as shown in Figure 22–6.

Figure 22–6: Result of widget pool example

What happens, though, when the procedure has deleted its instance of h-MakeBuffer.p?

The unnamed widget pool in that instance of h-MakeBuffer.p goes away, and the dynamic buffer that hMyBuf points to goes away with it. But the hMyBuf variable is still very much alive, and in fact the value of the handle that it holds hasn’t changed. However, that handle value doesn’t point to anything anymore, so you get a string of errors, as shown in Figure 22–7.

Figure 22–7: Errors for unnamed widget pool example

This sequence of errors is the bane of any dynamic programmer’s existence. It is the most likely consequence of failing to make sure that all your dynamic objects live exactly as long as they need to, but no longer.

Using named widget pools

The solution to this is to give names to widget pools that hold objects that might outlive the procedure that created them. In addition to giving the pool a name, you must also define it to be PERSISTENT. You can then create dynamic objects explicitly in that pool:

/* h-MakeBuffer.p */
CREATE WIDGET-POOL "MakeBuffPool" PERSISTENT.
PROCEDURE getBuffer:
    DEFINE INPUT PARAMETER cTable AS CHARACTER  NO-UNDO.
    DEFINE OUTPUT PARAMETER hBuffer AS HANDLE    NO-UNDO.
    /* The buffer will be allocated to the unnamed widget pool. */
    CREATE BUFFER hBuffer FOR TABLE cTable IN WIDGET-POOL "MakeBuffPool".
END PROCEDURE.

The persistent widget pool becomes another dynamic object that your application has to take responsibility for managing. You must delete it when you’re finally done with, or all the objects in it will sit there in memory until your session ends. Here the calling procedure cleans up after it is done using the buffer handle:

/* … end of h-RunMakeBuff.p … */
DELETE PROCEDURE hProc.
MESSAGE " No more errors, the buffer is still"
    hMyBuf:NAME VIEW-AS ALERT-BOX.
DELETE WIDGET-POOL "MakeBuffPool".

When you run the procedure, the errors go away as shown in Figure 22–8, because the handle is valid until you delete the pool.

Figure 22–8: Result of named widget pool example

Remember that all these steps are important:

  1. You must give the widget pool a name if you want to allocate objects to it specifically or if you want it to outlive the procedure that creates it.
  2. The widget pool name is a character expression. So, if you are using a literal string rather than a variable for the name, you must remember to put it in quotes both where you create it and wherever you reference it. This is different from other CREATE statements, where you normally specify a handle variable as a target for the create and where the dynamic object you create doesn’t really have a name the same way that static objects do.
  3. You must also make a named pool PERSISTENT if you want it to outlive the procedure that creates it.
  4. You must remember to delete the widget pool when you are done using it, just as you delete individual dynamic objects that aren’t in a specific widget pool when you are done with them.

You might create a named widget pool that was not persistent simply to put different dynamic objects in different pools within a single procedure, so that you could delete one widget pool within the procedure without deleting objects in some other widget pool. In this case, all of the pools that haven’t been specifically deleted during the execution of the procedure are deleted when the procedure is deleted.

A widget pool cannot be created as PERSISTENT without giving it a name.

When you create a persistent named pool, its name effectively becomes global to the session. Any procedure running anywhere in the session can delete it. You cannot, however, access named widget pools through the SESSION handle as you can with some other kinds of objects, such as windows and procedures.

Making the best use of dynamic objects

There are a few simple rules for making the best use of dynamic objects without incurring memory leaks:

Avoiding stale handles

You saw the errors that you get if you inadvertently try to reference (or dereference, as the error message says) a stale handle. A stale handle is one that holds a pointer to an object that no longer exists. If you make sure that your objects always live just as long as they should, then you shouldn’t get this type of error. But it’s still very good practice to check the validity of a handle when there’s any chance that it might be invalid, which almost certainly includes whenever it comes from another procedure that might be maintained independently of the one that needs to use the handle.

You can use the VALID-HANDLE function to check whether a handle is valid. If you do use this check, then always determine how your procedure should react if a handle that ought to be valid turns out not to be. Simply substituting your own “Invalid handle!” message for the default one that Progress gives you is not the right approach. There are many things you can try to blame the end user of your application for, but supplying a procedure with an invalid object handle is not one of them.

Remember, too, that once you have encountered an invalid handle, whether it is a stale handle for a deleted object or simply a handle variable that was never properly initialized, your end user will get another ugly message for every statement in your procedure that vainly tries to reference it. In the Runmake.p example, the procedure generated three internal system errors because the buffer handle was not valid. It could easily have been a hundred errors, if the procedure had gone on to continue to try to work with that invalid handle. Never risk subjecting your end users to this abuse.

The VALID-HANDLE function is also very useful and necessary anytime your code is walking through a chain of handles, such as in this procedure that displays all the windows currently running in the session:

DEFINE VARIABLE hChild AS HANDLE     NO-UNDO.
hChild = SESSION:FIRST-CHILD.
REPEAT WHILE VALID-HANDLE(hChild):
   DISPLAY hChild:NAME FORMAT "X(30)"
       hChild:TYPE WITH FRAME F 10 DOWN.
    hChild = hChild:NEXT-SIBLING.
END.

You could also use the VALID-HANDLE function as a signal that tells your procedure whether an attempt to locate a particular object was successful or not, somewhat like the AVAILABLE function after a FIND statement on a buffer.

In early versions of Progress, it was possible (and quite likely) that if you deleted an object and then created another object, even an object of a different type, the new object would have the same handle value as the object you just deleted. Therefore, the VALID-HANDLE function could return true because the handle was pointing to a valid object, even though it was not the object you expected it to be. The TYPE and UNIQUE-ID object attributes exist largely to help you identify whether an object is the same one you referred to earlier. As of Progress Version 9.1C, this is no longer the case. Handles are allocated in such a way that you cannot have a handle variable that inadvertently points to a different object residing in the same memory location as one that was deleted earlier in the session. Therefore, you can be confident that when you use the VALID-HANDLE function, it is not only telling whether the object the handle points to is some valid object, but assuring you that it is the same object it always was before. Progress does this using a mechanism called opaque handles. You do not need to do anything special to get the benefit of them.

Deleting persistent procedures

You must clean up persistent procedure instances you have created just as with other objects. Remember that the RUN prog.p PERSISTENT statement is much the same as CREATE object. Progress creates a running instance of the procedure and all its contents, and leaves it in memory until you specifically delete it. The simplest way to delete a procedure when you’re done with it is to use this statement:

DELETE PROCEDURE procedure-handle.

Note that you use the PROCEDURE keyword in this statement, not OBJECT.

The AppBuilder templates observe a convention that is a very useful alternative to simply deleting a procedure. If you delete a procedure from the outside, it might not be finished doing what it needs to do and might not have a chance to clean up the objects inside it properly. It is much more reliable, and much more object-oriented, to tell the procedure to delete itself rather than to simply kill it. You’ve seen the way the AppBuilder-generated procedure handled this earlier, but it’s worth repeating in the context of this discussion about memory management.

The procedure’s main block runs a standard internal procedure called enable_UI and then waits for a CLOSE event if the procedure is not being run persistent. If it is being run persistent, then it stays in memory without the need for any WAIT-FOR statement:

/* Now enable the interface and wait for the exit condition.            */
/* (NOTE: handle ERROR and END-KEY so cleanup code will always fire.    */
MAIN-BLOCK:
DO ON ERROR UNDO MAIN-BLOCK, LEAVE MAIN-BLOCK
   ON END-KEY UNDO MAIN-BLOCK, LEAVE MAIN-BLOCK:
  RUN enable_UI.
  IF NOT THIS-PROCEDURE:PERSISTENT THEN
     WAIT-FOR CLOSE OF THIS-PROCEDURE.
END.

The WINDOW-CLOSE event on the window applies the procedural CLOSE event to the procedure:

DO:
  /* This event will close the window and terminate the procedure. */
  APPLY "CLOSE":U TO THIS-PROCEDURE.
  RETURN NO-APPLY.
END.

The main block also defines this CLOSE event, which runs the cleanup code in the disable_UI internal procedure, which again is part of the AppBuilder’s own standard mechanism for starting and stopping procedures:

/* The CLOSE event can be used from inside or outside the procedure to */
/* terminate it. */
ON CLOSE OF THIS-PROCEDURE
   RUN disable_UI.

And finally, disable_UI deletes any dynamic objects. Then, if the procedure was run persistent, it explicitly deletes itself. If it wasn’t run persistent, then Progress deletes it for you:

/* Delete the WINDOW we created */
  IF SESSION:DISPLAY-TYPE = "GUI":U AND VALID-HANDLE(C-Win)
  THEN DELETE WIDGET C-Win.
  IF THIS-PROCEDURE:PERSISTENT THEN DELETE PROCEDURE THIS-PROCEDURE.
END PROCEDURE.

Whether you use the AppBuilder templates or not, use some similar convention that makes sure your procedures clean up after themselves and deletes them when your application is done with them.

Deleting temp-table copies

When you pass a static temp-table from one procedure to another using the static TABLE parameter form, either locally or remotely, Progress takes care of deleting the temp-table and its contents when the procedure that defines it is deleted, just as it does for other static objects.

However, if you pass a temp-table through a TABLE-HANDLE, then you are creating a dynamic copy of the temp-table. Whether there is a static temp-table definition for it on one side of the call or the other, the TABLE-HANDLE parameter creates a dynamic temp-table. If a procedure passes a temp-table to another procedure using the INPUT TABLE-HANDLE form, then the calling procedure must take responsibility for deleting the temp-table when it’s done with it. If a procedure receives a temp-table from another procedure using the OUTPUT TABLE-HANDLE form, then the receiving procedure must delete its copy of the temp-table after it is done with it.

As with other dynamic objects, Progress does not know when you are done with a dynamic copy of a temp-table and therefore cannot delete it for you. You cannot allocate a temp-table to a widget pool, so this mechanism does not help you out. The temp-table is created in the SESSION widget pool and therefore stays there until it’s deleted or until the session ends. Because a temp-table is normally a much larger object than a dynamic button, query, or buffer, it is critically important that you delete temp-tables when you’re done with them. If you’re done with the data in the temp-table but need to use the table again, then use the EMPTY-TEMP-TABLE method on the table handle to remove the data. Delete the table itself using the DELETE OBJECT statement when you are done with it altogether.

Because dynamic temp-tables are allocated in the SESSION pool, you can locate them through the SESSION handle’s buffer chain. If the buffer has a valid TABLE-HANDLE attribute, then it’s a temp-table buffer and its TABLE-HANDLE points to the temp-table itself:

DEFINE VARIABLE hBuffer AS HANDLE     NO-UNDO.
hBuffer = SESSION:FIRST-BUFFER.
REPEAT WHILE VALID-HANDLE (hBuffer):
    IF VALID-HANDLE (hBuffer:TABLE-HANDLE) THEN
        DISPLAY hBuffer:NAME WITH FRAME F 10 DOWN.
    hBuffer = hBuffer:NEXT-SIBLING.
END.

Other object types

Memory management is also an issue for other object types that aren’t discussed in this book, but which this section mentions briefly:

There is a certain amount of information on these special types in OpenEdge Development: Progress 4GL Reference and online help, but you will find detailed information in OpenEdge Development: Programming Interfaces.

Conclusion

Memory management in Progress is not that difficult if you establish a standard set of templates for your procedures and an architecture that makes it clear what procedures are responsible for creating dynamic objects and deleting them, and when your session is done using them. If you are organized in your design and test your application carefully, you should have no trouble creating a reliable application that makes appropriate use of the power and flexibility of dynamic objects. Just remember the basic rule:

You create it, you delete it!

Conclusion to the book

This book has introduced you to the essentials of the Progress 4GL. Its purpose is to give you the skills you need to start to build successful business applications using the OpenEdge platform. Even in a book of this scope, it is not possible to discuss all the elements of the Progress 4GL. Language statements that support interfaces to other programming environments, such as socket support, support for generating and consuming XML documents, and others, are left to other documentation. Also, language statements used in constructing Progress-based reports and statements directly in support of the database and AppServer are discussed elsewhere. In addition, the many new features of OpenEdge Release 10 are covered in other documentation.

In any case, the language statements alone are never sufficient to build a complex enterprise application. It’s very important that you adopt an overall architecture for your application to give you the consistency and flexibility you need to complete your application and to maintain and extend it in a fast-changing world. OpenEdge provides components such as Progress SmartObjects, as well as the Progress Dynamics development framework to give you a basis for creating powerful and sophisticated applications quickly. Other OpenEdge 10 documentation also provides guidelines and principles for an overall application architecture. Whether you use OpenEdge-provided components or develop your own framework, you should plan the structure of your application carefully before you begin writing large numbers of Progress 4GL procedures. A thorough up-front design will save you tremendous effort in the long run and give you a basis for an application that will ensure your success long into the future.


Copyright © 2005 Progress Software Corporation
www.progress.com
Voice: (781) 280-4000
Fax: (781) 280-4095
Table of ContentsPreviousNextIndex