diff --git a/DOCS.md b/DOCS.md index e7457e4..a925763 100644 --- a/DOCS.md +++ b/DOCS.md @@ -25,6 +25,10 @@ The python wrapper supports keyword arguments for functions/methods. Hence, the - Methods - Constness has no effect. - Specify by-value (not reference) return types, even if C++ method returns reference. + - MATLAB maps a disengaged `std::optional` to `[]` and an engaged + optional to the normal wrapped representation of `T`. + `std::optional>` follows the existing pair convention: + two MATLAB outputs, both `[]` when the optional is disengaged. - Must start with a letter (upper or lowercase). - Overloads are supported. @@ -39,6 +43,10 @@ The python wrapper supports keyword arguments for functions/methods. Hence, the - C/C++ basic types: `string`, `bool`, `size_t`, `size_t`, `double`, `char`, `unsigned char`. - Any class with which be copied with `std::make_shared()` (except Eigen). - `std::shared_ptr` of any object type (except Eigen). + - For MATLAB, `std::optional` accepts `[]` for `std::nullopt` or the + normal MATLAB representation of `T` for an engaged value. Consequently, + an engaged optional containing an empty MATLAB array cannot be + distinguished from `std::nullopt`. - Properties or Variables - You can specify class variables in the interface file as long as they are in the `public` scope, e.g. diff --git a/gtwrap/matlab_wrapper/mixins.py b/gtwrap/matlab_wrapper/mixins.py index 910e8ca..6416f7a 100644 --- a/gtwrap/matlab_wrapper/mixins.py +++ b/gtwrap/matlab_wrapper/mixins.py @@ -90,7 +90,31 @@ def is_eigen_ref(self, arg_type) -> bool: and len(arg_type.template_params) == 1 and arg_type.template_params[0].typename.name in self.eigen_ref_types) - + + @staticmethod + def is_optional(arg_type: parser.Type) -> bool: + """Check whether ``arg_type`` is a ``std::optional``.""" + return (isinstance(arg_type, parser.TemplatedType) + and arg_type.typename.qualified_name() == 'std::optional' + and len(arg_type.template_params) == 1) + + def optional_value_type(self, arg_type: parser.Type) -> parser.Type: + """Return ``T`` from ``std::optional``. + + Raises: + ValueError: If ``arg_type`` is not a well-formed ``std::optional``. + """ + if not self.is_optional(arg_type): + raise ValueError(f'Expected std::optional, got {arg_type}') + return arg_type.template_params[0] + + @staticmethod + def is_pair(arg_type: parser.Type) -> bool: + """Check whether ``arg_type`` is a ``std::pair``.""" + return (isinstance(arg_type, parser.TemplatedType) + and arg_type.typename.qualified_name() in ('pair', 'std::pair') + and len(arg_type.template_params) == 2) + def is_class_enum(self, arg_type: parser.Type, class_: parser.Class): """Check if arg_type is an enum in the class `class_`.""" if class_: @@ -213,6 +237,18 @@ def _format_return_type(self, """ return_wrap = '' + optional_pair_types = self._optional_pair_types(return_type.type1) + if optional_pair_types: + return 'optional>'.format( + type1=self._format_type_name( + optional_pair_types[0].typename, + separator=separator, + include_namespace=include_namespace), + type2=self._format_type_name( + optional_pair_types[1].typename, + separator=separator, + include_namespace=include_namespace)) + if self._return_count(return_type) == 1: return_wrap = self._format_type_name( return_type.type1.typename, diff --git a/gtwrap/matlab_wrapper/wrapper.py b/gtwrap/matlab_wrapper/wrapper.py index 50b271c..8a574a5 100755 --- a/gtwrap/matlab_wrapper/wrapper.py +++ b/gtwrap/matlab_wrapper/wrapper.py @@ -228,6 +228,46 @@ def _wrap_args(self, args): return arg_wrap + def _matlab_type_check(self, + ctype, + variable, + wrap_datatypes=True): + """Return the MATLAB predicate used to dispatch one argument.""" + if self.is_optional(ctype): + value_check = self._matlab_type_check( + self.optional_value_type(ctype), variable, wrap_datatypes) + if not value_check: + return '' + return '({empty} || {value_check})'.format( + empty=f'isempty({variable})', value_check=value_check) + + name = ctype.typename.name + if name in self.not_check_type: + return '' + + check_type = self.data_type_param.get(name) + if self.data_type.get(check_type): + check_type = self.data_type[check_type] + + if check_type is None: + check_type = self._format_type_name( + ctype.typename, + separator='.', + is_constructor=not wrap_datatypes) + + checks = ["isa({variable},'{check_type}')".format( + variable=variable, check_type=check_type)] + if name == 'Vector': + checks.append(f'size({variable},2)==1') + if name == 'Point2': + checks.append(f'size({variable},1)==2') + checks.append(f'size({variable},2)==1') + if name == 'Point3': + checks.append(f'size({variable},1)==3') + checks.append(f'size({variable},2)==1') + + return ' && '.join(checks) + def _wrap_variable_arguments(self, args, wrap_datatypes=True): """ Wrap an interface_parser.ArgumentList into a statement of argument checks. @@ -240,36 +280,11 @@ def _wrap_variable_arguments(self, args, wrap_datatypes=True): var_arg_wrap = '' for i, arg in enumerate(args.list(), 1): - name = arg.ctype.typename.name - if name in self.not_check_type: - continue - - check_type = self.data_type_param.get(name) - - if self.data_type.get(check_type): - check_type = self.data_type[check_type] - - if check_type is None: - check_type = self._format_type_name( - arg.ctype.typename, - separator='.', - is_constructor=not wrap_datatypes) - - var_arg_wrap += " && isa(varargin{{{num}}},'{data_type}')".format( - num=i, data_type=check_type) - if name == 'Vector': - var_arg_wrap += ' && size(varargin{{{num}}},2)==1'.format( - num=i) - if name == 'Point2': - var_arg_wrap += ' && size(varargin{{{num}}},1)==2'.format( - num=i) - var_arg_wrap += ' && size(varargin{{{num}}},2)==1'.format( - num=i) - if name == 'Point3': - var_arg_wrap += ' && size(varargin{{{num}}},1)==3'.format( - num=i) - var_arg_wrap += ' && size(varargin{{{num}}},2)==1'.format( - num=i) + variable = f'varargin{{{i}}}' + check = self._matlab_type_check(arg.ctype, variable, + wrap_datatypes) + if check: + var_arg_wrap += f' && {check}' return var_arg_wrap @@ -315,37 +330,10 @@ def _wrap_method_check_statement(self, args: parser.ArgumentList): if self.is_eigen_ref(arg.ctype): continue - name = arg.ctype.typename.name - - if name in self.not_check_type: - arg_id += 1 - continue - - check_type = self.data_type_param.get(name) - - if self.data_type.get(check_type): - check_type = self.data_type[check_type] - - if check_type is None: - check_type = self._format_type_name(arg.ctype.typename, - separator='.') - - check_statement += " && isa(varargin{{{id}}},'{ctype}')".format( - id=arg_id, ctype=check_type) - - if name == 'Vector': - check_statement += ' && size(varargin{{{num}}},2)==1'.format( - num=arg_id) - if name == 'Point2': - check_statement += ' && size(varargin{{{num}}},1)==2'.format( - num=arg_id) - check_statement += ' && size(varargin{{{num}}},2)==1'.format( - num=arg_id) - if name == 'Point3': - check_statement += ' && size(varargin{{{num}}},1)==3'.format( - num=arg_id) - check_statement += ' && size(varargin{{{num}}},2)==1'.format( - num=arg_id) + variable = f'varargin{{{arg_id}}}' + check = self._matlab_type_check(arg.ctype, variable) + if check: + check_statement += f' && {check}' arg_id += 1 @@ -360,11 +348,54 @@ def _wrap_method_check_statement(self, args: parser.ArgumentList): return check_statement + def _unwrap_value_expression(self, + ctype, + value, + instantiated_class=None): + """Return a C++ expression that unwraps one MATLAB value.""" + if self.is_optional(ctype): + value_type = self.optional_value_type(ctype) + cpp_type = value_type.to_cpp() + inner = self._unwrap_value_expression(value_type, 'value', + instantiated_class) + return ('unwrap_optional<{cpp_type}>({value}, ' + '[](const mxArray* value) {{ return {inner}; }})').format( + cpp_type=cpp_type, value=value, inner=inner) + + ctype_camel = self._format_type_name(ctype.typename, separator='') + ctype_sep = self._format_type_name(ctype.typename) + + if instantiated_class and self.is_enum(ctype, instantiated_class): + enum_type = f'{ctype.typename}' + return f'unwrap_enum<{enum_type}>({value})' + + if self.is_matrix_view(ctype): + return f'unwrapMatrixView< {ctype_sep} >({value})' + + if self.is_ptr(ctype) and ctype.typename.name not in self.ignore_namespace: + return ('unwrap_ptr< {ctype} >({value}, "ptr_{camel}")'.format( + ctype=ctype_sep, value=value, camel=ctype_camel)) + + if ((self.is_shared_ptr(ctype) or self.can_be_pointer(ctype)) + and ctype.typename.name not in self.ignore_namespace): + unwrapped = ('unwrap_shared_ptr< {ctype} >({value}, ' + '"ptr_{camel}")').format(ctype=ctype_sep, + value=value, + camel=ctype_camel) + return unwrapped if self.is_shared_ptr(ctype) else f'*{unwrapped}' + + return f'unwrap< {ctype_sep} >({value})' + def _unwrap_argument(self, arg, arg_id=0, instantiated_class=None): ctype_camel = self._format_type_name(arg.ctype.typename, separator='') ctype_sep = self._format_type_name(arg.ctype.typename) - if instantiated_class and \ + if self.is_optional(arg.ctype): + arg_type = arg.ctype.get_typename() + unwrap = self._unwrap_value_expression( + arg.ctype, f'in[{arg_id}]', instantiated_class) + ';' + + elif instantiated_class and \ self.is_enum(arg.ctype, instantiated_class): enum_type = f"{arg.ctype.typename}" arg_type = f"{enum_type}" @@ -447,6 +478,7 @@ def _wrapper_unwrap_arguments(self, continue if not self.is_eigen_ref(arg.ctype) and \ + not self.is_optional(arg.ctype) and \ not self.is_ref(arg.ctype) and (self.is_shared_ptr(arg.ctype) or \ self.is_ptr(arg.ctype) or self.can_be_pointer(arg.ctype)) and \ not self.is_enum(arg.ctype, instantiated_class) and \ @@ -461,12 +493,22 @@ def _wrapper_unwrap_arguments(self, return params, body_args - @staticmethod - def _return_count(return_type): + def _optional_pair_types(self, ctype): + """Return the pair members from ``optional>``.""" + if not self.is_optional(ctype): + return None + value_type = self.optional_value_type(ctype) + if not self.is_pair(value_type): + return None + return tuple(value_type.template_params) + + def _return_count(self, return_type): """The amount of objects returned by the given interface_parser.ReturnType. """ - return 1 if return_type.type2 == '' else 2 + if return_type.type2 != '': + return 2 + return 2 if self._optional_pair_types(return_type.type1) else 1 def _wrapper_name(self): """Determine the name of wrapper function.""" @@ -863,10 +905,9 @@ def _group_class_methods(self, methods): """Group overloaded methods together""" return self._group_methods(methods) - @classmethod - def _format_varargout(cls, return_type, return_type_formatted): + def _format_varargout(self, return_type, return_type_formatted): """Determine format of return and varargout statements""" - if cls._return_count(return_type) == 1: + if self._return_count(return_type) == 1: varargout = '' \ if return_type_formatted == 'void' \ else 'varargout{1} = ' @@ -998,6 +1039,12 @@ def wrap_static_methods(self, namespace_name, instantiated_class, for static_overload in static_method: check_statement = self._wrap_method_check_statement( static_overload.args) + return_type_formatted = self._format_return_type( + static_overload.return_type, + include_namespace=True, + separator='.') + varargout = self._format_varargout( + static_overload.return_type, return_type_formatted) end_statement = '' \ if check_statement == '' \ @@ -1008,21 +1055,19 @@ def wrap_static_methods(self, namespace_name, instantiated_class, method_text += textwrap.indent(textwrap.dedent('''\ % {name_caps} usage: {name_upper_case}({args}) : returns {return_type} % Doxygen can be found at https://gtsam.org/doxygen/ - {check_statement}{spacing}varargout{{1}} = {wrapper}({id}, varargin{{:}});{end_statement} + {check_statement}{spacing}{varargout}{wrapper}({id}, varargin{{:}});{end_statement} ''').format( name=''.join(format_name), name_caps=static_overload.name.upper(), name_upper_case=static_overload.name, args=self._wrap_args(static_overload.args), - return_type=self._format_return_type( - static_overload.return_type, - include_namespace=True, - separator="."), + return_type=return_type_formatted, length=len(static_overload.args.list()), var_args_list=self._wrap_variable_arguments( static_overload.args), check_statement=check_statement, spacing='' if check_statement == '' else ' ', + varargout=varargout, wrapper=self._wrapper_name(), id=self._update_wrapper_id( (namespace_name, instantiated_class, @@ -1271,6 +1316,59 @@ def wrap_collector_function_shared_return(self, id=func_id, new_line=new_line) + def _collector_wrap_expression(self, + obj, + ctype, + instantiated_class=None): + """Return the expression that converts one C++ value to mxArray*.""" + if self.is_optional(ctype): + value_type = self.optional_value_type(ctype) + cpp_type = value_type.to_cpp() + wrapped = self._collector_wrap_expression('value', value_type, + instantiated_class) + return ('wrap_optional({obj}, [](const {cpp_type}& value) {{ ' + 'return {wrapped}; }})').format(obj=obj, + cpp_type=cpp_type, + wrapped=wrapped) + + if instantiated_class and self.is_enum(ctype, instantiated_class): + if self.is_class_enum(ctype, instantiated_class): + class_name = '.'.join(instantiated_class.namespaces()[1:] + + [instantiated_class.name]) + else: + class_name = '.'.join( + instantiated_class.parent.full_namespaces()[1:]) + if class_name: + class_name += '.' + return f'wrap_enum({obj},"{class_name}{ctype.typename.name}")' + + ctype_cpp = self._format_type_name(ctype.typename) + if ((self.is_shared_ptr(ctype) or self.is_ptr(ctype)) + and ctype.typename.name in self.ignore_namespace): + return f'wrap< {ctype_cpp} >(*{obj})' + + if (self.is_shared_ptr(ctype) or self.is_ptr(ctype) + or self.can_be_pointer(ctype)): + if self.is_shared_ptr(ctype): + shared_obj = obj + elif self.is_ptr(ctype): + shared_obj = f'std::make_shared<{ctype_cpp}>(*{obj})' + else: + shared_obj = f'std::make_shared<{ctype_cpp}>({obj})' + + matlab_name = self._format_type_name(ctype.typename, + separator='.') + is_virtual = any( + cls.name == ctype.typename.name and cls.is_virtual + for cls in self.classes) + return ('wrap_shared_ptr({shared_obj}, "{matlab_name}", ' + '{is_virtual})').format( + shared_obj=shared_obj, + matlab_name=matlab_name, + is_virtual='true' if is_virtual else 'false') + + return f'wrap< {ctype_cpp} >({obj})' + def wrap_collector_function_return_types(self, return_type, func_id): """ Wrap the return type of the collector function when a std::pair is returned. @@ -1316,7 +1414,12 @@ def _collector_return(self, """Helper method to get the final statement before the return in the collector function.""" expanded = '' - if instantiated_class and \ + if self.is_optional(ctype): + expanded = ' out[0] = {wrapped};'.format( + wrapped=self._collector_wrap_expression( + obj, ctype, instantiated_class)) + + elif instantiated_class and \ self.is_enum(ctype, instantiated_class): if self.is_class_enum(ctype, instantiated_class): class_name = ".".join(instantiated_class.namespaces()[1:] + @@ -1348,17 +1451,6 @@ def _collector_return(self, obj=obj, method_name_sep=sep_method_name('.')) else: method_name_sep_dot = sep_method_name('.') - - # Specialize for std::optional so we access the underlying member - #TODO(Varun) How do we handle std::optional as a Mex type? - if isinstance(ctype, parser.TemplatedType) and \ - "std::optional" == str(ctype.typename)[:13]: - obj = f"*{obj}" - type_name = ctype.template_params[0].typename - method_name_sep_dot = ".".join( - type_name.namespaces) + f".{type_name.name}" - - shared_obj_template = 'std::make_shared<{method_name_sep_col}>({obj}),' \ '"{method_name_sep_dot}"' shared_obj = shared_obj_template \ @@ -1426,7 +1518,24 @@ def wrap_collector_function_return(self, method, instantiated_class=None): obj += '{}{}({})'.format(obj_start, method_name, params) if return_1_name != 'void': - if return_count == 1: + optional_pair_types = self._optional_pair_types(return_1) + if optional_pair_types: + expanded += ' auto optionalPairResult = {};\n'.format(obj) + expanded += ' if (optionalPairResult) {\n' + expanded += ' const auto& pairResult = *optionalPairResult;\n' + expanded += textwrap.indent( + self.wrap_collector_function_return_types( + optional_pair_types[0], 0), + prefix=' ') + expanded += textwrap.indent( + self.wrap_collector_function_return_types( + optional_pair_types[1], 1), + prefix=' ') + expanded += '\n } else {\n' + expanded += ' out[0] = mxCreateDoubleMatrix(0, 0, mxREAL);\n' + expanded += ' out[1] = mxCreateDoubleMatrix(0, 0, mxREAL);\n' + expanded += ' }' + elif return_count == 1: expanded += self._collector_return( obj, return_1, instantiated_class=instantiated_class) @@ -1641,7 +1750,8 @@ def generate_collector_function(self, func_id): # Setter if "_set_" in method_name: - is_ptr_type = self.can_be_pointer(extra.ctype) and \ + is_ptr_type = not self.is_optional(extra.ctype) and \ + self.can_be_pointer(extra.ctype) and \ not self.is_enum(extra.ctype, collector_func[1]) return_body = ' obj->{0} = {1}{0};'.format( extra.name, '*' if is_ptr_type else '') diff --git a/matlab.h b/matlab.h index ae559a9..3139101 100644 --- a/matlab.h +++ b/matlab.h @@ -40,12 +40,14 @@ extern "C" { #include #include #include +#include #include #include #include #include #include #include +#include using namespace std; @@ -151,6 +153,19 @@ mxArray* wrap(const Class& value) { return wrapDefault(value, IsMatlabSizeOrKeyScalar()); } +/** + * Wrap a C++ optional as either MATLAB [] or the normally wrapped value. + * + * The callback keeps ownership policy in the generated wrapper: scalar and + * matrix values are copied into MATLAB arrays, while wrapped class values can + * be copied into a shared_ptr-backed MATLAB proxy. + */ +template +mxArray* wrap_optional(const std::optional& value, Wrapper&& wrapper) { + if (!value) return mxCreateDoubleMatrix(0, 0, mxREAL); + return std::forward(wrapper)(*value); +} + // specialization to string // wraps into a character array template<> @@ -299,6 +314,15 @@ T unwrap(const mxArray* array) { return unwrapDefault(array, IsMatlabSizeOrKeyScalar()); } +/** Unwrap MATLAB [] as nullopt, or unwrap an engaged optional's value. */ +template +std::optional unwrap_optional(const mxArray* array, + Unwrapper&& unwrapper) { + if (mxIsEmpty(array)) return std::nullopt; + return std::optional{ + std::forward(unwrapper)(array)}; +} + /// @brief Unwrap from matlab array to C++ enum type /// @tparam T The C++ enum type /// @param array Matlab mxArray diff --git a/tests/fixtures/optionals.i b/tests/fixtures/optionals.i new file mode 100644 index 0000000..833cb60 --- /dev/null +++ b/tests/fixtures/optionals.i @@ -0,0 +1,23 @@ +#include + +namespace gtsam { + +class Pose3 { + Pose3(); + + std::optional threshold; + + static std::optional MaybePose(bool available); + + std::optional maybeVector(bool available) const; + std::optional> maybeGaussian( + bool available) const; + + void acceptOptionalDouble(const std::optional& value) const; + void acceptOptionalPose(const std::optional& value) const; + void acceptOptionalVector(const std::optional& value) const; +}; + +} + +std::optional maybeDouble(bool available); diff --git a/tests/test_matlab_wrapper.py b/tests/test_matlab_wrapper.py index 30974e1..9aca27e 100644 --- a/tests/test_matlab_wrapper.py +++ b/tests/test_matlab_wrapper.py @@ -197,6 +197,89 @@ def test_eigen_ref_jacobians(self): self.assertIn('gtsam::Pose3::Expmap(xi,Hxi)', cpp_content) self.assertIn('out[1] = wrap< Eigen::MatrixXd >(Hxi);', cpp_content) self.assertIn('checkArguments("gtsam::Pose3.Expmap",nargout,nargin,1);', cpp_content) + + def test_std_optional(self): + """Test MATLAB [] <-> std::nullopt and engaged value conversion.""" + file = osp.join(self.INTERFACE_DIR, 'optionals.i') + + wrapper = MatlabWrapper(module_name='optionals', + top_module_namespace=['gtsam'], + ignore_classes=['']) + wrapper.wrap([file], path=self.MATLAB_ACTUAL_DIR) + + cpp_file = osp.join(self.MATLAB_ACTUAL_DIR, + 'optionals_wrapper.cpp') + with open(cpp_file, 'r', encoding='UTF-8') as f: + cpp_content = f.read() + + m_file = osp.join(self.MATLAB_ACTUAL_DIR, '+gtsam', 'Pose3.m') + with open(m_file, 'r', encoding='UTF-8') as f: + matlab_content = f.read() + + # Empty arrays select nullopt; engaged values retain normal type checks. + self.assertIn( + "(isempty(varargin{1}) || isa(varargin{1},'double'))", + matlab_content) + self.assertIn( + "(isempty(varargin{1}) || isa(varargin{1},'gtsam.Pose3'))", + matlab_content) + self.assertIn( + "(isempty(varargin{1}) || isa(varargin{1},'double')" + " && size(varargin{1},2)==1)", matlab_content) + self.assertNotIn("isa(varargin{1},'std.optional", matlab_content) + + # Primitive and class-value optionals recurse through the contained + # type's established wrapping policy. + self.assertIn( + 'wrap_optional(maybeDouble(available), ' + '[](const double& value) { return wrap< double >(value); })', + cpp_content) + self.assertIn( + 'wrap_optional(gtsam::Pose3::MaybePose(available), ' + '[](const gtsam::Pose3& value) { return ' + 'wrap_shared_ptr(std::make_shared(value), ' + '"gtsam.Pose3", false); })', cpp_content) + # Arguments and properties use values, not fake optional proxy classes. + self.assertIn( + 'std::optional value = unwrap_optional(in[1], ' + '[](const mxArray* value) { return unwrap< double >(value); });', + cpp_content) + self.assertIn( + 'std::optional value = ' + 'unwrap_optional(in[1], ' + '[](const mxArray* value) { return ' + '*unwrap_shared_ptr< gtsam::Pose3 >' + '(value, "ptr_gtsamPose3"); });', cpp_content) + self.assertIn('obj->threshold = threshold;', cpp_content) + self.assertNotIn('obj->threshold = *threshold;', cpp_content) + + # A pair keeps the wrapper's conventional two-output MATLAB API; an + # empty optional produces [] for both outputs. + self.assertIn( + '[ varargout{1} varargout{2} ] = optionals_wrapper(', + matlab_content) + self.assertIn('auto optionalPairResult = ' + 'obj->maybeGaussian(available);', cpp_content) + self.assertIn('const auto& pairResult = *optionalPairResult;', + cpp_content) + self.assertIn('out[0] = wrap< Vector >(pairResult.first);', + cpp_content) + self.assertIn('out[1] = wrap< Matrix >(pairResult.second);', + cpp_content) + self.assertGreaterEqual( + cpp_content.count('mxCreateDoubleMatrix(0, 0, mxREAL)'), 2) + + self.assertNotIn('make_shared unwrap_optional(', + header_content) + self.assertIn('if (mxIsEmpty(array)) return std::nullopt;', + header_content) def test_functions(self): """Test interface file with function info."""