| title | Wrapping Derived Types |
|---|---|
| description | How prik wraps Fortran derived types as Python classes with methods, fields, constructors, and ownership rules |
| audience | users, advanced users |
| prerequisites | wrapping modules, data types |
| related | memory-management.md, generic-interfaces.md |
| status | maintained |
| publication | reviewed |
A supported Fortran type becomes a generated Python extension class.
Constructors and ordinary function results create independent Fortran
instances that are released with their Python objects. A nested component
belongs to its parent, and a module object belongs to the Fortran module.
Python accesses fields through generated getters and setters. Methods call
wrapped Fortran procedures. Python never reads the native memory layout
directly.
Create points.f90:
module points
implicit none
type :: point
real(8) :: x = 0.0_8
real(8) :: y = 0.0_8
end type point
type :: holder
type(point) :: origin
end type holder
contains
subroutine move(item, dx, dy)
type(point), intent(inout) :: item
real(8), intent(in) :: dx, dy
item%x = item%x + dx
item%y = item%y + dy
end subroutine move
function make_point(x, y) result(item)
real(8), intent(in) :: x, y
type(point) :: item
item%x = x
item%y = y
end function make_point
subroutine set_origin(container, item)
type(holder), intent(inout) :: container
type(point), intent(in) :: item
container%origin = item
end subroutine set_origin
end module pointsBuild it:
python3 -m prik points.f90 --out geometry --out-dir build/geometryimport sys
import numpy as np
sys.path.insert(0, "build/geometry")
import geometry.points as points
# Create new object
item = points.point(x=np.float64(1.0), y=np.float64(2.0))
# Call method (inout mutation)
points.move(item, np.float64(3.0), np.float64(4.0))
print(item.x, item.y) # 4.0 6.0
# Function returning derived type
made = points.make_point(np.float64(8.0), np.float64(9.0))
# Nested component
container = points.holder()
points.set_origin(container, made)
container.origin.x = np.float64(12.0)
print(container.origin.x) # 12.0The class docstring gives a short index:
print(points.point.__doc__)point
Opaque wrapper for native type point.
Constructor
-----------
point(*, x=0.0, y=0.0) -> point
Fields
------
x : float64
y : float64
The constructor has its own detailed docstring:
print(points.point.__init__.__doc__)- Lifetime: A constructed or returned object is released when its Python object is no longer used. A nested component stays tied to its parent.
- Mutation:
intent(out)andintent(inout)modify a caller-provided instance and do not return it again. - Missing intent: A dummy without
intentfollows the same conservative in-place rule asintent(inout). - Fields: Public scalar numeric/logical/complex fields become Python attributes.
- Nested types: Appear as generated objects tied to their parent.
- Results: Derived-type function results create new independent objects.
- Default constructor: Automatically generated from public, writable primitive scalar fields.
- Constructor fields: Passed by keyword (
logical,integer,real, andcomplex).
The default constructor assigns public fields directly. If the native module
already provides initialize_point(item, x, y), an edited contract can use it
as the constructor.
In this mapping, @bind selects the native initializer,
@native_call(...) gives its argument order, Pass() inserts the new
point, and Addr(Arg(i)) passes Python argument i by address:
from prik.contracts import Addr, Arg, Float64, Pass, bind, native_call
class point:
x: Float64
y: Float64
@bind("initialize_point")
@native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))])
def __init__(self, x: Float64, y: Float64) -> None: ...Replace the generated field-keyword __init__ declaration with this one.
The edit changes construction only; it does not create
initialize_point in the native module.
After rebuilding, points.point.__init__.__doc__ starts with
point(x, y) -> point and lists both parameters.
For the complete replacement rules, see Replace the Constructor.
A public type-bound procedure becomes a method on the generated class. The
passed object becomes self and is not repeated in the Python call.
type :: counter
integer(4) :: value = 0
contains
procedure :: increment
end type counter
contains
subroutine increment(self, amount)
class(counter), intent(inout) :: self
integer(4), intent(in) :: amount
self%value = self%value + amount
end subroutine incrementitem = counters.counter(value=np.int32(4))
item.increment(np.int32(3))
print(item.value) # 7The method mutates the existing counter; it does not replace the Python
object.
The move(item, dx, dy) procedure from this page's example can remain a
module-level function and also become point.move(dx, dy).
Pass() supplies self to the native call. Arg(i) refers to a visible
Python argument. Add the method to the existing point class while keeping
the module declaration:
from prik.contracts import Addr, Arg, Float64, Pass, native_call
class point:
@native_call([Pass(), Addr(Arg(0)), Addr(Arg(1))])
def move(self, dx: Float64, dy: Float64) -> None: ...
@native_call([Arg(0), Addr(Arg(1)), Addr(Arg(2))])
def move(item: point, dx: Float64, dy: Float64) -> None: ...Both declarations call the existing native move procedure:
points.move(item, np.float64(2.0), np.float64(3.0))
item.move(np.float64(2.0), np.float64(3.0))To expose only the method, import private and add @private to the
module-level declaration.
The class docstring now lists move(dx, dy) -> None under Methods.
points.point.move.__doc__ contains its complete parameter and return details.
For the complete mapping rules, see Expose a Module Procedure as a Method.
Fortran extension types generate a matching Python inheritance hierarchy. Inherited fields and methods remain available on the derived class, and an overridden type-bound method uses the derived implementation.
type :: base_shape
real(8) :: size
contains
procedure :: area => base_area
procedure :: set_size => base_set_size
end type base_shape
type, extends(base_shape) :: circle
real(8) :: radius
contains
procedure :: area => circle_area
end type circle
contains
real(8) function base_area(self) result(value)
class(base_shape), intent(in) :: self
value = self%size
end function base_area
subroutine base_set_size(self, value)
class(base_shape), intent(inout) :: self
real(8), intent(in) :: value
self%size = value
end subroutine base_set_size
real(8) function circle_area(self) result(value)
class(circle), intent(in) :: self
value = acos(-1.0_8) * self%radius * self%radius
end function circle_areaFor a wrapped module imported as shapes, the generated classes preserve that
relationship:
shape = shapes.circle()
assert isinstance(shape, shapes.base_shape)
shape.set_size(np.float64(5.0))
shape.radius = np.float64(2.0)
print(shape.size) # inherited field: 5.0
print(shape.area()) # overridden method: about 12.5664A required scalar class(base), intent(in) argument accepts wrapped instances
from the known base and descendant classes:
real(8) function describe_shape(item) result(value)
class(base_shape), intent(in) :: item
value = item%area()
end function describe_shapeprint(shapes.describe_shape(shape)) # about 12.5664This polymorphic boundary is intentionally limited to required scalar inputs.
Polymorphic outputs, mutable arguments, arrays, allocatable or pointer scalars,
and unlimited polymorphism (class(*)) are not supported.
A type-bound generic groups several concrete methods under one Python method.
For example, add can accept exact integer or real amounts:
type :: counter
integer(4) :: value = 0
contains
procedure :: add_integer
procedure :: add_real
generic :: add => add_integer, add_real
end type counterThe generated contract uses the same explicit overload links as a module-level generic:
from prik.contracts import Float64, Int32, bind, overload, private
class counter:
@private
def add_integer(self, amount: Int32) -> Int32: ...
@private
def add_real(self, amount: Float64) -> Float64: ...
@bind("add")
@overload("add_integer")
def add(self, amount: Int32) -> Int32: ...
@bind("add")
@overload("add_real")
def add(self, amount: Float64) -> Float64: ...print(item.add(np.int32(2))) # exact Int32 candidate
print(item.add(np.float64(0.5))) # exact Float64 candidateThe passed object participates in native dispatch but is already fixed by the
generated class. The remaining arguments must still match one candidate
exactly. Each @overload retains a concrete contract. @bind("add") routes
the native call through the public type-bound generic because its specifics
are private.
A defined operator with a wrapped derived-type operand becomes a Python magic method. Its overload candidates are attached to the generated class.
interface operator(+)
module procedure add_points
end interface operator(+)
contains
function add_points(left, right) result(output)
type(point), intent(in) :: left, right
type(point) :: output
output%x = left%x + right%x
output%y = left%y + right%y
end function add_pointsThe generated contract exposes operator(+) as __add__:
from prik.contracts import overload, private
class point:
@overload("add_points")
def __add__(self, right: point) -> point: ...
@private
def add_points(left: point, right: point) -> point: ...Python uses the normal operator:
left = points.point(x=np.float64(1.0), y=np.float64(2.0))
right = points.point(x=np.float64(3.0), y=np.float64(4.0))
total = left + right
print(total.x, total.y) # 4.0 6.0The magic method docstring shows the accepted operator signatures:
print(points.point.__add__.__doc__)The relevant part is:
__add__(*args, **kwargs)
Supported Signatures
--------------------
__add__(right: point) -> point
| Fortran generic | Python method | Python syntax |
|---|---|---|
Binary +, -, *, /, ** |
Direct and reflected magic methods | left + right |
Unary +, - |
__pos__, __neg__ |
+value, -value |
| Relational operators | __eq__, __lt__, and related methods |
left == right |
.and., .or., .not. |
__and__, __or__, __invert__ |
left & right, ~value |
Named operator .name. |
operator_name or r_operator_name |
Explicit method call |
assignment(=) |
assign |
target.assign(value) |
Python and, or, and not cannot be overloaded, so logical operator
generics use &, |, and ~. Python assignment only rebinds a name, so
defined assignment uses .assign(...).
At least one operand must be a wrapped derived type. Other operands can be supported primitive scalars, arrays, or generated classes. Their dispatch is exact.
For the overload rules shared by type-bound generics and operators, see Edit an Overload Set.
- Continue with Allocatables.
- Read Memory Management for the lifetime of native storage and NumPy views.