In this topic
- About Geodatabase API best practices
- Understanding recycling
- Storing FindField results
- Performing DDL inside of edit sessions
- Calling Store inside of Store-triggered events
- GetFeature and GetFeatures
- Careless reuse of variables
- Inserts and relationship class notification
- Modifying schema objects
About Geodatabase API best practices
This topic is intended to be a "cheat sheet" for developers using the Geodatabase API. There are many best practices that can improve performance and just as many common mistakes that can harm performance, or cause unexpected results. The information in this topic is a combination of both and is based on code examples found in support incidents, forum posts, and other third-party code.
The code examples in this topic are used to illustrate the text in the surrounding paragraphs and not simply to provide code that can be copied and pasted into an application. In some cases, the code is used to illustrate a programming pattern to avoid. Before copying and pasting any code in this topic, ensure from the code's surrounding paragraph text that it is an example of a practice to use and not an example of something to avoid.
Understanding recycling
Recycling is a property of cursors that determines how rows from the cursor are created. Recycling can be enabled or disabled and is exposed through the API as a Boolean parameter on several cursor instantiation methods, including ITable.Search and ISelectionSet.Search.
If recycling is enabled, a cursor only allocates memory for a single row regardless of how many rows are returned from the cursor. This provides performance benefits in terms of both memory usage and running time, but has drawbacks for certain workflows.
Recycling is useful in situations where only a single row is going to be referenced at any time, for example, drawing geometries or displaying the current row's ObjectID to a console window. When multiple rows from a cursor need to be compared in some way, or when rows are being edited, avoid recycling. Consider the following code example that compares the first two geometries from a feature cursor to see if they are equal:
[C#]
public static void RecyclingInappropriateExample(IFeatureClass featureClass, Boolean
enableRecycling)
{
using(ComReleaser comReleaser = new ComReleaser())
{
// Create a search cursor.
IFeatureCursor featureCursor = featureClass.Search(null, enableRecycling);
comReleaser.ManageLifetime(featureCursor);
// Get the first two geometries and see if they intersect.
IFeature feature1 = featureCursor.NextFeature();
IFeature feature2 = featureCursor.NextFeature();
IRelationalOperator relationalOperator = (IRelationalOperator)feature1.Shape;
Boolean geometriesEqual = relationalOperator.Equals(feature2.Shape);
Console.WriteLine("Geometries are equal: {0}", geometriesEqual);
}
}
[VB.NET]
Public Shared Sub RecyclingInappropriateExample(ByVal featureClass As IFeatureClass, ByVal enableRecycling As Boolean)
Using comReleaser As ComReleaser = New ComReleaser()
' Create a search cursor.
Dim featureCursor As IFeatureCursor = featureClass.Search(Nothing, enableRecycling)
comReleaser.ManageLifetime(featureCursor)
' Get the first two geometries and see if they intersect.
Dim feature1 As IFeature = featureCursor.NextFeature()
Dim feature2 As IFeature = featureCursor.NextFeature()
Dim relationalOperator As IRelationalOperator = CType(feature1.Shape, IRelationalOperator)
Dim geometriesEqual As Boolean = relationalOperator.Equals(feature2.Shape)
Console.WriteLine("Geometries are equal: {0}", geometriesEqual)
End Using
End Sub
If recycling is enabled, the preceding code always returns true because the feature1 and feature2 references will point to the same object. The second NextFeature call does not create a row, it overwrites the existing row's values. The call to IRelationalOperator.Equals is comparing a geometry to itself. For the same reason, any comparison between the ObjectID or attribute values of the "two" features also indicates equality.
Disabling recycling is a more cautious approach because inappropriate use of a non-recycling cursor is unlikely to return unexpected results like the one in the previous code example, but it can impose a significant penalty in performance.
The following code example opens a search cursor on a feature class and finds the sum of every feature's area. As this does not reference any previously fetched rows, this is an ideal candidate for a recycling cursor:
[C#]
public static void RecyclingAppropriateExample(IFeatureClass featureClass, Boolean
enableRecycling)
{
using(ComReleaser comReleaser = new ComReleaser())
{
// Create a search cursor.
IFeatureCursor featureCursor = featureClass.Search(null, enableRecycling);
comReleaser.ManageLifetime(featureCursor);
// Create a sum of each geometry's area.
IFeature feature = null;
double totalShapeArea = 0;
while ((feature = featureCursor.NextFeature()) != null)
{
IArea shapeArea = (IArea)feature.Shape;
totalShapeArea += shapeArea.Area;
}
Console.WriteLine("Total shape area: {0}", totalShapeArea);
}
}
[VB.NET]
Public Shared Sub RecyclingAppropriateExample(ByVal featureClass As IFeatureClass, ByVal enableRecycling As Boolean)
Using comReleaser As ComReleaser = New ComReleaser()
' Create a search cursor.
Dim featureCursor As IFeatureCursor = featureClass.Search(Nothing, enableRecycling)
comReleaser.ManageLifetime(featureCursor)
' Create a sum of each geometry's area.
Dim feature As IFeature = featureCursor.NextFeature()
Dim totalShapeArea As Double = 0
While Not feature Is Nothing
Dim shapeArea As IArea = CType(feature.Shape, IArea)
totalShapeArea + = shapeArea.Area
End While
Console.WriteLine("Total shape area: {0}", totalShapeArea)
End Using
End Sub
The preceding code example was tested on a feature class in a file geodatabase containing approximately 500,000 features with the following results (in the following, ~ indicates approximately):
- The process's working set increased by ~4 percent with recycling enabled as opposed to ~48 percent with recycling disabled.
- With recycling disabled, the method took ~2.25 times as long to run.
Other similar workflows can result in an even more dramatic difference when inappropriately using non-recycling cursors, such as a working set increase of nearly 250 percent and an execution time 12 times longer than with recycling enabled.
Storing FindField results
Methods, such as IClass.FindField and IFields.FindField are used to retrieve the position of a field in a dataset or a fields collection based on its name. Relying on FindField as opposed to hard-coded field positions is a good practice but overusing FindField can hinder performance. Consider the following code example, where the "NAME" attribute is retrieved from a cursor's features:
[C#]
public static void ExcessiveFindFieldCalls(IFeatureClass featureClass)
{
using(ComReleaser comReleaser = new ComReleaser())
{
// Open a cursor on the feature class.
IFeatureCursor featureCursor = featureClass.Search(null, true);
comReleaser.ManageLifetime(featureCursor);
// Display the NAME value from each feature.
IFeature feature = null;
while ((feature = featureCursor.NextFeature()) != null)
{
Console.WriteLine(feature.get_Value(featureClass.FindField("NAME")));
}
}
}
[VB.NET]
Public Shared Sub ExcessiveFindFieldCalls(ByVal featureClass As IFeatureClass)
Using comReleaser As ComReleaser = New ComReleaser()
' Open a cursor on the feature class.
Dim featureCursor As IFeatureCursor = featureClass.Search(Nothing, True)
comReleaser.ManageLifetime(featureCursor)
' Display the NAME value from each feature.
Dim feature As IFeature = featureCursor.NextFeature()
While Not feature Is Nothing
Console.WriteLine(feature.Value(featureClass.FindField("NAME")))
feature = featureCursor.NextFeature()
End While
End Using
End Sub
Although the FindField call is not an expensive operation, when large numbers of features are involved, the cost adds up. Changing the code so that the FindField result is reused typically increases performance by 3–10 percent (or more in some cases) and requires little effort.
The following code example shows an additional good practice (checking that FindField values are not –1). If a field cannot be found, FindField returns –1. If a value of –1 is then used as a parameter for the Value property (the get_Value and set_Value methods in C#), a descriptive error message is not returned, as Value has no way of knowing what field the client intended to access.
public static void SingleFindFieldCall(IFeatureClass featureClass)
{
using(ComReleaser comReleaser = new ComReleaser())
{
// Open a cursor on the feature class.
IFeatureCursor featureCursor = featureClass.Search(null, true);
comReleaser.ManageLifetime(featureCursor);
// Display the NAME value from each feature.
IFeature feature = null;
int nameIndex = featureClass.FindField("NAME");
// Make sure the FindField result is valid.
if (nameIndex == - 1)
{
throw new ArgumentException("The NAME field could not be found.");
}
while ((feature = featureCursor.NextFeature()) != null)
{
Console.WriteLine(feature.get_Value(nameIndex));
}
}
}
[VB.NET]
Public Shared Sub SingleFindFieldCall(ByVal featureClass As IFeatureClass)
Using comReleaser As ComReleaser = New ComReleaser()
' Open a cursor on the feature class.
Dim featureCursor As IFeatureCursor = featureClass.Search(Nothing, True)
comReleaser.ManageLifetime(featureCursor)
' Display the NAME value from each feature.
Dim feature As IFeature = featureCursor.NextFeature()
Dim nameIndex As Integer = featureClass.FindField("NAME")
' Make sure the FindField result is valid.
If nameIndex = -1 Then
Throw New ArgumentException("The NAME field could not be found.")
End If
While Not feature Is Nothing
Console.WriteLine(feature.Value(nameIndex))
feature = featureCursor.NextFeature()
End While
End Using
End Sub
Performing DDL inside of edit sessions
Data definition language (DDL) commands are database commands that modify the schema of a database. Examples include creating tables, adding a new field to a table, or dropping an index. Methods that trigger DDL commands, such as IFeatureWorkspace.CreateTable or IClass.AddField, should never be called inside an edit session, because DDL commands will commit any transactions that are currently open, making it impossible to rollback any unwanted edits if an error occurs.
This practice also extends to geodatabase schema modification that is not true DDL from a database perspective—such as modifying a domain—because these types of operations explicitly commit their changes. A real-world example of this is a custom editing application that adds new values to a coded value domain based on a user's edits, then fails unexpectedly when the application tries to commit the edits. The approach in cases like these is to maintain a list of values that the user has provided, then add them once the edit session has been stopped.
Calling Store inside of Store-triggered events
The Geodatabase API exposes several events that allow developers to apply custom behavior when Store is called on a method, such as IObjectClassEvents.OnCreate and IRelatedObjectClassEvents.RelatedObjectCreated. Developers implementing class extensions or event handlers that define custom behavior through these methods, and others like them, should ensure that Store is not called again on the row that triggered the event, even if the custom behavior caused the row to be modified. Calling Store on the object again triggers the event model from within the model, leading to unexpected behavior. In some cases, this results in infinite recursion causing an application to hang, while in others, errors are returned with messages that might be difficult to interpret.
The following code example shows a simple "timestamp" example that is intended to maintain the current user's name on features being created, but produces different varieties of errors depending on the data source:
[C#]
private static void EventHandlerInitialization(IFeatureClass featureClass)
{
IObjectClassEvents_Event objectClassEvents = (IObjectClassEvents_Event)
featureClass;
objectClassEvents.OnCreate += new IObjectClassEvents_OnCreateEventHandler
(OnCreateHandler);
}
private static void OnCreateHandler(IObject obj)
{
obj.set_Value(NAME_INDEX, Environment.UserName);
obj.Store(); // Do not do this!
}
[VB.NET]
Private Shared Sub EventHandlerInitialization(ByVal featureClass As IFeatureClass)
Dim objectClassEvents As IObjectClassEvents_Event = CType(featureClass, IObjectClassEvents_Event)
AddHandler objectClassEvents.OnCreate, AddressOf OnCreateHandler
End Sub
Private Shared Sub OnCreateHandler(ByVal obj As IObject)
obj.Value(NAME_INDEX) = Environment.UserName
obj.Store() ' Do not do this!
End Sub
GetFeature and GetFeatures
The IFeatureClass interface exposes two similar methods—GetFeature and GetFeatures—for retrieving features by their ObjectIDs. The former retrieves a single feature and takes an integer parameter, while the latter creates a cursor that returns the features specified in an integer array parameter (it also has a parameter that specifies whether the cursor will be recycling).
For performance purposes, anytime more than one feature is being retrieved using a known ObjectID, always use the GetFeatures method. Compare the following two code examples:
IFeatureClass.GetFeatures uses a conformant array parameter that makes it unsafe for use in .NET; IGeoDatabaseBridge.GetFeatures provides the same functionality in an interop-safe manner.
private static void GetFeatureExample(IFeatureClass featureClass, int[] oidList)
{
int nameFieldIndex = featureClass.FindField("NAME");
foreach (int oid in oidList)
{
IFeature feature = featureClass.GetFeature(oid);
Console.WriteLine("NAME: {0}", feature.get_Value(nameFieldIndex));
}
}
private static void GetFeaturesExample(IFeatureClass featureClass, int[] oidList)
{
int nameFieldIndex = featureClass.FindField("NAME");
using(ComReleaser comReleaser = new ComReleaser())
{
IGeoDatabaseBridge geodatabaseBridge = new GeoDatabaseHelperClass();
IFeatureCursor featureCursor = geodatabaseBridge.GetFeatures(featureClass,
ref oidList, true);
comReleaser.ManageLifetime(featureCursor);
IFeature feature = null;
while ((feature = featureCursor.NextFeature()) != null)
{
Console.WriteLine("NAME: {0}", feature.get_Value(nameFieldIndex));
}
}
}
[VB.NET]
Private Shared Sub GetFeatureExample(ByVal featureClass As IFeatureClass, ByVal oidList As Integer())
Dim nameFieldIndex As Integer = featureClass.FindField("NAME")
For Each oid As Integer In oidList
Dim feature As IFeature = featureClass.GetFeature(oid)
Console.WriteLine("NAME: {0}", feature.Value(nameFieldIndex))
Next
End Sub
Private Shared Sub GetFeaturesExample(ByVal featureClass As IFeatureClass, ByVal oidList As Integer())
Dim nameFieldIndex As Integer = featureClass.FindField("NAME")
Using comReleaser As ComReleaser = New ComReleaser()
Dim geodatabaseBridge As IGeoDatabaseBridge = New GeoDatabaseHelper()
Dim featureCursor As IFeatureCursor = geodatabaseBridge.GetFeatures(featureClass, oidList, True)
comReleaser.ManageLifetime(featureCursor)
Dim feature As IFeature = featureCursor.NextFeature()
While Not feature Is Nothing
Console.WriteLine("NAME: {0}", feature.Value(nameFieldIndex))
feature = featureCursor.NextFeature()
End While
End Using
End Sub
The preceding code examples have the same level of performance if a single feature is being requested, but the GetFeatures example outperforms the GetFeature example on as few as two features (especially with remote databases), and the difference between the two grows as more features are requested. With 100 features, the GetFeature example typically requires as much as 10–12 times to run, while with 1,000 features, it often takes up to 20 times as long.
Careless reuse of variables
The careless reuse of variables can cause two types of complications when working with the Geodatabase API. The first type of complication is most commonly seen when creating collections, such as sets of fields. See the following code example, which was intended to create a set of fields containing an ObjectID field and a string field:
[C#]
private static IFields FieldSetCreation()
{
// Create a field collection and a field.
IFields fields = new FieldsClass();
IFieldsEdit fieldsEdit = (IFieldsEdit)fields;
IField field = new FieldClass();
IFieldEdit fieldEdit = (IFieldEdit)field;
// Add an ObjectID field.
fieldEdit.Name_2 = "OBJECTID";
fieldEdit.Type_2 = esriFieldType.esriFieldTypeOID;
fieldsEdit.AddField(field);
// Add a text field.
fieldEdit.Name_2 = "NAME";
fieldEdit.Type_2 = esriFieldType.esriFieldTypeString;
fieldsEdit.AddField(field);
return fields;
}
[VB.NET]
Private Shared Function FieldSetCreation() As IFields
' Create a field collection and a field.
Dim fields As IFields = New FieldsClass()
Dim fieldsEdit As IFieldsEdit = CType(fields, IFieldsEdit)
Dim field As IField = New FieldClass()
Dim fieldEdit As IFieldEdit = CType(field, IFieldEdit)
' Add an ObjectID field.
fieldEdit.Name_2 = "OBJECTID"
fieldEdit.Type_2 = esriFieldType.esriFieldTypeOID
fieldsEdit.AddField(field)
' Add a text field.
fieldEdit.Name_2 = "NAME"
fieldEdit.Type_2 = esriFieldType.esriFieldTypeString
fieldsEdit.AddField(field)
Return fields
End Function
The reason this code does not work as anticipated might not be immediately apparent, and the error message returned when the resulting field set is used to create a table, might not help a much (it will be something to the effect of duplicate fields existing in the table). What is actually happening is the final field set contains two fields (two identical string fields). Since the "field" and "fieldEdit" variables still reference the ObjectID field that has been added, that field object is being modified, then added a second time to the collection. This can be avoided using the following two different approaches:
- Reassign the field and fieldEdit variables to a newly created field object after each field is added.
- Use a separate set of variables for each field that will be added to the collection, that is, "oidField" and "oidFieldEdit"
The second type of complication that results from careless reuse of variables is losing all references to objects that should be explicitly released using the ComReleaser class or the Marshal.ReleaseComObject method. Consider the following code example:
[C#]
private static void CursorReassignment(IFeatureClass featureClass, IQueryFilter
queryFilter)
{
// Execute a query...
IFeatureCursor featureCursor = featureClass.Search(queryFilter, true);
IFeature feature = null;
while ((feature = featureCursor.NextFeature()) != null)
{
// Do something with the feature...
}
// Re-execute the query...
featureCursor = featureClass.Search(queryFilter, true);
feature = null;
while ((feature = featureCursor.NextFeature()) != null)
{
// Do something with the feature...
}
// Release the cursor.
Marshal.ReleaseComObject(featureCursor);
}
[VB.NET]
Private Shared Sub CursorReassignment(ByVal featureClass As IFeatureClass, ByVal queryFilter As IQueryFilter)
' Execute a query...
Dim featureCursor As IFeatureCursor = featureClass.Search(queryFilter, True)
Dim feature As IFeature = featureCursor.NextFeature()
While Not feature Is Nothing
' Do something with the feature...
feature = featureCursor.NextFeature()
End While
' Re-execute the query...
featureCursor = featureClass.Search(queryFilter, True)
feature = featureCursor.NextFeature()
While Not feature Is Nothing
' Do something with the feature...
feature = featureCursor.NextFeature()
End While
' Release the cursor.
Marshal.ReleaseComObject(featureCursor)
End Sub
The problem that occurs in this kind of situation is that only the second cursor object that was instantiated is actually being released. Since the only reference to the first was lost, the first cursor is now dependent on being released by non-deterministic garbage collection. The same problem can also occur when the ComReleaser class is used. Lifetime management is object-specific, not variable-specific. For example, in the following code example, only the first cursor is properly managed:
[C#]
private static void CursorReassignment(IFeatureClass featureClass, IQueryFilter
queryFilter)
{
using(ComReleaser comReleaser = new ComReleaser())
{
// Execute a query...
IFeatureCursor featureCursor = featureClass.Search(queryFilter, true);
comReleaser.ManageLifetime(featureCursor);
IFeature feature = null;
while ((feature = featureCursor.NextFeature()) != null)
{
// Do something with the feature...
}
// Re-execute the query...
featureCursor = featureClass.Search(queryFilter, true);
feature = null;
while ((feature = featureCursor.NextFeature()) != null)
{
// Do something with the feature...
}
}
}
[VB.NET]
Private Shared Sub CursorReassignment(ByVal featureClass As IFeatureClass, ByVal queryFilter As IQueryFilter)
Using comReleaser As ComReleaser = New ComReleaser
' Execute a query...
Dim featureCursor As IFeatureCursor = featureClass.Search(queryFilter, True)
comReleaser.ManageLifetime(featureCursor)
Dim feature As IFeature = featureCursor.NextFeature()
While Not feature Is Nothing
' Do something with the feature...
feature = featureCursor.NextFeature()
End While
' Re-execute the query...
featureCursor = featureClass.Search(queryFilter, True)
feature = featureCursor.NextFeature()
While Not feature Is Nothing
' Do something with the feature...
feature = featureCursor.NextFeature()
End While
End Using
End Sub
Inserts and relationship class notification
Notification (also known as messaging) is a property of relationship classes that define which direction messages are sent between the two object classes participating in the relationship class. The following are the four types of notification:
- None—Typical for simple relationships
- Forward—Typical for composite relationships
- Backward
- Both (bi-directional)
These messages ensure the proper behavior of composite relationships, feature-linked annotation classes, and many custom class extensions. This behavior does come at a price, however. Edits and inserts to datasets that trigger notification is noticeably slower than the same operation on datasets that do not trigger any notification.
For inserts, this performance hit can be mitigated by ensuring that all notified classes are opened before any inserts taking place. The following code example is based on a schema where a parcels feature class participates in a composite relationship class with an Owners table, and inserts are being made to the feature class:
[C#]
public static void NotifiedClassEditsExample(IWorkspace workspace)
{
// Open the class that will be edited.
IFeatureWorkspace featureWorkspace = (IFeatureWorkspace)workspace;
IFeatureClass featureClass = featureWorkspace.OpenFeatureClass("PARCELS");
ITable table = featureWorkspace.OpenTable("OWNERS");
// Begin an edit session and operation.
IWorkspaceEdit workspaceEdit = (IWorkspaceEdit)workspace;
workspaceEdit.StartEditing(true);
workspaceEdit.StartEditOperation();
// Create a search cursor.
using(ComReleaser comReleaser = new ComReleaser())
{
IFeatureCursor featureCursor = featureClass.Insert(true);
comReleaser.ManageLifetime(featureCursor);
IFeatureBuffer featureBuffer = featureClass.CreateFeatureBuffer();
comReleaser.ManageLifetime(featureBuffer);
for (int i = 0; i < 1000; i++)
{
featureBuffer.Shape = CreateRandomPolygon();
featureCursor.InsertFeature(featureBuffer);
}
featureCursor.Flush();
}
// Commit the edits.
workspaceEdit.AbortEditOperation();
workspaceEdit.StopEditing(false);
}
[VB.NET]
Public Shared Sub NotifiedClassEditsExample(ByVal workspace As IWorkspace)
' Open the class that will be edited.
Dim featureWorkspace As IFeatureWorkspace = CType(workspace, IFeatureWorkspace)
Dim featureClass As IFeatureClass = featureWorkspace.OpenFeatureClass("PARCELS")
Dim Table As ITable = featureWorkspace.OpenTable("OWNERS")
' Begin an edit session and operation.
Dim workspaceEdit As IWorkspaceEdit = CType(workspace, IWorkspaceEdit)
workspaceEdit.StartEditing(True)
workspaceEdit.StartEditOperation()
' Create a search cursor.
Using comReleaser As ComReleaser = New ComReleaser()
Dim featureCursor As IFeatureCursor = featureClass.Insert(True)
comReleaser.ManageLifetime(featureCursor)
Dim featureBuffer As IFeatureBuffer = featureClass.CreateFeatureBuffer()
comReleaser.ManageLifetime(featureBuffer)
For i As Integer = 0 To 1000
featureBuffer.Shape = CreateRandomPolygon()
featureCursor.InsertFeature(featureBuffer)
Next i
featureCursor.Flush()
End Using
' Commit the edits.
workspaceEdit.AbortEditOperation()
workspaceEdit.StopEditing(False)
End Sub
The performance benefits of ensuring the notified class has been opened in this scenario is extremely significant. In the preceding case, where 1,000 features are inserted, failing to open the notified class typically causes an application to run for 10–15 times as long as it would with the notified class open. This is especially significant when a class triggers notification to multiple classes, as this factor is multiplied by the number of classes that are being notified (that is, a 50–75 times increase in running time if five unopened classes are being notified).
Modifying schema objects
Every type of geodatabase object—datasets, domains, fields, and so on—has a corresponding class in the API. Developers should be aware that these classes fall into two categories of the following behaviors:
- Those that automatically persist schema changes in the geodatabase, that is, tables
- Those that do not, that is, fields, domains, indexes
A classic example of this are the methods, IClass.AddField and IFieldsEdit.AddField. When the former is called, the API adds a field to the database table. When the latter is called, a field is added to the field collection in memory but no change is made to the actual table. Many developers have discovered the hard way that opening a table, getting a fields collection, and adding a new field to it is not the correct workflow.
Other invalid workflows include the following:
- Modifying fields that have already been created in the geodatabase using the IFieldEdit interface
- Modifying index collections that have already been created in the geodatabase using the IIndexesEdit interface
- Modifying indexes that have already been created in the geodatabase using the IIndexEdit interface
Another similar workflow is retrieving a domain from a workspace and making modifications to it, for example, adding a new code to a coded value domain. While these changes are not automatically persisted in the geodatabase, IWorkspaceDomains2.AlterDomain can be called to overwrite the persisted domain with the modified object.
To use the code in this topic, reference the following assemblies in your Visual Studio project. In the code files, you will need using (C#) or Imports (VB .NET) directives for the corresponding namespaces (given in parenthesis below if different from the assembly name):
ESRI.ArcGIS.ADF.Connection.Local ESRI.ArcGIS.Geodatabase ESRI.ArcGIS.System (ESRI.ArcGIS.esriSystem)