Quien opera software empresarial heredado en Delphi conoce la tensión: por un lado se desean objetos de dominio estructurados y capas claras, por otro existen Datasets, Variants, importaciones CSV, payloads de interfaz o una REST-API que de alguna forma deben mapearse a objetos. Es aquí donde con frecuencia se recurre a Delphi RTTI para mapeo sin magia: es decir, mapeo por reflexión (RTTI = Run-Time Type Information, información de tipos en tiempo de ejecución), pero de modo que siga siendo comprensible, depurable y no dependa en secreto de convenciones o juegos de nombres.
El punto clave: la „magia“ suele surgir no por la RTTI en sí, sino por reglas implícitas. Si, en cambio, las reglas de mapeo están explícitas en atributos, las conversiones están centralizadas y los errores indican una causa clara, la RTTI se convierte en una herramienta en lugar de una sorpresa.
Por qué el mapeo RTTI en Delphi suele fallar
El mapeo basado en RTTI rara vez fracasa por la idea en sistemas reales, sino por condiciones de contorno:
- Formas de datos heredados: Null/Empty/0 no están claramente diferenciados, los tipos de campo cambian, los strings contienen „N/A“.
- Convenciones que aparecen de forma gradual: „el campo se llama como la property“ funciona hasta el primer alias, join o nombre de propiedad refactorizado.
- Difícil de depurar: Si un mapper „simplemente no asigna nada“, después falta la causa. En producción eso es crítico.
- Mitos de rendimiento: RTTI se etiqueta en bloque como „lento“, aunque normalmente la falta de caching es el problema.
Por ello, un enfoque viable debería (1) tener metadatos de mapeo explícitos, (2) tratar claramente la conversión y la semántica de nulos, (3) proporcionar salidas de error y depuración y (4) cachear la información RTTI.
Delphi RTTI para mapeo sin magia: principios de diseño
El patrón siguiente es deliberadamente „aburrido“ en el mejor sentido: las reglas son visibles, los efectos secundarios están limitados y puede integrarse de forma gradual en módulos existentes.
- Atributos en lugar de convenciones de nombres: la propiedad recibe un atributo que nombra la columna fuente.
- Opt-in: solo se asignan las propiedades marcadas. No hay sorpresas por „todas las propiedades publicadas“.
- Conversión en un único lugar: Variant/String/Integer/Boolean/Enum/Nullable se mapean de forma centralizada.
- Modo de depuración: opcionalmente se registra qué campos fueron asignados/omitidos — con la razón.
- Caché RTTI: las partes más costosas (lista de propiedades, evaluación de atributos) se preparan por tipo.
Fragmento de código: mapeo por atributos con RTTI, caché y depuración
El fragmento mapea una fila (p. ej. de BDE-reemplazo con integración nativa vía TDataSet) a un objeto. En lugar de acoplar el mapper rígidamente a TField, usamos una pequeña interfaz reader. Esto es valioso en la práctica, porque usted podrá usar la misma lógica más adelante también para JSON, INI, CSV o respuestas de API.
unit RttiMapping;
interface
uses
System.SysUtils, System.Rtti, System.TypInfo, System.Generics.Collections,
System.Variants;
type
// Mapeo explícito: Property <- nombre de origen
MapFromAttribute = class(TCustomAttribute)
private
FName: string;
public
constructor Create(const AName: string);
property Name: string read FName;
end;
// Pequeña abstracción: proporcionar valor + distinguir existencia/NULL
IValueReader = interface
['{7D1E5864-7D3A-4D30-BD1C-0A94F7E6C0EF}']
function HasValue(const AName: string): Boolean;
function IsNull(const AName: string): Boolean;
function GetValue(const AName: string): Variant;
end;
TRttiMapOptions = set of (moDebug, moIgnoreMissing, moIgnoreNull);
ERttiMappingError = class(Exception);
TRttiMapper = class
private
type
TPropMap = record
Prop: TRttiProperty;
SourceName: string;
end;
TTypeCache = class
Props: TArray
end;
private
class var FCache: TObjectDictionary
class var FCacheLock: TObject;
class function GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache; static;
class function FindMapFromAttr(const AProp: TRttiProperty): string; static;
class procedure SetPropertyValue(const AInstance: TObject; const AProp: TRttiProperty;
const AValue: Variant); static;
class function VariantToBoolean(const V: Variant): Boolean; static;
class function VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer; static;
public
class constructor Create;
class destructor Destroy;
class procedure MapToObject(const AReader: IValueReader; const ATarget: TObject;
const AOptions: TRttiMapOptions = [moIgnoreMissing]); static;
end;
implementation
{ MapFromAttribute }
constructor MapFromAttribute.Create(const AName: string);
begin
inherited Create;
FName := AName;
end;
{ TRttiMapper }
class constructor TRttiMapper.Create;
begin
FCache := TObjectDictionary
FCacheLock := TObject.Create;
end;
class destructor TRttiMapper.Destroy;
begin
FCache.Free;
FCacheLock.Free;
end;
class function TRttiMapper.FindMapFromAttr(const AProp: TRttiProperty): string;
var
Attr: TCustomAttribute;
begin
Result := “;
for Attr in AProp.GetAttributes do
if Attr is MapFromAttribute then
Exit(MapFromAttribute(Attr).Name);
end;
class function TRttiMapper.GetOrBuildCache(ATypeInfo: PTypeInfo): TTypeCache;
var
Ctx: TRttiContext;
RType: TRttiType;
P: TRttiProperty;
L: TList
Src: string;
M: TPropMap;
begin
TMonitor.Enter(FCacheLock);
try
if FCache.TryGetValue(ATypeInfo, Result) then
Exit;
Result := TTypeCache.Create;
Ctx := TRttiContext.Create;
RType := Ctx.GetType(ATypeInfo);
L := TList
try
for P in RType.GetProperties do
begin
if not P.IsWritable then
Continue;
// Opt-in: solo Properties con atributo
Src := FindMapFromAttr(P);
if Src = “ then
Continue;
M.Prop := P;
M.SourceName := Src;
L.Add(M);
end;
Result.Props := L.ToArray;
finally
L.Free;
end;
FCache.Add(ATypeInfo, Result);
finally
TMonitor.Exit(FCacheLock);
end;
end;
class function TRttiMapper.VariantToBoolean(const V: Variant): Boolean;
var
S: string;
begin
if VarIsBool(V) then
Exit(V);
if VarIsNumeric(V) then
Exit(V <> 0);
S := Trim(VarToStr(V)).ToLower;
if (S = ‚1‘) or (S = ‚true‘) or (S = ‚t‘) or (S = ‚y‘) or (S = ‚yes‘) then
Exit(True);
if (S = ‚0‘) or (S = ‚false‘) or (S = ‚f‘) or (S = ’n‘) or (S = ’no‘) then
Exit(False);
raise ERttiMappingError.CreateFmt(‚Conversión a booleano fallida: „%s“‚, [VarToStr(V)]);
end;
class function TRttiMapper.VariantToEnumOrdinal(AEnumType: TRttiType; const V: Variant): Integer;
var
Ord: Integer;
Name: string;
begin
if VarIsNumeric(V) then
begin
Ord := Integer(V);
if (Ord < GetTypeData(AEnumType.Handle)^.MinValue) or
(Ord > GetTypeData(AEnumType.Handle)^.MaxValue) then
raise ERttiMappingError.CreateFmt(‚Ordinal de enum fuera de rango: %d‘, [Ord]);
Exit(Ord);
end;
Name := VarToStr(V);
Ord := GetEnumValue(AEnumType.Handle, Name);
if Ord < 0 then
raise ERttiMappingError.CreateFmt('Nombre de enum desconocido: "%s"', [Name]);
Result := Ord;
end;
class procedure TRttiMapper.SetPropertyValue(const AInstance: TObject;
const AProp: TRttiProperty; const AValue: Variant);
var
V: TValue;
T: TRttiType;
Ord: Integer;
begin
T := AProp.PropertyType;
// Conversión deliberadamente selectiva: mejor fallar de forma clara que fallar silenciosamente "de cualquier manera".
case T.TypeKind of
tkUString, tkString, tkLString, tkWString:
V := TValue.From
tkInteger, tkInt64:
V := TValue.From
tkFloat:
V := TValue.From
tkEnumeration:
begin
if T.Handle = TypeInfo(Boolean) then
V := TValue.From
else
begin
Ord := VariantToEnumOrdinal(T, AValue);
V := TValue.FromOrdinal(T.Handle, Ord);
end;
end;
tkSet:
raise ERttiMappingError.CreateFmt(‚Mapeo de set no implementado para %s‘, [AProp.Name]);
tkClass:
raise ERttiMappingError.CreateFmt(‚Mapeo de propiedades de clase no implementado para %s‘, [AProp.Name]);
else
raise ERttiMappingError.CreateFmt(‚TypeKind no soportado (%s) para %s‘,
[GetEnumName(TypeInfo(TTypeKind), Ord(T.TypeKind)), AProp.Name]);
end;
AProp.SetValue(AInstance, V);
end;
class procedure TRttiMapper.MapToObject(const AReader: IValueReader;
const ATarget: TObject; const AOptions: TRttiMapOptions);
var
Cache: TTypeCache;
M: TPropMap;
V: Variant;
Msg: string;
begin
if (ATarget = nil) or (AReader = nil) then
raise ERttiMappingError.Create(‚MapToObject: Reader o Target es nil‘);
Cache := GetOrBuildCache(ATarget.ClassInfo);
for M in Cache.Props do
begin
if not AReader.HasValue(M.SourceName) then
begin
if not (moIgnoreMissing in AOptions) then
raise ERttiMappingError.CreateFmt(‚Origen faltante: „%s“ para Property %s‘,
[M.SourceName, M.Prop.Name]);
Continue;
end;
if AReader.IsNull(M.SourceName) then
begin
if moIgnoreNull in AOptions then
Continue;
// Sin mecánica Nullable/Optional no es posible asignar NULL de forma sensata.
Continue;
end;
V := AReader.GetValue(M.SourceName);
try
SetPropertyValue(ATarget, M.Prop, V);
if moDebug in AOptions then
begin
Msg := Format(‚Mapeado %s <- %s (%s)‘, [M.Prop.Name, M.SourceName, VarTypeAsText(VarType(V))]);
OutputDebugString(PChar(Msg));
end;
except
on E: Exception do
raise ERttiMappingError.CreateFmt(‚Error de mapeo en %s <- %s: %s‘,
[M.Prop.Name, M.SourceName, E.Message]);
end;
end;
end;
end.
Para qué sirve
Obtendrá un mapeo que puede evaluarse claramente en revisiones de código:
- Cada Property mapeada está marcada visualmente (atributo).
- La conversión es centralizada; por tanto, coherente y comprobable.
- Los mensajes de error indican qué Property y qué fuente están afectadas.
- Un modo de depuración le proporciona, en caso de necesidad, la cadena de evidencias, sin que necesite breakpoints en el proceso productivo.
Condiciones y trampas típicas
- Semántica NULL: Sin un concepto propio de Nullable (p. ej.
Nullable<T>o Option-Types) asignar „NULL“ no es inequívoco. En el snippet NULL se omite por defecto. Esto es conservador y evita sobreescrituras silenciosas. - Duración de TRttiContext: Construimos el caché una vez por tipo y descartamos el Context después. Esto es habitual. Importante: no crear un nuevo RTTI-Context por cada asignación de campo.
- Threading: El caché está protegido mediante Monitor. En mapeos de alta concurrencia (p. ej. REST-Server) debería además considerar precargar el caché al inicio (Preload) para reducir la contención de locks.
- PropertyType Kind:
tkClassytkSetno están implementados a propósito. Para objetos anidados debería mapear recursivamente (con una política clara) o asignar manualmente de forma deliberada. - Trampas de locale:
varDoublemedianteVarAsTypees relativamente robusto, pero cadenas como „1,23“ vs. „1.23“ siguen siendo un problema. Si sus fuentes devuelven cadenas, suele ser mejor un parser propio (con Culture definida).
Variante para FireDAC y TDataSet: Reader-Adapter statt Mapper-Kopplung
En aplicaciones BDE-Ablosung mit nativer Anbindung o clásicas VCL/Win32 la fuente suele ser un TDataSet. En lugar de vincular el Mapper a TField, escriba un adaptador que implemente la interfaz IValueReader. La ventaja: el Mapper permanece independiente del acceso a datos (importante si desea externalizar el acceso a datos más adelante en servicios o en un REST-Server).
uses Data.DB, System.Variants, RttiMapping;
type
TDataSetValueReader = class(TInterfacedObject, IValueReader)
private
FDS: TDataSet;
public
constructor Create(ADS: TDataSet);
function HasValue(const AName: string): Boolean;
function IsNull(const AName: string): Boolean;
function GetValue(const AName: string): Variant;
end;
constructor TDataSetValueReader.Create(ADS: TDataSet);
begin
inherited Create;
FDS := ADS;
end;
function TDataSetValueReader.HasValue(const AName: string): Boolean;
begin
Result := (FDS <> nil) and (FDS.FindField(AName) <> nil);
end;
function TDataSetValueReader.IsNull(const AName: string): Boolean;
var
F: TField;
begin
F := FDS.FindField(AName);
Result := (F = nil) or F.IsNull;
end;
function TDataSetValueReader.GetValue(const AName: string): Variant;
begin
Result := FDS.FieldByName(AName).Value;
end;
Así queda un mapeo concreto:
type
TOrderRow = class
private
FId: Int64;
FCustomerNo: string;
FIsClosed: Boolean;
public
[MapFrom('order_id')]
property Id: Int64 read FId write FId;
[MapFrom('customer_no')]
property CustomerNo: string read FCustomerNo write FCustomerNo;
[MapFrom('is_closed')]
property IsClosed: Boolean read FIsClosed write FIsClosed;
end;
// ...
var
Row: TOrderRow;
Reader: IValueReader;
begin
Row := TOrderRow.Create;
try
Reader := TDataSetValueReader.Create(MyQuery);
TRttiMapper.MapToObject(Reader, Row, [moIgnoreMissing, moDebug, moIgnoreNull]);
finally
Row.Free;
end;
end;
Dónde merece la pena el enfoque — y dónde no
Este patrón suele ser útil en tres situaciones:
- Modernización gradual: Desea introducir objetos de dominio sin rehacer por completo el acceso a datos de inmediato (típico en la Delphi Modernización de aplicaciones existentes).
- Fronteras de interfaces: Importaciones CSV/Excel, REST-payloads o fuentes de datos «mixtas» requieren una conversión robusta y mensajes de error claros.
- Mantenibilidad en el equipo: Los atributos hacen visibles y revisables las reglas de mapeo, lo que en bases de código grandes es especialmente valioso.
También hay límites de aplicación claros:
- Grafos de objetos complejos (colecciones hijas, referencias cíclicas) no deben mapearse de forma «automágica». Aquí suele ser más estable un código explícito o un patrón separado de ensamblador/fábrica.
- Rutas críticas de alto rendimiento (p. ej. ETL de datos masivos) se benefician más de mapeadores generados por código o de mapeo optimizado manualmente, incluso si la RTTI está en caché.
- Nullable/Optional es un tema aparte. Si necesita distinguir realmente entre «no presente», «NULL» y «valor por defecto», debe expresarlo en el modelo de dominio, no ocultarlo en el mapper.
Integración en arquitectura y operación
Desde la perspectiva de la arquitectura, este mapper es un componente de infraestructura en la frontera entre la representación de datos y el dominio. No sustituye una separación de capas limpia, pero puede posibilitarla: el acceso a datos (FireDAC, SQL, vistas) puede seguir siendo pragmático, mientras que el dominio permanece consistente. En sistemas multicapa (a menudo denominados Layer-3 Arquitectura: UI, dominio/servicios, infraestructura) el mapper pertenece a la infraestructura y lo utilizan los servicios, no los formularios de la UI.
Operativamente importante: no active moDebug de forma permanente en servicios productivos, sino de manera selectiva. Para problemas de datos difíciles de reproducir conviene disponer de un canal de diagnóstico activable (configuración, feature-flag). De lo contrario, existe el riesgo de un volumen excesivo de logs y efectos secundarios.
Conclusión: RTTI sí, pero solo con directrices claras
Delphi RTTI para mapeo sin magia funciona bien cuando utiliza RTTI como herramienta para metadatos declarativos — no como una invitación a heurísticas ocultas. Atributos como opt‑in, conversión centralizada, caché por tipo y mensajes de error comprensibles elevan el tema de «opaco» a «operacional». El enfoque es deliberadamente no universal: para grafos anidados, semántica estricta de nulos o rendimiento máximo necesitará componentes adicionales. No obstante, como puente robusto entre conjuntos de datos/estructuras legacy y objetos de dominio más modernos, en muchas bases de código Delphi supone el paso pragmático que hace posible la modernización.
Si en una aplicación Delphi madura está atascado con las aristas de mapeo, la calidad de datos o la modernización por fases, podemos configurarlo conjuntamente de forma ordenada e integrarlo en su arquitectura: póngase en contacto.
En el entorno técnico también desempeñan un papel importante Delphi Rtti Mapping y Attribute Mapping Delphi cuando integraciones, flujos de datos y evolución deben operar de forma coherente.
Discutir un proyecto o iniciativa de modernización con Net-Base.